429 lines
16 KiB
JavaScript
429 lines
16 KiB
JavaScript
import * as fs from "node:fs";
|
|
import * as path from "node:path";
|
|
import * as os from "node:os";
|
|
import { spawn } from "node:child_process";
|
|
import { GoogleGenAI } from "@google/genai";
|
|
|
|
export const NANO_BANANA_MODELS = [
|
|
{
|
|
id: "codex-image-gen",
|
|
label: "OpenAI Codex (gpt-5.6-sol + Built-in image_gen) — Recommended",
|
|
description: "codex-image-gen — High-resolution photorealistic raster PNG product & apparel mockups via OpenAI Codex built-in image_gen"
|
|
},
|
|
{
|
|
id: "gemini-nano-banana-2.1",
|
|
label: "Nano Banana 2.1 (Google Gemini + Free-Tier Auto Fallback)",
|
|
description: "gemini-nano-banana-2.1 — Uses native Nano Banana 2.1 on Tier 1 keys, with automatic Gemini 3.1 Flash-Lite Vector Mockup fallback on Free Tier keys"
|
|
},
|
|
{
|
|
id: "gemini-3-pro-image",
|
|
label: "Nano Banana Pro (Gemini Studio Quality)",
|
|
description: "gemini-3-pro-image — High-detail studio mockup generation and multi-turn editing"
|
|
},
|
|
{
|
|
id: "gemini-3.1-flash-lite-image",
|
|
label: "Nano Banana 2 Lite (Gemini Ultra-Fast)",
|
|
description: "gemini-3.1-flash-lite-image — Rapid concept sketching and quick iterations"
|
|
}
|
|
];
|
|
|
|
export const SURFACE_PRESETS = {
|
|
"tshirt-front": {
|
|
label: "👕 T-Shirt (Full Front Print)",
|
|
buildPrompt: ({ prompt, garmentColorName, garmentHex }) =>
|
|
`Photorealistic studio flat-lay apparel mockup of a heavyweight ${garmentColorName || "vintage cream"} (${garmentHex || "#F5E8D3"}) crewneck t-shirt. Centered full-front screenprint graphic design: ${prompt}. Crisp fabric texture, authentic screenprint ink detail, clean studio lighting.`
|
|
},
|
|
"tshirt-left-chest": {
|
|
label: "👕 T-Shirt (5-inch Left-Chest Emblem)",
|
|
buildPrompt: ({ prompt, garmentColorName, garmentHex }) =>
|
|
`Photorealistic studio closeup & flat-lay apparel mockup of a heavyweight ${garmentColorName || "vintage cream"} (${garmentHex || "#F5E8D3"}) crewneck t-shirt featuring a compact 5-inch left-chest pocket-zone screenprinted emblem: ${prompt}. Clean vector-style spot-color ink on fabric, studio lighting.`
|
|
},
|
|
"isolated-graphic": {
|
|
label: "🎨 Isolated Screenprint Artwork (Vector Style)",
|
|
buildPrompt: ({ prompt, garmentColorName }) =>
|
|
`Isolated flat vector screenprint t-shirt graphic emblem on a clean solid ${garmentColorName || "cream"} background, no shirt folds, bold spot-color illustration and outlined vintage typography: ${prompt}`
|
|
},
|
|
"sticker": {
|
|
label: "🏷️ Die-Cut Vinyl Sticker / Patch",
|
|
buildPrompt: ({ prompt }) =>
|
|
`Photorealistic die-cut vinyl sticker with a crisp white contour border resting on a dark slate studio surface. Bold vector illustration and typography: ${prompt}`
|
|
},
|
|
"poster": {
|
|
label: "🖼️ Framed Studio Art Poster",
|
|
buildPrompt: ({ prompt }) =>
|
|
`Gallery-framed vertical screenprint poster mockup in a minimalist studio interior. Bold editorial illustration and typography: ${prompt}`
|
|
},
|
|
"app-ui": {
|
|
label: "💻 Product / App Interface Concept",
|
|
buildPrompt: ({ prompt }) =>
|
|
`High-craft modern product design and UI concept presentation mockup on a sleek dark desktop studio display: ${prompt}`
|
|
}
|
|
};
|
|
|
|
export function buildNanoBananaPrompt({
|
|
rawPrompt,
|
|
ideaTitle,
|
|
elevatorPitch,
|
|
template = "tshirt-front",
|
|
garmentColorName = "Vintage Cream",
|
|
garmentHex = "#F5E8D3",
|
|
whackCard = null,
|
|
editInstruction = null
|
|
}) {
|
|
if (editInstruction && editInstruction.trim()) {
|
|
let editText = editInstruction.trim();
|
|
if (whackCard) {
|
|
editText += ` Incorporate creative twist from Card #${whackCard.number} (${whackCard.title}): ${whackCard.promptQuestion}`;
|
|
}
|
|
return editText;
|
|
}
|
|
|
|
const baseConcept = (rawPrompt && rawPrompt.trim())
|
|
? rawPrompt.trim()
|
|
: `${ideaTitle}${elevatorPitch ? ` — ${elevatorPitch}` : ""}`;
|
|
|
|
const surface = SURFACE_PRESETS[template] || SURFACE_PRESETS["tshirt-front"];
|
|
let finalPrompt = surface.buildPrompt({
|
|
prompt: baseConcept,
|
|
garmentColorName,
|
|
garmentHex
|
|
});
|
|
|
|
if (whackCard) {
|
|
finalPrompt += ` Creative direction inspired by "${whackCard.title}": ${whackCard.promptQuestion}`;
|
|
}
|
|
return finalPrompt;
|
|
}
|
|
|
|
export function isCodexConfigured() {
|
|
const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
|
|
return fs.existsSync(path.join(codexHome, "auth.json"));
|
|
}
|
|
|
|
function findNewestGeneratedImage(generatedImagesRoot, startedAtMs) {
|
|
if (!fs.existsSync(generatedImagesRoot)) return null;
|
|
let newestFile = null;
|
|
let newestMtime = 0;
|
|
|
|
for (const entry of fs.readdirSync(generatedImagesRoot, { withFileTypes: true })) {
|
|
const fullEntry = path.join(generatedImagesRoot, entry.name);
|
|
if (entry.isDirectory()) {
|
|
for (const sub of fs.readdirSync(fullEntry, { withFileTypes: true })) {
|
|
if (sub.isFile() && /\.(png|jpg|jpeg|webp)$/i.test(sub.name)) {
|
|
const candidate = path.join(fullEntry, sub.name);
|
|
const stat = fs.statSync(candidate);
|
|
if (stat.mtimeMs >= startedAtMs - 2000 && stat.mtimeMs > newestMtime) {
|
|
newestMtime = stat.mtimeMs;
|
|
newestFile = candidate;
|
|
}
|
|
}
|
|
}
|
|
} else if (entry.isFile() && /\.(png|jpg|jpeg|webp)$/i.test(entry.name)) {
|
|
const stat = fs.statSync(fullEntry);
|
|
if (stat.mtimeMs >= startedAtMs - 2000 && stat.mtimeMs > newestMtime) {
|
|
newestMtime = stat.mtimeMs;
|
|
newestFile = fullEntry;
|
|
}
|
|
}
|
|
}
|
|
return newestFile;
|
|
}
|
|
|
|
/**
|
|
* Generates or edits a high-resolution raster PNG mockup using OpenAI Codex CLI
|
|
* (`codex exec -m gpt-5.6-sol`) and its built-in `image_gen` tool.
|
|
*/
|
|
export async function generateWithCodex({
|
|
prompt,
|
|
referenceImagePath = null,
|
|
outputDir
|
|
}) {
|
|
const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
|
|
const generatedImagesRoot = path.join(codexHome, "generated_images");
|
|
const startedAtMs = Date.now();
|
|
|
|
const instruction = referenceImagePath && fs.existsSync(referenceImagePath)
|
|
? `Use the built-in image_gen tool right now to edit the attached reference image according to this instruction: ${prompt}. Do not run any shell commands, just call image_gen and reply DONE.`
|
|
: `Use the built-in image_gen tool right now to generate this image: ${prompt}. Do not run any shell commands, just call image_gen and reply DONE.`;
|
|
|
|
const args = [
|
|
"exec",
|
|
"-m",
|
|
process.env.CODEX_MODEL || "gpt-5.6-sol",
|
|
"--skip-git-repo-check",
|
|
"--json"
|
|
];
|
|
|
|
if (referenceImagePath && fs.existsSync(referenceImagePath) && !referenceImagePath.endsWith(".svg")) {
|
|
args.push("-i", referenceImagePath);
|
|
}
|
|
args.push(instruction);
|
|
|
|
const { stdout, stderr, exitCode } = await new Promise((resolve, reject) => {
|
|
const child = spawn("codex", args, {
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
env: { ...process.env }
|
|
});
|
|
|
|
let out = "";
|
|
let err = "";
|
|
child.stdout.on("data", (d) => {
|
|
out += d.toString();
|
|
});
|
|
child.stderr.on("data", (d) => {
|
|
err += d.toString();
|
|
});
|
|
child.on("error", reject);
|
|
child.on("close", (code) => {
|
|
resolve({ stdout: out, stderr: err, exitCode: code });
|
|
});
|
|
});
|
|
|
|
// Extract thread_id from JSONL stdout
|
|
let threadId = null;
|
|
let lastAgentText = null;
|
|
for (const line of stdout.split("\n")) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed.startsWith("{")) continue;
|
|
try {
|
|
const evt = JSON.parse(trimmed);
|
|
if (evt.type === "thread.started" && evt.thread_id) {
|
|
threadId = evt.thread_id;
|
|
} else if (evt.type === "item.completed" && evt.item?.type === "agent_message") {
|
|
lastAgentText = evt.item.text;
|
|
}
|
|
} catch {
|
|
// ignore non-json lines
|
|
}
|
|
}
|
|
|
|
let sourceImagePath = null;
|
|
if (threadId) {
|
|
const threadDir = path.join(generatedImagesRoot, threadId);
|
|
if (fs.existsSync(threadDir)) {
|
|
const files = fs
|
|
.readdirSync(threadDir)
|
|
.filter((f) => /\.(png|jpg|jpeg|webp)$/i.test(f))
|
|
.map((f) => path.join(threadDir, f));
|
|
if (files.length > 0) {
|
|
sourceImagePath = files[files.length - 1];
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!sourceImagePath) {
|
|
sourceImagePath = findNewestGeneratedImage(generatedImagesRoot, startedAtMs);
|
|
}
|
|
|
|
if (!sourceImagePath || !fs.existsSync(sourceImagePath)) {
|
|
throw new Error(
|
|
`Codex completed (exit ${exitCode}) without saving an image. ${stderr || lastAgentText || ""}`.trim()
|
|
);
|
|
}
|
|
|
|
const ext = path.extname(sourceImagePath).replace(/^\./, "") || "png";
|
|
const filename = `codex-${Date.now()}-${Math.random().toString(36).slice(2, 7)}.${ext}`;
|
|
const fullPath = path.join(outputDir, filename);
|
|
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
fs.copyFileSync(sourceImagePath, fullPath);
|
|
|
|
return {
|
|
filename,
|
|
imageUrl: `/mockups/${filename}`,
|
|
mimeType: `image/${ext === "jpg" ? "jpeg" : ext}`,
|
|
modelUsed: "OpenAI Codex (gpt-5.6-sol / image_gen)",
|
|
interactionId: threadId,
|
|
textNotes: "Generated high-resolution raster PNG via OpenAI Codex built-in image_gen."
|
|
};
|
|
}
|
|
|
|
function extractSvgBlock(text) {
|
|
if (!text) return null;
|
|
const match = String(text).match(/<svg[\s\S]*?<\/svg>/i);
|
|
if (!match) return null;
|
|
let svg = match[0];
|
|
if (!svg.includes("xmlns=")) {
|
|
svg = svg.replace("<svg", '<svg xmlns="http://www.w3.org/2000/svg"');
|
|
}
|
|
return svg;
|
|
}
|
|
|
|
/**
|
|
* Free-Tier Fallback: When Google AI Studio's Free Tier blocks raster image models
|
|
* (`limit: 0 input tokens per minute on Free Tier`), uses `gemini-3.1-flash-lite`
|
|
* on the same API key to generate a custom, detailed multi-layer SVG product/apparel mockup!
|
|
*/
|
|
async function generateFreeTierSvgMockup({
|
|
ai,
|
|
prompt,
|
|
referenceImagePath,
|
|
outputDir
|
|
}) {
|
|
let previousSvgSnippet = "";
|
|
if (referenceImagePath && referenceImagePath.endsWith(".svg") && fs.existsSync(referenceImagePath)) {
|
|
previousSvgSnippet = fs.readFileSync(referenceImagePath, "utf-8").slice(0, 12000);
|
|
}
|
|
|
|
const svgPrompt = [
|
|
"You are an expert apparel & product graphic designer and SVG illustrator.",
|
|
"Generate a complete, standalone, richly detailed 800x800 SVG (`viewBox=\"0 0 800 800\"`) product mockup for the following brief:",
|
|
`BRIEF: ${prompt}`,
|
|
previousSvgSnippet
|
|
? `EXISTING SVG TO EDIT/REFINE:\n${previousSvgSnippet}\nApply the requested changes while preserving or improving the overall composition.`
|
|
: "",
|
|
"DESIGN RULES:",
|
|
"1. Include a sleek studio backdrop (`#121621` radial gradient).",
|
|
"2. If the brief mentions a t-shirt, draw a realistic, well-proponed crewneck t-shirt silhouette with collar ribbing, sleeve seams, subtle fabric shading, and the requested garment hex color. Place the custom screenprint illustration and vintage typography either full-front (`cx=400, cy=360`) or in the 5-inch left-chest pocket zone (`cx=490, cy=285`, scaled compactly) according to the brief.",
|
|
"3. If the brief is a sticker, poster, or app UI, draw the realistic die-cut border, gallery frame, or device frame accordingly.",
|
|
"4. Draw rich, multi-layered custom vector illustration paths, badges, emblems, stars/mountains/icons, and bold screenprint typography with high contrast against the garment/surface color.",
|
|
"5. Return ONLY the raw `<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 800 800\">...</svg>` code block with no markdown explanation."
|
|
]
|
|
.filter(Boolean)
|
|
.join("\n\n");
|
|
|
|
const interaction = await ai.interactions.create({
|
|
model: "gemini-3.1-flash-lite",
|
|
input: svgPrompt
|
|
});
|
|
|
|
const svgContent = extractSvgBlock(interaction.output_text);
|
|
if (!svgContent) {
|
|
throw new Error("Free-tier fallback completed without valid SVG output.");
|
|
}
|
|
|
|
const filename = `nb-svg-${Date.now()}-${Math.random().toString(36).slice(2, 7)}.svg`;
|
|
const fullPath = path.join(outputDir, filename);
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
fs.writeFileSync(fullPath, svgContent, "utf-8");
|
|
|
|
return {
|
|
filename,
|
|
imageUrl: `/mockups/${filename}`,
|
|
mimeType: "image/svg+xml",
|
|
modelUsed: "gemini-3.1-flash-lite (Free-Tier Vector Mockup)",
|
|
interactionId: interaction.id || null,
|
|
textNotes: "Generated via Gemini 3.1 Flash-Lite Vector Studio."
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Generates or edits an image using:
|
|
* 1. OpenAI Codex (`codex-image-gen`) built-in `image_gen` tool (default when selected or when Codex auth is available), OR
|
|
* 2. Google Gemini Nano Banana (`gemini-nano-banana-2.1`) with automatic fallback to `Codex` or `gemini-3.1-flash-lite` SVG when Free Tier quota is 0.
|
|
*/
|
|
export async function generateWithNanoBanana({
|
|
apiKey,
|
|
model = "codex-image-gen",
|
|
prompt,
|
|
referenceImagePath = null,
|
|
referenceImageBase64 = null,
|
|
referenceMimeType = "image/png",
|
|
outputDir
|
|
}) {
|
|
// 1. Primary path: OpenAI Codex built-in image_gen
|
|
if (model === "codex-image-gen" || (!apiKey && isCodexConfigured())) {
|
|
return await generateWithCodex({
|
|
prompt,
|
|
referenceImagePath,
|
|
outputDir
|
|
});
|
|
}
|
|
|
|
const resolvedKey = apiKey || process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY;
|
|
if (!resolvedKey) {
|
|
if (isCodexConfigured()) {
|
|
return await generateWithCodex({
|
|
prompt,
|
|
referenceImagePath,
|
|
outputDir
|
|
});
|
|
}
|
|
const err = new Error(
|
|
"No AI image provider configured. Select 'OpenAI Codex (image_gen)' or configure a Gemini API key in Settings."
|
|
);
|
|
err.code = "MISSING_API_KEY";
|
|
throw err;
|
|
}
|
|
|
|
const validModel = NANO_BANANA_MODELS.some((m) => m.id === model)
|
|
? model
|
|
: "gemini-nano-banana-2.1";
|
|
|
|
const ai = new GoogleGenAI({ apiKey: resolvedKey });
|
|
|
|
let inputPayload = prompt;
|
|
let base64Data = referenceImageBase64;
|
|
let mimeType = referenceMimeType || "image/png";
|
|
|
|
if (!base64Data && referenceImagePath && fs.existsSync(referenceImagePath) && !referenceImagePath.endsWith(".svg")) {
|
|
const bytes = fs.readFileSync(referenceImagePath);
|
|
base64Data = bytes.toString("base64");
|
|
if (referenceImagePath.endsWith(".jpg") || referenceImagePath.endsWith(".jpeg")) {
|
|
mimeType = "image/jpeg";
|
|
}
|
|
}
|
|
|
|
if (base64Data) {
|
|
const cleanedBase64 = base64Data.replace(/^data:image\/[a-zA-Z0-9+.-]+;base64,/, "");
|
|
inputPayload = [
|
|
{ type: "text", text: prompt },
|
|
{
|
|
type: "image",
|
|
mime_type: mimeType,
|
|
data: cleanedBase64
|
|
}
|
|
];
|
|
}
|
|
|
|
try {
|
|
const interaction = await ai.interactions.create({
|
|
model: validModel,
|
|
input: inputPayload
|
|
});
|
|
|
|
const generatedImage = interaction.output_image;
|
|
if (!generatedImage || !generatedImage.data) {
|
|
throw new Error(
|
|
interaction.output_text ||
|
|
"Nano Banana completed the interaction without returning an image block."
|
|
);
|
|
}
|
|
|
|
const ext = (generatedImage.mime_type || "").includes("jpeg") ? "jpg" : "png";
|
|
const filename = `nb-${Date.now()}-${Math.random().toString(36).slice(2, 7)}.${ext}`;
|
|
const fullPath = path.join(outputDir, filename);
|
|
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
fs.writeFileSync(fullPath, Buffer.from(generatedImage.data, "base64"));
|
|
|
|
return {
|
|
filename,
|
|
imageUrl: `/mockups/${filename}`,
|
|
mimeType: generatedImage.mime_type || `image/${ext}`,
|
|
modelUsed: validModel,
|
|
interactionId: interaction.id || null,
|
|
textNotes: interaction.output_text || null
|
|
};
|
|
} catch (err) {
|
|
const msg = String(err?.message || "");
|
|
if (msg.includes("429") || msg.includes("Free Tier") || msg.includes("limit: 0") || msg.includes("quota")) {
|
|
// If OpenAI Codex is authenticated on this host, prefer high-res raster generation via Codex!
|
|
if (isCodexConfigured()) {
|
|
return await generateWithCodex({
|
|
prompt,
|
|
referenceImagePath,
|
|
outputDir
|
|
});
|
|
}
|
|
return await generateFreeTierSvgMockup({
|
|
ai,
|
|
prompt,
|
|
referenceImagePath,
|
|
outputDir
|
|
});
|
|
}
|
|
throw err;
|
|
}
|
|
}
|