Save current image generation workspace
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
import Database from "better-sqlite3";
|
||||
import { existsSync, mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const dataDir = join(/* turbopackIgnore: true */ process.cwd(), "data");
|
||||
const dbPath = join(dataDir, "imagegen.sqlite");
|
||||
|
||||
let db: Database.Database | null = null;
|
||||
|
||||
export function getDataDir() {
|
||||
if (!existsSync(dataDir)) {
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
|
||||
return dataDir;
|
||||
}
|
||||
|
||||
export function getDb() {
|
||||
getDataDir();
|
||||
|
||||
if (!db) {
|
||||
db = new Database(dbPath);
|
||||
db.pragma("journal_mode = WAL");
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS history (
|
||||
id TEXT PRIMARY KEY,
|
||||
mode TEXT NOT NULL,
|
||||
prompt TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
size TEXT NOT NULL,
|
||||
quality TEXT NOT NULL,
|
||||
output_format TEXT NOT NULL,
|
||||
image_url TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS canvas_projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
db.prepare(
|
||||
"INSERT OR IGNORE INTO settings (key, value) VALUES ('retentionDays', '7')",
|
||||
).run();
|
||||
}
|
||||
|
||||
return db;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
defaultCanvasProjectData,
|
||||
type CanvasProjectData,
|
||||
type CanvasProjectListItem,
|
||||
type CanvasProjectRecord,
|
||||
isCanvasProjectData,
|
||||
} from "@/lib/canvas/types";
|
||||
import { getDb } from "@/lib/server/db";
|
||||
|
||||
type CanvasProjectRow = {
|
||||
id: string;
|
||||
title: string;
|
||||
data: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export function listCanvasProjects(): CanvasProjectListItem[] {
|
||||
const rows = getDb()
|
||||
.prepare(
|
||||
`SELECT id, title, data, created_at AS createdAt, updated_at AS updatedAt
|
||||
FROM canvas_projects
|
||||
ORDER BY updated_at DESC`,
|
||||
)
|
||||
.all() as CanvasProjectRow[];
|
||||
|
||||
return rows.map((row) => {
|
||||
const data = parseCanvasProjectData(row.data);
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
nodeCount: data.nodes.length,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function getCanvasProjectById(id: string): CanvasProjectRecord | null {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
`SELECT id, title, data, created_at AS createdAt, updated_at AS updatedAt
|
||||
FROM canvas_projects
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.get(id) as CanvasProjectRow | undefined;
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
data: parseCanvasProjectData(row.data),
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function createCanvasProject(input?: {
|
||||
title?: string;
|
||||
data?: CanvasProjectData;
|
||||
}) {
|
||||
const now = new Date().toISOString();
|
||||
const id = crypto.randomUUID();
|
||||
const title = input?.title?.trim() || "未命名画布";
|
||||
const data = input?.data ?? defaultCanvasProjectData;
|
||||
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO canvas_projects (id, title, data, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(id, title, JSON.stringify(data), now, now);
|
||||
|
||||
return getCanvasProjectById(id);
|
||||
}
|
||||
|
||||
export function updateCanvasProject(
|
||||
id: string,
|
||||
patch: {
|
||||
title?: string;
|
||||
data?: CanvasProjectData;
|
||||
},
|
||||
) {
|
||||
const current = getCanvasProjectById(id);
|
||||
if (!current) return null;
|
||||
|
||||
const title = patch.title?.trim() || current.title;
|
||||
const data = patch.data ?? current.data;
|
||||
const updatedAt = new Date().toISOString();
|
||||
|
||||
getDb()
|
||||
.prepare(
|
||||
`UPDATE canvas_projects
|
||||
SET title = ?, data = ?, updated_at = ?
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.run(title, JSON.stringify(data), updatedAt, id);
|
||||
|
||||
return getCanvasProjectById(id);
|
||||
}
|
||||
|
||||
export function deleteCanvasProject(id: string) {
|
||||
const result = getDb()
|
||||
.prepare("DELETE FROM canvas_projects WHERE id = ?")
|
||||
.run(id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
function parseCanvasProjectData(raw: string): CanvasProjectData {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
return isCanvasProjectData(parsed) ? parsed : defaultCanvasProjectData;
|
||||
} catch {
|
||||
return defaultCanvasProjectData;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { existsSync, readdirSync, statSync, unlinkSync } from "node:fs";
|
||||
import {
|
||||
type CanvasImageNodeMetadata,
|
||||
isCanvasProjectData,
|
||||
} from "@/lib/canvas/types";
|
||||
import { getDb } from "@/lib/server/db";
|
||||
import { getImageDir } from "@/lib/server/storage/image-file-storage";
|
||||
|
||||
export type StoredImage = {
|
||||
id: string;
|
||||
mode: string;
|
||||
prompt: string;
|
||||
model: string;
|
||||
size: string;
|
||||
quality: string;
|
||||
outputFormat: string;
|
||||
imageUrl: string;
|
||||
filePath: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
let cleanupTimerStarted = false;
|
||||
|
||||
export function startHistoryCleanupTimer() {
|
||||
if (cleanupTimerStarted) return;
|
||||
cleanupTimerStarted = true;
|
||||
const timer = setInterval(
|
||||
() => {
|
||||
cleanupExpiredHistory();
|
||||
},
|
||||
60 * 60 * 1000,
|
||||
);
|
||||
timer.unref?.();
|
||||
}
|
||||
|
||||
export function getRetentionDays() {
|
||||
const row = getDb()
|
||||
.prepare("SELECT value FROM settings WHERE key = 'retentionDays'")
|
||||
.get() as { value?: string } | undefined;
|
||||
const parsed = Number(row?.value ?? "7");
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 7;
|
||||
}
|
||||
|
||||
export function setRetentionDays(days: number) {
|
||||
const normalized = Number.isFinite(days) && days >= 0 ? Math.floor(days) : 7;
|
||||
getDb()
|
||||
.prepare(
|
||||
"INSERT INTO settings (key, value) VALUES ('retentionDays', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
)
|
||||
.run(String(normalized));
|
||||
cleanupExpiredHistory();
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function insertHistory(record: StoredImage) {
|
||||
startHistoryCleanupTimer();
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO history
|
||||
(id, mode, prompt, model, size, quality, output_format, image_url, file_path, created_at)
|
||||
VALUES
|
||||
(@id, @mode, @prompt, @model, @size, @quality, @outputFormat, @imageUrl, @filePath, @createdAt)`,
|
||||
)
|
||||
.run(record);
|
||||
}
|
||||
|
||||
export function listHistory() {
|
||||
cleanupExpiredHistory();
|
||||
return getDb()
|
||||
.prepare(
|
||||
`SELECT id, mode, prompt, model, size, quality,
|
||||
output_format AS outputFormat,
|
||||
image_url AS imageUrl,
|
||||
file_path AS filePath,
|
||||
created_at AS createdAt
|
||||
FROM history
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 100`,
|
||||
)
|
||||
.all() as StoredImage[];
|
||||
}
|
||||
|
||||
export function cleanupExpiredHistory() {
|
||||
startHistoryCleanupTimer();
|
||||
const days = getRetentionDays();
|
||||
if (days === 0) {
|
||||
deleteRowsBefore(new Date());
|
||||
cleanupOrphanedImages();
|
||||
return;
|
||||
}
|
||||
|
||||
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
||||
deleteRowsBefore(cutoff);
|
||||
cleanupOrphanedImages();
|
||||
}
|
||||
|
||||
function deleteRowsBefore(cutoff: Date) {
|
||||
const expired = getDb()
|
||||
.prepare("SELECT file_path AS filePath FROM history WHERE created_at < ?")
|
||||
.all(cutoff.toISOString()) as Pick<StoredImage, "filePath">[];
|
||||
|
||||
for (const item of expired) {
|
||||
removeFile(item.filePath);
|
||||
}
|
||||
|
||||
getDb().prepare("DELETE FROM history WHERE created_at < ?").run(cutoff.toISOString());
|
||||
}
|
||||
|
||||
function cleanupOrphanedImages() {
|
||||
const imageDir = getImageDir();
|
||||
if (!existsSync(imageDir)) return;
|
||||
|
||||
const livePaths = new Set<string>([
|
||||
...(getDb().prepare("SELECT file_path AS filePath FROM history").all() as Pick<
|
||||
StoredImage,
|
||||
"filePath"
|
||||
>[]).map((item) => item.filePath),
|
||||
...collectCanvasImagePaths(),
|
||||
]);
|
||||
|
||||
for (const entry of readdirSync(imageDir)) {
|
||||
const filePath = `${imageDir}\\${entry}`;
|
||||
try {
|
||||
if (statSync(filePath).isFile() && !livePaths.has(filePath)) {
|
||||
unlinkSync(filePath);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort cleanup; stale files can be retried on the next request.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collectCanvasImagePaths() {
|
||||
const rows = getDb()
|
||||
.prepare("SELECT data FROM canvas_projects")
|
||||
.all() as { data: string }[];
|
||||
|
||||
const filePaths = new Set<string>();
|
||||
|
||||
for (const row of rows) {
|
||||
try {
|
||||
const parsed = JSON.parse(row.data) as unknown;
|
||||
if (!isCanvasProjectData(parsed)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const node of parsed.nodes) {
|
||||
if (node.type !== "image") continue;
|
||||
const metadata = node.metadata as CanvasImageNodeMetadata;
|
||||
if (metadata.filePath) {
|
||||
filePaths.add(metadata.filePath);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed project payloads so cleanup cannot break reads.
|
||||
}
|
||||
}
|
||||
|
||||
return [...filePaths];
|
||||
}
|
||||
|
||||
function removeFile(filePath: string) {
|
||||
try {
|
||||
if (filePath && existsSync(filePath) && statSync(filePath).isFile()) {
|
||||
unlinkSync(filePath);
|
||||
}
|
||||
} catch {
|
||||
// Ignore deletion errors so history cleanup cannot break generation.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,665 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import OpenAI, { toFile } from "openai";
|
||||
import type { ImagesResponse } from "openai/resources/images";
|
||||
import {
|
||||
cleanupExpiredHistory,
|
||||
insertHistory,
|
||||
type StoredImage,
|
||||
} from "@/lib/server/repositories/history-repository";
|
||||
import { persistBase64Image } from "@/lib/server/storage/image-file-storage";
|
||||
|
||||
const identityGuard =
|
||||
"When editing a person or group photo, strictly preserve all original identities, facial features, expressions, pose, clothing layout, body proportions, and composition. Do not add, remove, crop, replace, redraw, or distort any person. Change only the requested lighting, color, background polish, clarity, and photographic finish.";
|
||||
const openAITimeoutMs = 270_000;
|
||||
const openAIMaxRetries = 0;
|
||||
const jobRetentionMs = 60 * 60 * 1000;
|
||||
|
||||
type ImageMode = "generate" | "edit";
|
||||
type OutputFormat = "png" | "jpeg" | "webp";
|
||||
type Quality = "low" | "medium" | "high" | "auto";
|
||||
type LogLevel = "info" | "error";
|
||||
type JobStatus = "queued" | "running" | "succeeded" | "failed";
|
||||
|
||||
export type UploadedFile = {
|
||||
buffer: Buffer;
|
||||
name: string;
|
||||
type: string;
|
||||
size: number;
|
||||
};
|
||||
|
||||
type ImageJobInput = {
|
||||
mode: ImageMode;
|
||||
prompt: string;
|
||||
model: string;
|
||||
size: string;
|
||||
quality: Quality;
|
||||
outputFormat: OutputFormat;
|
||||
preserveIdentity: boolean;
|
||||
images: UploadedFile[];
|
||||
mask?: UploadedFile;
|
||||
};
|
||||
|
||||
type ImageJobResult = {
|
||||
id: string;
|
||||
imageUrl: string;
|
||||
model: string;
|
||||
size: string;
|
||||
quality: Quality;
|
||||
outputFormat: OutputFormat;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type ImageJobError = {
|
||||
error: string;
|
||||
status: number;
|
||||
code?: string;
|
||||
type?: string;
|
||||
param?: string;
|
||||
upstreamStatus?: number;
|
||||
upstreamRequestId?: string;
|
||||
details?: unknown;
|
||||
};
|
||||
|
||||
type ImageJob = {
|
||||
jobId: string;
|
||||
requestId: string;
|
||||
status: JobStatus;
|
||||
phase: string;
|
||||
progressMessage: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
durationMs?: number;
|
||||
result?: ImageJobResult;
|
||||
error?: ImageJobError;
|
||||
};
|
||||
|
||||
const globalForImageJobs = globalThis as typeof globalThis & {
|
||||
imageJobs?: Map<string, ImageJob>;
|
||||
};
|
||||
const imageJobs = globalForImageJobs.imageJobs ?? new Map<string, ImageJob>();
|
||||
globalForImageJobs.imageJobs = imageJobs;
|
||||
|
||||
export async function submitImageJobRequest(request: Request) {
|
||||
cleanupOldJobs();
|
||||
|
||||
const requestId =
|
||||
request.headers.get("x-client-request-id") || crypto.randomUUID();
|
||||
const startedAt = performance.now();
|
||||
let phase = "init";
|
||||
const log = createLogger(requestId, startedAt, () => phase);
|
||||
|
||||
try {
|
||||
log("info", "job submission received", {
|
||||
method: request.method,
|
||||
contentType: request.headers.get("content-type"),
|
||||
contentLength: request.headers.get("content-length"),
|
||||
userAgent: request.headers.get("user-agent"),
|
||||
});
|
||||
|
||||
phase = "cleanup";
|
||||
cleanupExpiredHistory();
|
||||
|
||||
const apiKey = process.env.OPENAI_API_KEY;
|
||||
const baseURL = process.env.OPENAI_BASE_URL;
|
||||
|
||||
if (!apiKey) {
|
||||
phase = "validate-env";
|
||||
log("error", "missing OPENAI_API_KEY");
|
||||
return jsonWithRequestId(
|
||||
{
|
||||
error: "Missing OPENAI_API_KEY in .env.local",
|
||||
requestId,
|
||||
phase,
|
||||
durationMs: elapsedMs(startedAt),
|
||||
},
|
||||
500,
|
||||
requestId,
|
||||
);
|
||||
}
|
||||
|
||||
phase = "parse-form";
|
||||
const formData = await request.formData();
|
||||
const input = await parseImageJobInput(formData);
|
||||
|
||||
log("info", "form parsed", {
|
||||
mode: input.mode,
|
||||
model: input.model,
|
||||
size: input.size,
|
||||
quality: input.quality,
|
||||
outputFormat: input.outputFormat,
|
||||
preserveIdentity: input.preserveIdentity,
|
||||
promptChars: input.prompt.length,
|
||||
promptPreview: preview(input.prompt),
|
||||
imageCount: input.images.length,
|
||||
imageNames: input.images.map((image) => image.name),
|
||||
imageTypes: input.images.map((image) => image.type),
|
||||
imageBytes: input.images.map((image) => image.size),
|
||||
hasMask: Boolean(input.mask),
|
||||
maskName: input.mask?.name,
|
||||
maskType: input.mask?.type,
|
||||
maskBytes: input.mask?.size,
|
||||
baseURL: baseURL ? redactUrl(baseURL) : "default",
|
||||
});
|
||||
|
||||
phase = "validate-input";
|
||||
const validationError = validateImageJobInput(input);
|
||||
if (validationError) {
|
||||
log("error", validationError);
|
||||
return jsonWithRequestId(
|
||||
{
|
||||
error: validationError,
|
||||
requestId,
|
||||
phase,
|
||||
durationMs: elapsedMs(startedAt),
|
||||
},
|
||||
400,
|
||||
requestId,
|
||||
);
|
||||
}
|
||||
|
||||
phase = "queue-job";
|
||||
const job = createJob(requestId);
|
||||
imageJobs.set(job.jobId, job);
|
||||
log("info", "image job queued", { jobId: job.jobId });
|
||||
|
||||
void runImageJob(job, input, {
|
||||
apiKey,
|
||||
baseURL: baseURL || undefined,
|
||||
submittedAt: startedAt,
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
jobId: job.jobId,
|
||||
requestId,
|
||||
status: job.status,
|
||||
phase: job.phase,
|
||||
progressMessage: job.progressMessage,
|
||||
pollUrl: `/api/images?jobId=${job.jobId}`,
|
||||
durationMs: elapsedMs(startedAt),
|
||||
},
|
||||
{
|
||||
status: 202,
|
||||
headers: {
|
||||
"x-request-id": requestId,
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
const details = serializeError(error);
|
||||
const status = normalizeStatus(details.status);
|
||||
const durationMs = elapsedMs(startedAt);
|
||||
log("error", "job submission failed", {
|
||||
durationMs,
|
||||
status,
|
||||
error: details,
|
||||
});
|
||||
return jsonWithRequestId(
|
||||
{
|
||||
error: details.message,
|
||||
requestId,
|
||||
phase,
|
||||
durationMs,
|
||||
status,
|
||||
code: details.code,
|
||||
type: details.type,
|
||||
param: details.param,
|
||||
upstreamStatus: details.status,
|
||||
upstreamRequestId: details.requestId,
|
||||
details: details.detail,
|
||||
},
|
||||
status,
|
||||
requestId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function getImageJobResponse(request: Request) {
|
||||
cleanupOldJobs();
|
||||
|
||||
const url = new URL(request.url);
|
||||
const jobId = url.searchParams.get("jobId");
|
||||
|
||||
if (!jobId) {
|
||||
return NextResponse.json({ error: "Missing jobId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const job = imageJobs.get(jobId);
|
||||
if (!job) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Image job not found or expired",
|
||||
jobId,
|
||||
},
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
jobId: job.jobId,
|
||||
requestId: job.requestId,
|
||||
status: job.status,
|
||||
phase: job.phase,
|
||||
progressMessage: job.progressMessage,
|
||||
createdAt: job.createdAt,
|
||||
updatedAt: job.updatedAt,
|
||||
startedAt: job.startedAt,
|
||||
completedAt: job.completedAt,
|
||||
durationMs: job.durationMs,
|
||||
result: job.result,
|
||||
error: job.error?.error,
|
||||
code: job.error?.code,
|
||||
type: job.error?.type,
|
||||
param: job.error?.param,
|
||||
upstreamStatus: job.error?.upstreamStatus,
|
||||
upstreamRequestId: job.error?.upstreamRequestId,
|
||||
details: job.error?.details,
|
||||
},
|
||||
{
|
||||
status: job.status === "failed" ? job.error?.status ?? 500 : 200,
|
||||
headers: {
|
||||
"x-request-id": job.requestId,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createJob(requestId: string): ImageJob {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
jobId: crypto.randomUUID(),
|
||||
requestId,
|
||||
status: "queued",
|
||||
phase: "queued",
|
||||
progressMessage: "任务已提交,等待开始生成",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
async function runImageJob(
|
||||
job: ImageJob,
|
||||
input: ImageJobInput,
|
||||
config: { apiKey: string; baseURL?: string; submittedAt: number },
|
||||
) {
|
||||
const startedAt = performance.now();
|
||||
let phase = "start-job";
|
||||
const log = createLogger(job.requestId, config.submittedAt, () => phase);
|
||||
|
||||
try {
|
||||
updateJob(job, {
|
||||
status: "running",
|
||||
phase,
|
||||
progressMessage: "任务已开始,正在准备图片模型请求",
|
||||
startedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const client = new OpenAI({
|
||||
apiKey: config.apiKey,
|
||||
baseURL: config.baseURL,
|
||||
timeout: openAITimeoutMs,
|
||||
maxRetries: openAIMaxRetries,
|
||||
});
|
||||
|
||||
const finalPrompt =
|
||||
input.preserveIdentity && input.mode === "edit"
|
||||
? `${input.prompt.trim()}\n\nConstraints: ${identityGuard}`
|
||||
: input.prompt.trim();
|
||||
|
||||
const common = {
|
||||
model: input.model,
|
||||
prompt: finalPrompt,
|
||||
n: 1,
|
||||
size: input.size,
|
||||
quality: input.quality,
|
||||
output_format: input.outputFormat,
|
||||
} as const;
|
||||
|
||||
phase =
|
||||
input.mode === "edit" ? "openai-images-edit" : "openai-images-generate";
|
||||
updateJob(job, {
|
||||
phase,
|
||||
progressMessage: "正在调用图片模型,前端会持续轮询结果",
|
||||
});
|
||||
log("info", "calling OpenAI image API", {
|
||||
jobId: job.jobId,
|
||||
mode: input.mode,
|
||||
model: input.model,
|
||||
size: input.size,
|
||||
quality: input.quality,
|
||||
outputFormat: input.outputFormat,
|
||||
timeoutMs: openAITimeoutMs,
|
||||
maxRetries: openAIMaxRetries,
|
||||
});
|
||||
|
||||
const result =
|
||||
input.mode === "edit"
|
||||
? await editImage(client, common, input.images, input.mask)
|
||||
: await client.images.generate(common);
|
||||
|
||||
log("info", "OpenAI image API completed", {
|
||||
jobId: job.jobId,
|
||||
upstreamDurationMs: elapsedMs(startedAt),
|
||||
dataItems: result.data?.length ?? 0,
|
||||
hasImageData: Boolean(result.data?.[0]?.b64_json),
|
||||
responseKeys: Object.keys(result),
|
||||
});
|
||||
|
||||
phase = "validate-openai-response";
|
||||
updateJob(job, {
|
||||
phase,
|
||||
progressMessage: "图片模型已返回,正在校验图片数据",
|
||||
});
|
||||
|
||||
const b64 = result.data?.[0]?.b64_json;
|
||||
if (!b64) {
|
||||
throw Object.assign(new Error("The image API returned no image data"), {
|
||||
status: 502,
|
||||
});
|
||||
}
|
||||
|
||||
phase = "save-image";
|
||||
updateJob(job, {
|
||||
phase,
|
||||
progressMessage: "正在保存图片到本地",
|
||||
});
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const persisted = persistBase64Image(id, input.outputFormat, b64);
|
||||
const createdAt = new Date().toISOString();
|
||||
const resultPayload = {
|
||||
id,
|
||||
imageUrl: persisted.imageUrl,
|
||||
model: input.model,
|
||||
size: input.size,
|
||||
quality: input.quality,
|
||||
outputFormat: input.outputFormat,
|
||||
createdAt,
|
||||
};
|
||||
|
||||
phase = "save-history";
|
||||
updateJob(job, {
|
||||
phase,
|
||||
progressMessage: "正在写入历史记录",
|
||||
});
|
||||
|
||||
const historyRecord: StoredImage = {
|
||||
...resultPayload,
|
||||
mode: input.mode,
|
||||
prompt: input.prompt,
|
||||
filePath: persisted.filePath,
|
||||
};
|
||||
insertHistory(historyRecord);
|
||||
|
||||
phase = "complete";
|
||||
updateJob(job, {
|
||||
status: "succeeded",
|
||||
phase,
|
||||
progressMessage: "图片已生成",
|
||||
completedAt: new Date().toISOString(),
|
||||
durationMs: elapsedMs(startedAt),
|
||||
result: resultPayload,
|
||||
});
|
||||
log("info", "image job completed", {
|
||||
jobId: job.jobId,
|
||||
imageUrl: persisted.imageUrl,
|
||||
durationMs: job.durationMs,
|
||||
});
|
||||
} catch (error) {
|
||||
const details = serializeError(error);
|
||||
const status = normalizeStatus(details.status);
|
||||
phase = phase || "failed";
|
||||
updateJob(job, {
|
||||
status: "failed",
|
||||
phase,
|
||||
progressMessage: "图片生成失败",
|
||||
completedAt: new Date().toISOString(),
|
||||
durationMs: elapsedMs(startedAt),
|
||||
error: {
|
||||
error: details.message,
|
||||
status,
|
||||
code: details.code,
|
||||
type: details.type,
|
||||
param: details.param,
|
||||
upstreamStatus: details.status,
|
||||
upstreamRequestId: details.requestId,
|
||||
details: details.detail,
|
||||
},
|
||||
});
|
||||
log("error", "image job failed", {
|
||||
jobId: job.jobId,
|
||||
durationMs: job.durationMs,
|
||||
status,
|
||||
error: details,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function updateJob(job: ImageJob, patch: Partial<ImageJob>) {
|
||||
Object.assign(job, patch, { updatedAt: new Date().toISOString() });
|
||||
}
|
||||
|
||||
async function parseImageJobInput(formData: FormData): Promise<ImageJobInput> {
|
||||
const images = formData.getAll("image");
|
||||
const mask = formData.get("mask");
|
||||
|
||||
return {
|
||||
mode: getString(formData, "mode", "generate") as ImageMode,
|
||||
prompt: getString(formData, "prompt"),
|
||||
model: getString(formData, "model", "gpt-image-2-2k"),
|
||||
size: getString(formData, "size", "auto"),
|
||||
quality: normalizeQuality(getString(formData, "quality", "high")),
|
||||
outputFormat: normalizeOutputFormat(
|
||||
getString(formData, "outputFormat", "png"),
|
||||
),
|
||||
preserveIdentity: getString(formData, "preserveIdentity", "true") === "true",
|
||||
images: await Promise.all(
|
||||
images
|
||||
.filter((image): image is File => image instanceof File)
|
||||
.map((image) => readUploadedFile(image)),
|
||||
),
|
||||
mask: mask instanceof File ? await readUploadedFile(mask) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function validateImageJobInput(input: ImageJobInput) {
|
||||
if (!input.prompt.trim()) {
|
||||
return "Prompt is required";
|
||||
}
|
||||
|
||||
if (input.mode === "edit" && input.images.length === 0) {
|
||||
return "Edit mode requires an uploaded image";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function readUploadedFile(file: File): Promise<UploadedFile> {
|
||||
return {
|
||||
buffer: Buffer.from(await file.arrayBuffer()),
|
||||
name: file.name || "upload.png",
|
||||
type: file.type || "image/png",
|
||||
size: file.size,
|
||||
};
|
||||
}
|
||||
|
||||
function cleanupOldJobs() {
|
||||
const cutoff = Date.now() - jobRetentionMs;
|
||||
for (const [jobId, job] of imageJobs.entries()) {
|
||||
if (new Date(job.createdAt).getTime() < cutoff) {
|
||||
imageJobs.delete(jobId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function jsonWithRequestId(
|
||||
body: Record<string, unknown>,
|
||||
status: number,
|
||||
requestId: string,
|
||||
) {
|
||||
return NextResponse.json(body, {
|
||||
status,
|
||||
headers: {
|
||||
"x-request-id": requestId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createLogger(
|
||||
requestId: string,
|
||||
startedAt: number,
|
||||
getPhase: () => string,
|
||||
) {
|
||||
return (
|
||||
level: LogLevel,
|
||||
message: string,
|
||||
data: Record<string, unknown> = {},
|
||||
) => {
|
||||
const payload = {
|
||||
requestId,
|
||||
route: "/api/images",
|
||||
phase: getPhase(),
|
||||
elapsedMs: elapsedMs(startedAt),
|
||||
message,
|
||||
...data,
|
||||
};
|
||||
const line = `[images:${level}] ${JSON.stringify(payload)}`;
|
||||
if (level === "error") {
|
||||
console.error(line);
|
||||
} else {
|
||||
console.info(line);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function getString(formData: FormData, key: string, fallback = "") {
|
||||
const value = formData.get(key);
|
||||
return typeof value === "string" && value.length > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
function normalizeQuality(value: string): Quality {
|
||||
return ["low", "medium", "high", "auto"].includes(value)
|
||||
? (value as Quality)
|
||||
: "high";
|
||||
}
|
||||
|
||||
function normalizeOutputFormat(value: string): OutputFormat {
|
||||
return ["png", "jpeg", "webp"].includes(value)
|
||||
? (value as OutputFormat)
|
||||
: "png";
|
||||
}
|
||||
|
||||
async function editImage(
|
||||
client: OpenAI,
|
||||
common: {
|
||||
model: string;
|
||||
prompt: string;
|
||||
n: 1;
|
||||
size: string;
|
||||
quality: Quality;
|
||||
output_format: OutputFormat;
|
||||
},
|
||||
images: UploadedFile[],
|
||||
mask: UploadedFile | undefined,
|
||||
): Promise<ImagesResponse> {
|
||||
if (images.length === 0) {
|
||||
throw new Error("Edit mode requires an uploaded image");
|
||||
}
|
||||
|
||||
const uploads = await Promise.all(
|
||||
images.map((image, index) =>
|
||||
toFile(image.buffer, image.name || `input-image-${index + 1}.png`, {
|
||||
type: image.type || "image/png",
|
||||
}),
|
||||
),
|
||||
);
|
||||
const editParams = {
|
||||
...common,
|
||||
image: uploads.length === 1 ? uploads[0] : uploads,
|
||||
};
|
||||
|
||||
if (mask && mask.size > 0) {
|
||||
const maskUpload = await toFile(mask.buffer, mask.name || "mask.png", {
|
||||
type: mask.type || "image/png",
|
||||
});
|
||||
|
||||
return client.images.edit({
|
||||
...editParams,
|
||||
mask: maskUpload,
|
||||
});
|
||||
}
|
||||
|
||||
return client.images.edit(editParams);
|
||||
}
|
||||
|
||||
function elapsedMs(startedAt: number) {
|
||||
return Math.round(performance.now() - startedAt);
|
||||
}
|
||||
|
||||
function preview(value: string, maxLength = 180) {
|
||||
const normalized = value.replace(/\s+/g, " ").trim();
|
||||
return normalized.length > maxLength
|
||||
? `${normalized.slice(0, maxLength)}...`
|
||||
: normalized;
|
||||
}
|
||||
|
||||
function redactUrl(value: string) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return `${url.origin}${url.pathname}`;
|
||||
} catch {
|
||||
return value.replace(/([?&](?:api[_-]?key|key|token)=)[^&]+/gi, "$1***");
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeStatus(value: unknown) {
|
||||
return typeof value === "number" && value >= 400 && value < 600 ? value : 500;
|
||||
}
|
||||
|
||||
function serializeError(error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
const record = error as Error & {
|
||||
status?: number;
|
||||
code?: string;
|
||||
type?: string;
|
||||
param?: string;
|
||||
request_id?: string;
|
||||
requestID?: string;
|
||||
headers?: unknown;
|
||||
error?: unknown;
|
||||
};
|
||||
|
||||
return {
|
||||
name: error.name,
|
||||
message: error.message || "Unknown image generation error",
|
||||
stack: error.stack,
|
||||
status: record.status,
|
||||
code: record.code,
|
||||
type: record.type,
|
||||
param: record.param,
|
||||
requestId: record.request_id || record.requestID,
|
||||
detail: safeJson(record.error),
|
||||
headers: safeJson(record.headers),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
message: "Unknown image generation error",
|
||||
detail: safeJson(error),
|
||||
};
|
||||
}
|
||||
|
||||
function safeJson(value: unknown) {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const imageDir = join(
|
||||
/* turbopackIgnore: true */ process.cwd(),
|
||||
"public",
|
||||
"generated",
|
||||
);
|
||||
|
||||
export type PersistedImageFile = {
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
imageUrl: string;
|
||||
};
|
||||
|
||||
export function getImageDir() {
|
||||
if (!existsSync(imageDir)) {
|
||||
mkdirSync(imageDir, { recursive: true });
|
||||
}
|
||||
|
||||
return imageDir;
|
||||
}
|
||||
|
||||
export function persistBase64Image(
|
||||
id: string,
|
||||
extension: string,
|
||||
base64: string,
|
||||
): PersistedImageFile {
|
||||
const normalizedExtension = extension === "jpeg" ? "jpg" : extension;
|
||||
const fileName = `${id}.${normalizedExtension}`;
|
||||
const filePath = join(getImageDir(), fileName);
|
||||
const imageUrl = `/generated/${fileName}`;
|
||||
writeFileSync(filePath, Buffer.from(base64, "base64"));
|
||||
|
||||
return { fileName, filePath, imageUrl };
|
||||
}
|
||||
|
||||
export function persistUploadedImageFile(
|
||||
id: string,
|
||||
filename: string,
|
||||
buffer: Buffer,
|
||||
): PersistedImageFile {
|
||||
const extension = getExtensionFromFilename(filename);
|
||||
const fileName = `${id}.${extension}`;
|
||||
const filePath = join(getImageDir(), fileName);
|
||||
const imageUrl = `/generated/${fileName}`;
|
||||
writeFileSync(filePath, buffer);
|
||||
|
||||
return { fileName, filePath, imageUrl };
|
||||
}
|
||||
|
||||
function getExtensionFromFilename(filename: string) {
|
||||
const parts = filename.split(".");
|
||||
const extension = parts.at(-1)?.toLowerCase();
|
||||
return extension && extension.length <= 5 ? extension : "png";
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export {
|
||||
cleanupExpiredHistory,
|
||||
getRetentionDays,
|
||||
insertHistory,
|
||||
listHistory,
|
||||
setRetentionDays,
|
||||
startHistoryCleanupTimer,
|
||||
type StoredImage,
|
||||
} from "@/lib/server/repositories/history-repository";
|
||||
export { getDb } from "@/lib/server/db";
|
||||
export {
|
||||
createCanvasProject,
|
||||
deleteCanvasProject,
|
||||
getCanvasProjectById,
|
||||
listCanvasProjects,
|
||||
updateCanvasProject,
|
||||
} from "@/lib/server/repositories/canvas-project-repository";
|
||||
export {
|
||||
getImageDir,
|
||||
persistBase64Image,
|
||||
persistUploadedImageFile,
|
||||
} from "@/lib/server/storage/image-file-storage";
|
||||
Reference in New Issue
Block a user