chore: lock AI providers and standardize bouquet images to 3:4
This commit is contained in:
@@ -178,54 +178,14 @@ export function buildOpenAIEditMask(width, height, selection) {
|
||||
return encodePng(width, height, rgba);
|
||||
}
|
||||
|
||||
/**
|
||||
* Visual mask for Gemini: white polygon on black = edit region.
|
||||
* @param {number} width
|
||||
* @param {number} height
|
||||
* @param {Array<{ x: number, y: number }>} selection
|
||||
*/
|
||||
export function buildGeminiEditMask(width, height, selection) {
|
||||
const polygon = closePolygon(
|
||||
selection.map((point) => ({
|
||||
x: (point.x / 100) * width,
|
||||
y: (point.y / 100) * height
|
||||
}))
|
||||
);
|
||||
|
||||
const rgba = new Uint8Array(width * height * 4);
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const index = (y * width + x) * 4;
|
||||
const inside = pointInPolygon(x + 0.5, y + 0.5, polygon);
|
||||
if (inside) {
|
||||
rgba[index] = 255;
|
||||
rgba[index + 1] = 255;
|
||||
rgba[index + 2] = 255;
|
||||
rgba[index + 3] = 255;
|
||||
} else {
|
||||
rgba[index] = 0;
|
||||
rgba[index + 1] = 0;
|
||||
rgba[index + 2] = 0;
|
||||
rgba[index + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return encodePng(width, height, rgba);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ base64: string, mimeType: string }} sourceImage
|
||||
* @param {Array<{ x: number, y: number }>} selection
|
||||
* @param {'openai' | 'gemini'} provider
|
||||
*/
|
||||
export function buildAreaEditMask(sourceImage, selection, provider) {
|
||||
export function buildAreaEditMask(sourceImage, selection) {
|
||||
const buffer = Buffer.from(sourceImage.base64, 'base64');
|
||||
const { width, height } = readImageDimensions(buffer, sourceImage.mimeType);
|
||||
const maskBuffer =
|
||||
provider === 'gemini'
|
||||
? buildGeminiEditMask(width, height, selection)
|
||||
: buildOpenAIEditMask(width, height, selection);
|
||||
const maskBuffer = buildOpenAIEditMask(width, height, selection);
|
||||
|
||||
return {
|
||||
base64: maskBuffer.toString('base64'),
|
||||
|
||||
@@ -31,12 +31,6 @@ export function getVisionModel() {
|
||||
});
|
||||
}
|
||||
|
||||
export function getImageModel() {
|
||||
return getClient().getGenerativeModel({
|
||||
model: env.GEMINI_IMAGE_MODEL || 'gemini-3.1-flash-image'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
*/
|
||||
|
||||
@@ -1,42 +1,11 @@
|
||||
/** @typedef {import('../flowerFlow/jobStore.js').GeneratedImage} GeneratedImage */
|
||||
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { BOUQUET_IMAGE_ASPECT_PROMPT } from '../../flowerFlow/bouquetImageFormat.js';
|
||||
import { getImageModel, isGeminiConfigured } from './client.js';
|
||||
import { mockGeneratedImage } from './mock.js';
|
||||
import { generateOpenAIImage, editOpenAIImage, isOpenAIConfigured } from '../openai/image.js';
|
||||
|
||||
export function getImageProvider() {
|
||||
const configured = env.IMAGE_PROVIDER?.trim().toLowerCase();
|
||||
if (configured === 'mock' || configured === 'openai' || configured === 'gemini') {
|
||||
return configured;
|
||||
}
|
||||
return isOpenAIConfigured() ? 'openai' : 'gemini';
|
||||
}
|
||||
|
||||
export function isImageGenerationConfigured() {
|
||||
const provider = getImageProvider();
|
||||
if (provider === 'mock') return false;
|
||||
return provider === 'openai' ? isOpenAIConfigured() : isGeminiConfigured();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('@google/generative-ai').GenerateContentResult} result
|
||||
* @returns {GeneratedImage}
|
||||
*/
|
||||
function imageFromGeminiResult(result) {
|
||||
const parts = result.response.candidates?.[0]?.content?.parts ?? [];
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.inlineData?.data) {
|
||||
return {
|
||||
mimeType: part.inlineData.mimeType || 'image/png',
|
||||
base64: part.inlineData.data
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Gemini image model did not return image data');
|
||||
return isOpenAIConfigured();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,27 +16,12 @@ function imageFromGeminiResult(result) {
|
||||
export async function generateBouquetImage(basePrompt) {
|
||||
const suffix = `Generate one final bouquet image. ${BOUQUET_IMAGE_ASPECT_PROMPT} The STRICT RECIPE flower list above is mandatory: include every listed species and do not add any other flowers. Keep it realistic, orderable from a real florist, front-facing, and suitable for a customer preview.`;
|
||||
const prompt = `${basePrompt}\n\n${suffix}`;
|
||||
const provider = getImageProvider();
|
||||
|
||||
if (provider === 'mock') {
|
||||
if (!isOpenAIConfigured()) {
|
||||
return mockGeneratedImage();
|
||||
}
|
||||
|
||||
if (provider === 'openai') {
|
||||
if (!isOpenAIConfigured()) {
|
||||
return mockGeneratedImage();
|
||||
}
|
||||
|
||||
return generateOpenAIImage(prompt);
|
||||
}
|
||||
|
||||
if (!isGeminiConfigured()) {
|
||||
return mockGeneratedImage();
|
||||
}
|
||||
|
||||
const model = getImageModel();
|
||||
const result = await model.generateContent(prompt);
|
||||
return imageFromGeminiResult(result);
|
||||
return generateOpenAIImage(prompt);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,52 +32,11 @@ export async function generateBouquetImage(basePrompt) {
|
||||
* @returns {Promise<GeneratedImage>}
|
||||
*/
|
||||
export async function editBouquetImage(sourceImage, editPrompt, options = {}) {
|
||||
const provider = getImageProvider();
|
||||
const mask = options.mask ?? null;
|
||||
|
||||
if (provider === 'mock' || sourceImage.mimeType === 'image/svg+xml') {
|
||||
if (sourceImage.mimeType === 'image/svg+xml' || !isOpenAIConfigured()) {
|
||||
return mockGeneratedImage('Edited bouquet');
|
||||
}
|
||||
|
||||
if (provider === 'openai') {
|
||||
if (!isOpenAIConfigured()) {
|
||||
return mockGeneratedImage('Edited bouquet');
|
||||
}
|
||||
|
||||
return editOpenAIImage(editPrompt, sourceImage, mask);
|
||||
}
|
||||
|
||||
if (!isGeminiConfigured()) {
|
||||
return mockGeneratedImage('Edited bouquet');
|
||||
}
|
||||
|
||||
const model = getImageModel();
|
||||
/** @type {import('@google/generative-ai').Part[]} */
|
||||
const parts = [
|
||||
{ text: editPrompt },
|
||||
{
|
||||
inlineData: {
|
||||
data: sourceImage.base64,
|
||||
mimeType: sourceImage.mimeType
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
if (mask) {
|
||||
parts.push(
|
||||
{
|
||||
text: 'This mask marks the edit region. Modify the bouquet photo only where the mask is white. Keep black areas unchanged.'
|
||||
},
|
||||
{
|
||||
inlineData: {
|
||||
data: mask.base64,
|
||||
mimeType: mask.mimeType
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const result = await model.generateContent(parts);
|
||||
|
||||
return imageFromGeminiResult(result);
|
||||
return editOpenAIImage(editPrompt, sourceImage, mask);
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ export function mockGeneratedImage(label = 'Bouquet') {
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="768" height="1024" viewBox="0 0 768 1024">
|
||||
<rect width="768" height="1024" fill="#f7f3ef"/>
|
||||
<text x="50%" y="48%" text-anchor="middle" font-size="42" fill="#6b5b53" font-family="Arial">Mock ${label}</text>
|
||||
<text x="50%" y="54%" text-anchor="middle" font-size="22" fill="#9a8d84" font-family="Arial">Set GEMINI_API_KEY for real images</text>
|
||||
<text x="50%" y="54%" text-anchor="middle" font-size="22" fill="#9a8d84" font-family="Arial">Set OPENAI_API_KEY for real images</text>
|
||||
</svg>`;
|
||||
|
||||
return {
|
||||
|
||||
95
src/lib/server/openai/bouquetImageFrame.js
Normal file
95
src/lib/server/openai/bouquetImageFrame.js
Normal file
@@ -0,0 +1,95 @@
|
||||
import sharp from 'sharp';
|
||||
|
||||
/** Product bouquet output — 3:4 portrait (matches UI aspect-[3/4] and mock SVG). */
|
||||
export const BOUQUET_OUTPUT_WIDTH = 768;
|
||||
export const BOUQUET_OUTPUT_HEIGHT = 1024;
|
||||
export const BOUQUET_OUTPUT_SIZE = `${BOUQUET_OUTPUT_WIDTH}x${BOUQUET_OUTPUT_HEIGHT}`;
|
||||
|
||||
/** Closest portrait size supported by gpt-image-1 (2:3). Cropped to 3:4 after generation. */
|
||||
export const OPENAI_REQUEST_WIDTH = 1024;
|
||||
export const OPENAI_REQUEST_HEIGHT = 1536;
|
||||
export const OPENAI_REQUEST_SIZE = `${OPENAI_REQUEST_WIDTH}x${OPENAI_REQUEST_HEIGHT}`;
|
||||
|
||||
const PAD_LEFT = (OPENAI_REQUEST_WIDTH - BOUQUET_OUTPUT_WIDTH) / 2;
|
||||
const PAD_TOP = (OPENAI_REQUEST_HEIGHT - BOUQUET_OUTPUT_HEIGHT) / 2;
|
||||
|
||||
/**
|
||||
* Center-crop (and resize if needed) to exact 3:4 bouquet output.
|
||||
* @param {Buffer} buffer
|
||||
* @returns {Promise<Buffer>}
|
||||
*/
|
||||
export async function frameToBouquetOutput(buffer) {
|
||||
const meta = await sharp(buffer).metadata();
|
||||
const width = meta.width ?? OPENAI_REQUEST_WIDTH;
|
||||
const height = meta.height ?? OPENAI_REQUEST_HEIGHT;
|
||||
|
||||
if (width === BOUQUET_OUTPUT_WIDTH && height === BOUQUET_OUTPUT_HEIGHT) {
|
||||
return buffer;
|
||||
}
|
||||
|
||||
const targetRatio = BOUQUET_OUTPUT_WIDTH / BOUQUET_OUTPUT_HEIGHT;
|
||||
let cropWidth = width;
|
||||
let cropHeight = height;
|
||||
|
||||
if (width / height > targetRatio) {
|
||||
cropWidth = Math.round(height * targetRatio);
|
||||
} else {
|
||||
cropHeight = Math.round(width / targetRatio);
|
||||
}
|
||||
|
||||
const left = Math.max(0, Math.round((width - cropWidth) / 2));
|
||||
const top = Math.max(0, Math.round((height - cropHeight) / 2));
|
||||
|
||||
return sharp(buffer)
|
||||
.extract({ left, top, width: cropWidth, height: cropHeight })
|
||||
.resize(BOUQUET_OUTPUT_WIDTH, BOUQUET_OUTPUT_HEIGHT)
|
||||
.png()
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pad a 3:4 bouquet image to OpenAI's 2:3 request size (white letterbox).
|
||||
* @param {Buffer} buffer
|
||||
* @returns {Promise<Buffer>}
|
||||
*/
|
||||
export async function padToOpenAIRequestSize(buffer) {
|
||||
const meta = await sharp(buffer).metadata();
|
||||
if (meta.width === OPENAI_REQUEST_WIDTH && meta.height === OPENAI_REQUEST_HEIGHT) {
|
||||
return buffer;
|
||||
}
|
||||
|
||||
return sharp(buffer)
|
||||
.resize(BOUQUET_OUTPUT_WIDTH, BOUQUET_OUTPUT_HEIGHT, { fit: 'fill' })
|
||||
.extend({
|
||||
top: PAD_TOP,
|
||||
bottom: PAD_TOP,
|
||||
left: PAD_LEFT,
|
||||
right: PAD_LEFT,
|
||||
background: { r: 255, g: 255, b: 255, alpha: 1 }
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pad an OpenAI edit mask (transparent=edit, opaque=preserve) to the request canvas.
|
||||
* @param {Buffer} maskBuffer
|
||||
* @returns {Promise<Buffer>}
|
||||
*/
|
||||
export async function padMaskToOpenAIRequestSize(maskBuffer) {
|
||||
const meta = await sharp(maskBuffer).metadata();
|
||||
if (meta.width === OPENAI_REQUEST_WIDTH && meta.height === OPENAI_REQUEST_HEIGHT) {
|
||||
return maskBuffer;
|
||||
}
|
||||
|
||||
return sharp(maskBuffer)
|
||||
.extend({
|
||||
top: PAD_TOP,
|
||||
bottom: PAD_TOP,
|
||||
left: PAD_LEFT,
|
||||
right: PAD_LEFT,
|
||||
background: { r: 255, g: 255, b: 255, alpha: 255 }
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
import { env } from '$env/dynamic/private';
|
||||
import OpenAI, { toFile } from 'openai';
|
||||
import {
|
||||
frameToBouquetOutput,
|
||||
padMaskToOpenAIRequestSize,
|
||||
padToOpenAIRequestSize,
|
||||
OPENAI_REQUEST_SIZE
|
||||
} from './bouquetImageFrame.js';
|
||||
|
||||
let client = null;
|
||||
|
||||
@@ -19,6 +25,25 @@ function getOpenAIClient() {
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('openai').Images.ImagesResponse['data']} data
|
||||
* @returns {Promise<Buffer>}
|
||||
*/
|
||||
async function readImageBytes(data) {
|
||||
const image = data?.[0];
|
||||
|
||||
if (image?.b64_json) {
|
||||
return Buffer.from(image.b64_json, 'base64');
|
||||
}
|
||||
|
||||
if (image?.url) {
|
||||
const imageResponse = await fetch(image.url);
|
||||
return Buffer.from(await imageResponse.arrayBuffer());
|
||||
}
|
||||
|
||||
throw new Error('OpenAI image model did not return image data');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} prompt
|
||||
* @returns {Promise<import('../flowerFlow/jobStore.js').GeneratedImage>}
|
||||
@@ -27,30 +52,16 @@ export async function generateOpenAIImage(prompt) {
|
||||
const response = await getOpenAIClient().images.generate({
|
||||
model: env.OPENAI_IMAGE_MODEL || 'gpt-image-1',
|
||||
prompt,
|
||||
size: env.OPENAI_IMAGE_SIZE || '1024x1536',
|
||||
size: OPENAI_REQUEST_SIZE,
|
||||
n: 1
|
||||
});
|
||||
|
||||
const image = response.data?.[0];
|
||||
const framed = await frameToBouquetOutput(await readImageBytes(response.data));
|
||||
|
||||
if (image?.b64_json) {
|
||||
return {
|
||||
mimeType: 'image/png',
|
||||
base64: image.b64_json
|
||||
};
|
||||
}
|
||||
|
||||
if (image?.url) {
|
||||
const imageResponse = await fetch(image.url);
|
||||
const bytes = new Uint8Array(await imageResponse.arrayBuffer());
|
||||
|
||||
return {
|
||||
mimeType: imageResponse.headers.get('content-type') || 'image/png',
|
||||
base64: Buffer.from(bytes).toString('base64')
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('OpenAI image model did not return image data');
|
||||
return {
|
||||
mimeType: 'image/png',
|
||||
base64: framed.toString('base64')
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,45 +71,30 @@ export async function generateOpenAIImage(prompt) {
|
||||
* @returns {Promise<import('../flowerFlow/jobStore.js').GeneratedImage>}
|
||||
*/
|
||||
export async function editOpenAIImage(prompt, sourceImage, mask = null) {
|
||||
const buffer = Buffer.from(sourceImage.base64, 'base64');
|
||||
const imageFile = await toFile(buffer, 'bouquet.png', { type: sourceImage.mimeType });
|
||||
const paddedSource = await padToOpenAIRequestSize(
|
||||
Buffer.from(sourceImage.base64, 'base64')
|
||||
);
|
||||
const imageFile = await toFile(paddedSource, 'bouquet.png', { type: 'image/png' });
|
||||
|
||||
/** @type {import('openai').default.Images.ImageEditParams} */
|
||||
const params = {
|
||||
model: env.OPENAI_IMAGE_MODEL || 'gpt-image-1',
|
||||
image: imageFile,
|
||||
prompt,
|
||||
size: env.OPENAI_IMAGE_SIZE || '1024x1536',
|
||||
size: OPENAI_REQUEST_SIZE,
|
||||
n: 1
|
||||
};
|
||||
|
||||
if (mask) {
|
||||
const maskFile = await toFile(Buffer.from(mask.base64, 'base64'), 'mask.png', {
|
||||
type: 'image/png'
|
||||
});
|
||||
params.mask = maskFile;
|
||||
const paddedMask = await padMaskToOpenAIRequestSize(Buffer.from(mask.base64, 'base64'));
|
||||
params.mask = await toFile(paddedMask, 'mask.png', { type: 'image/png' });
|
||||
}
|
||||
|
||||
const response = await getOpenAIClient().images.edit(params);
|
||||
const framed = await frameToBouquetOutput(await readImageBytes(response.data));
|
||||
|
||||
const image = response.data?.[0];
|
||||
|
||||
if (image?.b64_json) {
|
||||
return {
|
||||
mimeType: 'image/png',
|
||||
base64: image.b64_json
|
||||
};
|
||||
}
|
||||
|
||||
if (image?.url) {
|
||||
const imageResponse = await fetch(image.url);
|
||||
const bytes = new Uint8Array(await imageResponse.arrayBuffer());
|
||||
|
||||
return {
|
||||
mimeType: imageResponse.headers.get('content-type') || 'image/png',
|
||||
base64: Buffer.from(bytes).toString('base64')
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('OpenAI image edit did not return image data');
|
||||
return {
|
||||
mimeType: 'image/png',
|
||||
base64: framed.toString('base64')
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,11 +4,7 @@ import { buildAreaEditMask } from '$lib/server/flowerFlow/selectionMask.js';
|
||||
import { uploadGeneratedImages } from '$lib/server/flowerFlow/imageStorage.js';
|
||||
import { formatBouquetEditPrompt } from '$lib/flowerFlow/bouquetImageFormat.js';
|
||||
import { normalizeRecipeLists } from '$lib/flowerFlow/resolveRecipeFlowers.js';
|
||||
import {
|
||||
editBouquetImage,
|
||||
getImageProvider,
|
||||
isImageGenerationConfigured
|
||||
} from '$lib/server/gemini/image.js';
|
||||
import { editBouquetImage, isImageGenerationConfigured } from '$lib/server/gemini/image.js';
|
||||
import { applyRecipeEdit } from '$lib/server/gemini/text.js';
|
||||
import { RATE_LIMITS } from '$lib/server/rateLimit.js';
|
||||
import { enforceRateLimit, json, readJsonBody, toErrorResponse } from '$lib/server/http.js';
|
||||
@@ -58,18 +54,13 @@ function editForJob(jobId, job, instruction) {
|
||||
recipeChanged
|
||||
});
|
||||
|
||||
const provider = getImageProvider();
|
||||
const mask =
|
||||
instruction.mode === 'area' && instruction.selection.length >= 3
|
||||
? buildAreaEditMask(
|
||||
sourceImage,
|
||||
instruction.selection,
|
||||
provider === 'gemini' ? 'gemini' : 'openai'
|
||||
)
|
||||
? buildAreaEditMask(sourceImage, instruction.selection)
|
||||
: null;
|
||||
|
||||
console.log(
|
||||
`[flower-flow] edit-images job=${jobId.slice(0, 8)} provider=${provider} mode=${instruction.mode}${mask ? ' (masked)' : ''} → editing...`
|
||||
`[flower-flow] edit-images job=${jobId.slice(0, 8)} mode=${instruction.mode}${mask ? ' (masked)' : ''} → editing...`
|
||||
);
|
||||
const generatedImage = await editBouquetImage(sourceImage, editPrompt, { mask });
|
||||
const images = await uploadGeneratedImages(
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { requireJob, updateJob } from '$lib/server/flowerFlow/jobStore.js';
|
||||
import { normalizeRecipeLists } from '$lib/flowerFlow/resolveRecipeFlowers.js';
|
||||
import { buildImagePrompt } from '$lib/server/gemini/text.js';
|
||||
import {
|
||||
generateBouquetImage,
|
||||
getImageProvider,
|
||||
isImageGenerationConfigured
|
||||
} from '$lib/server/gemini/image.js';
|
||||
import { generateBouquetImage, isImageGenerationConfigured } from '$lib/server/gemini/image.js';
|
||||
import { uploadGeneratedImages } from '$lib/server/flowerFlow/imageStorage.js';
|
||||
import { RATE_LIMITS } from '$lib/server/rateLimit.js';
|
||||
import { json, readJsonBody, enforceRateLimit, toErrorResponse } from '$lib/server/http.js';
|
||||
@@ -80,9 +76,7 @@ export async function POST({ request, getClientAddress }) {
|
||||
});
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[flower-flow] generate-images job=${jobId.slice(0, 8)} provider=${getImageProvider()} → generating...`
|
||||
);
|
||||
console.log(`[flower-flow] generate-images job=${jobId.slice(0, 8)} → generating...`);
|
||||
const { imagePrompt, images, recipe: savedRecipe } = await generateForJob(jobId, job.recipe);
|
||||
console.log(
|
||||
`[flower-flow] generate-images job=${jobId.slice(0, 8)} OK (mock=${!isImageGenerationConfigured()})`
|
||||
|
||||
Reference in New Issue
Block a user