first commit
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const maxDuration = 60; // SD / cold-start models can take a while
|
||||
|
||||
/**
|
||||
* Generative image-edit proxy. The BACKEND is pluggable so you are not tied to a
|
||||
* paid model — pick one with env `AI_EDIT_PROVIDER` (default `auto`):
|
||||
*
|
||||
* - `local` → your own Stable Diffusion server (Automatic1111 / Forge /
|
||||
* SD.Next img2img API). Env: LOCAL_SD_URL (e.g. http://127.0.0.1:7860).
|
||||
* 100% free + private, needs a GPU box you run. Most reliable.
|
||||
* - `huggingface` → free Hugging Face Inference API (instruction image editing).
|
||||
* Env: HF_API_TOKEN (free), HF_IMAGE_MODEL (default instruct-pix2pix).
|
||||
* - `gemini` → Google Gemini image model (needs a billed key for image output).
|
||||
* Env: GEMINI_API_KEY, GEMINI_IMAGE_MODEL.
|
||||
* - `auto` → first configured of: local → huggingface → gemini.
|
||||
*
|
||||
* NOTE: `remove-background` never reaches here — it runs fully in-browser (@imgly,
|
||||
* free, no key). Object detection / faces / OCR / embeddings are also 100% in-browser.
|
||||
* See docs/AI-SETUP.md.
|
||||
*/
|
||||
|
||||
const OP_PROMPTS: Record<string, string> = {
|
||||
restore:
|
||||
'Restore and enhance this photograph: improve sharpness and clarity, correct exposure and white balance, reduce noise and compression artifacts, recover detail. Keep it natural and photorealistic.',
|
||||
colorize: 'Colorize this image with natural, realistic, well-balanced colors.',
|
||||
'replace-sky':
|
||||
'Replace the sky with a dramatic, beautiful golden-hour sky with soft clouds. Keep the foreground subject unchanged and the result photorealistic.',
|
||||
};
|
||||
|
||||
const MAX_BASE64 = 4_000_000; // ~3 MB decoded — stays under serverless body limits (e.g. Vercel ~4.5MB)
|
||||
|
||||
type Provider = 'local' | 'huggingface' | 'gemini' | 'none';
|
||||
|
||||
function resolveProvider(): Provider {
|
||||
const explicit = (process.env.AI_EDIT_PROVIDER || 'auto').toLowerCase();
|
||||
if (explicit === 'local' || explicit === 'huggingface' || explicit === 'gemini') return explicit;
|
||||
if (explicit === 'none') return 'none';
|
||||
// auto: prefer a private local server, then free HF, then Gemini.
|
||||
if (process.env.LOCAL_SD_URL) return 'local';
|
||||
if (process.env.HF_API_TOKEN) return 'huggingface';
|
||||
if (process.env.GEMINI_API_KEY) return 'gemini';
|
||||
return 'none';
|
||||
}
|
||||
|
||||
interface EditResult {
|
||||
imageBase64: string;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const provider = resolveProvider();
|
||||
if (provider === 'none') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'AI image editing is not configured. Set LOCAL_SD_URL (own Stable Diffusion), HF_API_TOKEN (free Hugging Face), or GEMINI_API_KEY. Background removal and all analysis still work with no key. See docs/AI-SETUP.md.',
|
||||
},
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request body.' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { imageBase64, mimeType, op } = (body ?? {}) as {
|
||||
imageBase64?: unknown;
|
||||
mimeType?: unknown;
|
||||
op?: { type?: string; prompt?: string };
|
||||
};
|
||||
|
||||
if (typeof imageBase64 !== 'string' || imageBase64.length === 0 || imageBase64.length > MAX_BASE64) {
|
||||
return NextResponse.json({ error: 'Invalid or oversized image (try a smaller image).' }, { status: 400 });
|
||||
}
|
||||
const safeMime =
|
||||
typeof mimeType === 'string' && /^image\/(jpeg|png|webp)$/.test(mimeType) ? mimeType : 'image/jpeg';
|
||||
|
||||
// Build the instruction from an allow-listed op (never trust arbitrary server prompts).
|
||||
let instruction: string;
|
||||
if (op?.type === 'prompt') {
|
||||
const p = typeof op.prompt === 'string' ? op.prompt.trim() : '';
|
||||
if (!p) return NextResponse.json({ error: 'Empty prompt.' }, { status: 400 });
|
||||
instruction = p.slice(0, 500);
|
||||
} else if (op?.type === 'replace-sky' && typeof op.prompt === 'string' && op.prompt.trim()) {
|
||||
instruction = `Replace the sky with: ${op.prompt.trim().slice(0, 300)}. Keep the foreground unchanged and photorealistic.`;
|
||||
} else if (op?.type && OP_PROMPTS[op.type]) {
|
||||
instruction = OP_PROMPTS[op.type]!;
|
||||
} else {
|
||||
return NextResponse.json({ error: 'Unsupported operation.' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
let result: EditResult;
|
||||
if (provider === 'local') result = await editLocal(instruction, imageBase64);
|
||||
else if (provider === 'huggingface') result = await editHuggingFace(instruction, imageBase64);
|
||||
else result = await editGemini(instruction, imageBase64, safeMime);
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'AI request failed.';
|
||||
const status = err instanceof AiError ? err.status : 502;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
|
||||
class AiError extends Error {
|
||||
status: number;
|
||||
constructor(message: string, status = 502) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backend: local Stable Diffusion (Automatic1111 / Forge / SD.Next img2img API)
|
||||
// ---------------------------------------------------------------------------
|
||||
async function editLocal(instruction: string, imageBase64: string): Promise<EditResult> {
|
||||
const base = process.env.LOCAL_SD_URL;
|
||||
if (!base || !/^https?:\/\//i.test(base)) {
|
||||
throw new AiError('LOCAL_SD_URL is not a valid http(s) URL.', 500);
|
||||
}
|
||||
const url = `${base.replace(/\/$/, '')}/sdapi/v1/img2img`;
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
init_images: [imageBase64],
|
||||
prompt: instruction,
|
||||
denoising_strength: Number(process.env.LOCAL_SD_DENOISE ?? 0.55),
|
||||
steps: Number(process.env.LOCAL_SD_STEPS ?? 25),
|
||||
cfg_scale: 7,
|
||||
sampler_name: process.env.LOCAL_SD_SAMPLER || 'Euler a',
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
throw new AiError('Could not reach your local Stable Diffusion server (LOCAL_SD_URL).', 502);
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new AiError(`Local SD server error (${res.status}).`, 502);
|
||||
}
|
||||
const data = (await res.json().catch(() => null)) as { images?: string[] } | null;
|
||||
const out = data?.images?.[0];
|
||||
if (!out) throw new AiError('Local SD server did not return an image.', 502);
|
||||
// A1111 returns raw base64 PNG (no data: prefix).
|
||||
return { imageBase64: out.includes(',') ? out.split(',')[1]! : out, mimeType: 'image/png' };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backend: Hugging Face Inference API (free tier) — instruction image editing.
|
||||
// ---------------------------------------------------------------------------
|
||||
async function editHuggingFace(instruction: string, imageBase64: string): Promise<EditResult> {
|
||||
const token = process.env.HF_API_TOKEN;
|
||||
if (!token) throw new AiError('HF_API_TOKEN is not set.', 500);
|
||||
const model = process.env.HF_IMAGE_MODEL || 'timbrooks/instruct-pix2pix';
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`https://api-inference.huggingface.co/models/${model}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
'content-type': 'application/json',
|
||||
// Wait for the (free) model to warm up instead of a fast 503.
|
||||
'x-wait-for-model': 'true',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
inputs: imageBase64,
|
||||
parameters: { prompt: instruction, guidance_scale: 7, image_guidance_scale: 1.5 },
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
throw new AiError('Could not reach the Hugging Face Inference API.', 502);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const detail = (await res.text().catch(() => '')).slice(0, 160);
|
||||
if (res.status === 503)
|
||||
throw new AiError('The free model is loading — try again in ~20s.', 503);
|
||||
throw new AiError(`Hugging Face error (${res.status}). ${detail}`, 502);
|
||||
}
|
||||
// Success returns raw image bytes.
|
||||
const outMime = res.headers.get('content-type') || 'image/png';
|
||||
if (outMime.startsWith('application/json')) {
|
||||
const j = (await res.json().catch(() => null)) as { error?: string } | null;
|
||||
throw new AiError(j?.error ? `Hugging Face: ${j.error}` : 'Hugging Face returned no image.', 502);
|
||||
}
|
||||
const buf = await res.arrayBuffer();
|
||||
return { imageBase64: Buffer.from(buf).toString('base64'), mimeType: outMime };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backend: Google Gemini image model (needs a billed key for image output).
|
||||
// ---------------------------------------------------------------------------
|
||||
async function editGemini(instruction: string, imageBase64: string, safeMime: string): Promise<EditResult> {
|
||||
const apiKey = process.env.GEMINI_API_KEY;
|
||||
if (!apiKey) throw new AiError('GEMINI_API_KEY is not set.', 500);
|
||||
const model = process.env.GEMINI_IMAGE_MODEL || 'gemini-2.5-flash-image';
|
||||
const prompt = `Edit this image as follows: ${instruction}. Preserve realism unless explicitly asked otherwise.`;
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(
|
||||
`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'x-goog-api-key': apiKey },
|
||||
body: JSON.stringify({
|
||||
contents: [
|
||||
{ role: 'user', parts: [{ inlineData: { mimeType: safeMime, data: imageBase64 } }, { text: prompt }] },
|
||||
],
|
||||
generationConfig: { responseModalities: ['IMAGE'] },
|
||||
}),
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
throw new AiError('Could not reach the AI service.', 502);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const detail = (await res.text().catch(() => '')).slice(0, 160);
|
||||
throw new AiError(`AI service error (${res.status}). ${detail}`, 502);
|
||||
}
|
||||
const data = (await res.json().catch(() => null)) as GeminiResponse | null;
|
||||
const parts = data?.candidates?.[0]?.content?.parts ?? [];
|
||||
const imgPart = parts.find((p) => p.inlineData?.data || p.inline_data?.data);
|
||||
const out = imgPart?.inlineData?.data ?? imgPart?.inline_data?.data;
|
||||
if (!out) throw new AiError('The model did not return an image (the free Gemini tier has no image output — use LOCAL_SD_URL or HF_API_TOKEN instead).', 502);
|
||||
const outMime = imgPart?.inlineData?.mimeType ?? imgPart?.inline_data?.mime_type ?? 'image/png';
|
||||
return { imageBase64: out, mimeType: outMime };
|
||||
}
|
||||
|
||||
interface GeminiPart {
|
||||
text?: string;
|
||||
inlineData?: { mimeType?: string; data?: string };
|
||||
inline_data?: { mime_type?: string; data?: string };
|
||||
}
|
||||
interface GeminiResponse {
|
||||
candidates?: Array<{ content?: { parts?: GeminiPart[] } }>;
|
||||
}
|
||||
Reference in New Issue
Block a user