Дерево истории, удаление версий, мультизагрузка, редактирование с референсом и областью, подсказки промптов
This commit is contained in:
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