Дерево истории, удаление версий, мультизагрузка, редактирование с референсом и областью, подсказки промптов
This commit is contained in:
27
README.md
27
README.md
@@ -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`.
|
||||
|
||||
## Логирование
|
||||
|
||||
Все действия сервера пишутся в стандартный вывод контейнера — смотрите их в
|
||||
|
||||
988
public/app.js
988
public/app.js
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
366
public/style.css
366
public/style.css
@@ -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
391
server.js
@@ -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 || "");
|
||||
|
||||
Reference in New Issue
Block a user