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

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

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 || "");