+ import { onMount } from 'svelte';
import FloristOrderMessage from './FloristOrderMessage.svelte';
import KakaoMap from './KakaoMap.svelte';
import ShopList from './ShopList.svelte';
@@ -19,10 +20,15 @@
onrefresh
} = $props();
- let mapCenterLat = $state(initialLat);
- let mapCenterLng = $state(initialLng);
+ let mapCenterLat = $state(DEFAULT_MAP_CENTER.lat);
+ let mapCenterLng = $state(DEFAULT_MAP_CENTER.lng);
let panTarget = $state(null);
+ onMount(() => {
+ mapCenterLat = initialLat;
+ mapCenterLng = initialLng;
+ });
+
function handleCenterChange(lat, lng) {
mapCenterLat = lat;
mapCenterLng = lng;
diff --git a/src/lib/flowerFlow/bouquetImageFormat.js b/src/lib/flowerFlow/bouquetImageFormat.js
index 50ae76a..74b1c5a 100644
--- a/src/lib/flowerFlow/bouquetImageFormat.js
+++ b/src/lib/flowerFlow/bouquetImageFormat.js
@@ -4,3 +4,90 @@ export const BOUQUET_IMAGE_ASPECT = '3:4';
export const BOUQUET_IMAGE_ASPECT_PROMPT =
'Vertical portrait composition with a 3:4 aspect ratio (width:height). Frame the full bouquet without cropping stems or wrapping.';
+
+/**
+ * Deterministic image prompt — recipe is the sole source of truth for flower species.
+ * @param {{ mainFlowers?: string[], subFlowers?: string[], greenery?: string[], colors?: string[], wrapping?: string, shape?: string }} recipe
+ * @returns {string}
+ */
+export function formatStrictBouquetImagePrompt(recipe) {
+ const mains = (recipe.mainFlowers ?? []).filter(Boolean);
+ const subs = (recipe.subFlowers ?? []).filter(Boolean);
+ const greenery = (recipe.greenery ?? []).filter(Boolean);
+ const allFlowers = [...mains, ...subs, ...greenery];
+
+ return [
+ 'Generate a realistic Korean florist bouquet product photo.',
+ '',
+ 'STRICT RECIPE — the bouquet must contain ONLY these flowers and NO other flower species:',
+ allFlowers.length > 0 ? allFlowers.map((flower) => `- ${flower}`).join('\n') : '- (none listed)',
+ '',
+ `Main focal blooms (each must be clearly visible): ${mains.join(', ') || 'none'}`,
+ `Supporting filler/line flowers (each must appear): ${subs.join(', ') || 'none'}`,
+ `Greenery (must appear): ${greenery.join(', ') || 'none'}`,
+ `Colors: ${(recipe.colors ?? []).join(', ') || 'natural harmony'}`,
+ `Wrapping: ${recipe.wrapping || 'neutral florist wrap'}`,
+ `Shape: ${recipe.shape || 'balanced hand-tied bouquet'}`,
+ '',
+ 'Hard constraints:',
+ '- Do NOT add any flower, filler, or foliage species not listed above',
+ '- EVERY species listed above MUST appear in the final image',
+ '- Real cut flowers only; no fantasy colors or impossible hybrids',
+ `- ${BOUQUET_IMAGE_ASPECT_PROMPT}`,
+ '- White background, soft natural lighting, front-facing, orderable from a real Korean florist'
+ ].join('\n');
+}
+
+/**
+ * Prompt for editing an existing bouquet photo (reference image passed separately).
+ * @param {{
+ * userPrompt: string,
+ * mode?: string,
+ * selection?: Array<{ x: number, y: number }>,
+ * recipe?: { mainFlowers?: string[], subFlowers?: string[], greenery?: string[] },
+ * recipeChanged?: boolean
+ * }} options
+ * @returns {string}
+ */
+export function formatBouquetEditPrompt(options) {
+ const { userPrompt, mode, selection, recipe, recipeChanged = false } = options;
+
+ const lines = [
+ 'You are editing the attached florist bouquet photograph.',
+ 'This is an image edit — modify the provided photo in place. Do not generate an unrelated new bouquet from scratch.',
+ '',
+ `Edit request: ${userPrompt}`,
+ '',
+ 'Preserve unless the edit request explicitly requires a change:',
+ '- Camera angle, white background, soft lighting, and overall framing',
+ '- Wrapping paper, ribbon, and bouquet shape',
+ '- Every flower species and greenery not involved in the edit request'
+ ];
+
+ if (recipeChanged && recipe) {
+ lines.push(
+ '',
+ 'This edit changes the flower list. Update only the affected blooms; keep the rest of the arrangement intact:',
+ `Main blooms: ${(recipe.mainFlowers ?? []).join(', ') || 'none'}`,
+ `Filler/line: ${(recipe.subFlowers ?? []).join(', ') || 'none'}`,
+ `Greenery: ${(recipe.greenery ?? []).join(', ') || 'none'}`
+ );
+ }
+
+ if (mode === 'area' && selection && selection.length >= 3) {
+ lines.push(
+ '',
+ 'Apply the edit ONLY inside the marked region shown in the attached mask image.',
+ 'White area in the mask = edit zone. Black area = do not change.',
+ 'Leave everything outside the marked region pixel-accurate to the original photo.'
+ );
+ }
+
+ lines.push(
+ '',
+ `Output exactly one edited bouquet photo. ${BOUQUET_IMAGE_ASPECT_PROMPT}`,
+ 'No side-by-side, before/after, or duplicate bouquets.'
+ );
+
+ return lines.join('\n');
+}
diff --git a/src/lib/flowerFlow/resolveRecipeFlowers.js b/src/lib/flowerFlow/resolveRecipeFlowers.js
index a2a4816..c0ebc86 100644
--- a/src/lib/flowerFlow/resolveRecipeFlowers.js
+++ b/src/lib/flowerFlow/resolveRecipeFlowers.js
@@ -41,12 +41,58 @@ function matchCatalogFlower(label) {
return null;
}
+/**
+ * Cap recipe flower lists to florist-realistic counts and demote extra mains to sub.
+ * @param {{ mainFlowers?: string[], subFlowers?: string[], greenery?: string[] }} recipe
+ */
+export function normalizeRecipeLists(recipe) {
+ if (!recipe) return recipe;
+
+ /** @type {string[]} */
+ const main = [...(recipe.mainFlowers ?? [])].filter(Boolean);
+ /** @type {string[]} */
+ let sub = [...(recipe.subFlowers ?? [])].filter(Boolean);
+ /** @type {string[]} */
+ const greenery = [...(recipe.greenery ?? [])].filter(Boolean);
+
+ /** @param {string} label */
+ const catalogId = (label) => matchCatalogFlower(label)?.id ?? null;
+
+ /** @param {string} label @param {string[]} list */
+ const listHasLabel = (label, list) => {
+ const id = catalogId(label);
+ if (id == null) {
+ const normalized = normalizeName(label);
+ return list.some((entry) => normalizeName(entry) === normalized);
+ }
+
+ return list.some((entry) => catalogId(entry) === id);
+ };
+
+ while (main.length > 2) {
+ const extra = main.pop();
+ if (!extra || listHasLabel(extra, sub) || listHasLabel(extra, greenery)) continue;
+
+ if (sub.length < 4) {
+ sub.unshift(extra);
+ }
+ }
+
+ return {
+ ...recipe,
+ mainFlowers: main,
+ subFlowers: sub.slice(0, 4),
+ greenery: greenery.slice(0, 2)
+ };
+}
+
/**
* @param {{ mainFlowers?: string[], subFlowers?: string[], greenery?: string[] } | null | undefined} recipe
* @param {(id: number) => string} getImageSrc
* @returns {RecipeFlowerCard[]}
*/
export function resolveRecipeFlowers(recipe, getImageSrc) {
+ const normalized = normalizeRecipeLists(recipe ?? {});
if (!recipe) return [];
/** @type {RecipeFlowerCard[]} */
@@ -77,9 +123,9 @@ export function resolveRecipeFlowers(recipe, getImageSrc) {
}
};
- addFlowers(recipe.mainFlowers, 'main');
- addFlowers(recipe.subFlowers, 'sub');
- addFlowers(recipe.greenery, 'greenery');
+ addFlowers(normalized.mainFlowers, 'main');
+ addFlowers(normalized.subFlowers, 'sub');
+ addFlowers(normalized.greenery, 'greenery');
return cards;
}
@@ -106,6 +152,17 @@ function pickKeywords(items, limit = 2) {
return items.filter(Boolean).slice(0, limit).join(', ');
}
+/** @param {string} value */
+function isHexColor(value) {
+ return /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(value.trim());
+}
+
+/** @param {string[] | undefined} colorPalette @param {number} [limit=2] */
+function pickMoodColors(colorPalette, limit = 2) {
+ const names = (colorPalette ?? []).filter((color) => color && !isHexColor(color));
+ return pickKeywords(names, limit);
+}
+
/**
* Short mood-led title for the result description card.
* @param {{ moodKeywords?: string[], styleImpression?: string[] } | null | undefined} moodAnalysis
@@ -153,16 +210,17 @@ function getPrimaryFlowerFromRecipe(recipe) {
* @param {{ mainFlowers?: string[] } | null | undefined} recipe
*/
export function buildBouquetRationale(moodAnalysis, userInput, recipe) {
+ const normalized = normalizeRecipeLists(recipe ?? {});
const recipient = userInput?.relationship?.trim();
const subject = recipient ? `${recipient}'s` : 'The';
const cardMessage = extractCardMessage(userInput);
- const mainFlower = getPrimaryFlowerFromRecipe(recipe);
+ const mainFlower = getPrimaryFlowerFromRecipe(normalized);
const mood = pickKeywords(
[...(moodAnalysis?.moodKeywords ?? []), ...(moodAnalysis?.styleImpression ?? [])],
2
);
- const colors = pickKeywords(moodAnalysis?.colorPalette, 2);
+ const colors = pickMoodColors(moodAnalysis?.colorPalette, 2);
/** @type {string[]} */
const parts = [];
diff --git a/src/lib/server/flowerFlow/loadGeneratedImage.js b/src/lib/server/flowerFlow/loadGeneratedImage.js
new file mode 100644
index 0000000..cc25933
--- /dev/null
+++ b/src/lib/server/flowerFlow/loadGeneratedImage.js
@@ -0,0 +1,29 @@
+/** @typedef {import('./jobStore.js').GeneratedImage} GeneratedImage */
+
+/**
+ * @param {GeneratedImage} image
+ * @returns {Promise<{ base64: string, mimeType: string }>}
+ */
+export async function loadGeneratedImageBytes(image) {
+ if (image.base64) {
+ return {
+ base64: image.base64,
+ mimeType: image.mimeType || 'image/png'
+ };
+ }
+
+ if (image.url) {
+ const response = await fetch(image.url);
+ if (!response.ok) {
+ throw new Error('Failed to load bouquet image for editing');
+ }
+
+ const buffer = Buffer.from(await response.arrayBuffer());
+ return {
+ base64: buffer.toString('base64'),
+ mimeType: response.headers.get('content-type') || image.mimeType || 'image/png'
+ };
+ }
+
+ throw new Error('Bouquet image has no url or base64 data');
+}
diff --git a/src/lib/server/flowerFlow/selectionMask.js b/src/lib/server/flowerFlow/selectionMask.js
new file mode 100644
index 0000000..971b290
--- /dev/null
+++ b/src/lib/server/flowerFlow/selectionMask.js
@@ -0,0 +1,228 @@
+import zlib from 'node:zlib';
+
+/** @param {Array<{ x: number, y: number }>} polygon */
+function closePolygon(polygon) {
+ if (polygon.length < 3) return polygon;
+ const first = polygon[0];
+ const last = polygon[polygon.length - 1];
+ if (first.x === last.x && first.y === last.y) return polygon;
+ return [...polygon, first];
+}
+
+/**
+ * @param {number} x
+ * @param {number} y
+ * @param {Array<{ x: number, y: number }>} polygon
+ */
+function pointInPolygon(x, y, polygon) {
+ let inside = false;
+ for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
+ const xi = polygon[i].x;
+ const yi = polygon[i].y;
+ const xj = polygon[j].x;
+ const yj = polygon[j].y;
+ const intersects = yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi + Number.EPSILON) + xi;
+ if (intersects) inside = !inside;
+ }
+ return inside;
+}
+
+/** @param {Buffer} buffer */
+function readPngDimensions(buffer) {
+ if (buffer.length < 24 || buffer.toString('ascii', 1, 4) !== 'PNG') {
+ throw new Error('Invalid PNG image');
+ }
+
+ return {
+ width: buffer.readUInt32BE(16),
+ height: buffer.readUInt32BE(20)
+ };
+}
+
+/** @param {Buffer} buffer */
+function readJpegDimensions(buffer) {
+ let offset = 2;
+ while (offset < buffer.length) {
+ if (buffer[offset] !== 0xff) {
+ offset += 1;
+ continue;
+ }
+
+ const marker = buffer[offset + 1];
+ if (marker === 0xc0 || marker === 0xc2 || marker === 0xc1) {
+ return {
+ height: buffer.readUInt16BE(offset + 5),
+ width: buffer.readUInt16BE(offset + 7)
+ };
+ }
+
+ const segmentLength = buffer.readUInt16BE(offset + 2);
+ offset += 2 + segmentLength;
+ }
+
+ throw new Error('Could not read JPEG dimensions');
+}
+
+/**
+ * @param {Buffer} buffer
+ * @param {string} mimeType
+ */
+export function readImageDimensions(buffer, mimeType) {
+ if (mimeType.includes('png') || (buffer[0] === 0x89 && buffer.toString('ascii', 1, 4) === 'PNG')) {
+ return readPngDimensions(buffer);
+ }
+
+ if (mimeType.includes('jpeg') || mimeType.includes('jpg') || (buffer[0] === 0xff && buffer[1] === 0xd8)) {
+ return readJpegDimensions(buffer);
+ }
+
+ throw new Error(`Unsupported image type for mask: ${mimeType}`);
+}
+
+/** @param {number} width @param {number} height @param {Uint8Array} rgba */
+function encodePng(width, height, rgba) {
+ /** @type {number[]} */
+ const rows = [];
+ let stride = 0;
+ for (let y = 0; y < height; y += 1) {
+ rows.push(0);
+ for (let x = 0; x < width; x += 1) {
+ const index = stride + x * 4;
+ rows.push(rgba[index], rgba[index + 1], rgba[index + 2], rgba[index + 3]);
+ }
+ stride += width * 4;
+ }
+
+ const compressed = zlib.deflateSync(Buffer.from(rows));
+
+ /** @type {Buffer[]} */
+ const chunks = [];
+ const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
+
+ /** @param {string} type @param {Buffer} data */
+ const pushChunk = (type, data) => {
+ const typeBuffer = Buffer.from(type, 'ascii');
+ const length = Buffer.alloc(4);
+ length.writeUInt32BE(data.length);
+ const crcInput = Buffer.concat([typeBuffer, data]);
+ const crc = Buffer.alloc(4);
+ crc.writeUInt32BE(crc32(crcInput));
+ chunks.push(length, typeBuffer, data, crc);
+ };
+
+ const ihdr = Buffer.alloc(13);
+ ihdr.writeUInt32BE(width, 0);
+ ihdr.writeUInt32BE(height, 4);
+ ihdr[8] = 8;
+ ihdr[9] = 6;
+ pushChunk('IHDR', ihdr);
+ pushChunk('IDAT', compressed);
+ pushChunk('IEND', Buffer.alloc(0));
+
+ return Buffer.concat([signature, ...chunks]);
+}
+
+/** @param {Buffer} data */
+function crc32(data) {
+ let crc = 0xffffffff;
+ for (let i = 0; i < data.length; i += 1) {
+ crc ^= data[i];
+ for (let bit = 0; bit < 8; bit += 1) {
+ crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
+ }
+ }
+ return (crc ^ 0xffffffff) >>> 0;
+}
+
+/**
+ * OpenAI mask: transparent pixels = edit region, opaque = preserve.
+ * @param {number} width
+ * @param {number} height
+ * @param {Array<{ x: number, y: number }>} selection
+ */
+export function buildOpenAIEditMask(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] = 0;
+ rgba[index + 1] = 0;
+ rgba[index + 2] = 0;
+ rgba[index + 3] = 0;
+ } else {
+ rgba[index] = 255;
+ rgba[index + 1] = 255;
+ rgba[index + 2] = 255;
+ rgba[index + 3] = 255;
+ }
+ }
+ }
+
+ 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) {
+ 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);
+
+ return {
+ base64: maskBuffer.toString('base64'),
+ mimeType: 'image/png',
+ width,
+ height
+ };
+}
diff --git a/src/lib/server/gemini/image.js b/src/lib/server/gemini/image.js
index 45bdcb1..fb872d4 100644
--- a/src/lib/server/gemini/image.js
+++ b/src/lib/server/gemini/image.js
@@ -4,7 +4,7 @@ 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, isOpenAIConfigured } from '../openai/image.js';
+import { generateOpenAIImage, editOpenAIImage, isOpenAIConfigured } from '../openai/image.js';
export function getImageProvider() {
const configured = env.IMAGE_PROVIDER?.trim().toLowerCase();
@@ -21,18 +21,34 @@ export function isImageGenerationConfigured() {
}
/**
+ * @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');
+}
+
+/**
+ * Initial bouquet generation from a text prompt (generating flow).
* @param {string} basePrompt
- * @param {{ edit?: boolean }} [options]
* @returns {Promise
}
*/
-export async function generateBouquetImage(basePrompt, options = {}) {
- const suffix = options.edit
- ? `Generate exactly one edited bouquet image. Show a single bouquet only, centered in frame. Do not show two bouquets, no side-by-side comparison, no before/after layout, and no duplicate arrangements. ${BOUQUET_IMAGE_ASPECT_PROMPT} Keep it realistic, orderable from a real florist, front-facing, and suitable for a customer preview.`
- : `Generate one final bouquet image. ${BOUQUET_IMAGE_ASPECT_PROMPT} Keep it realistic, orderable from a real florist, front-facing, and suitable for a customer preview.`;
+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();
- // Explicit mock mode: develop the full flow without spending any image quota.
if (provider === 'mock') {
return mockGeneratedImage();
}
@@ -50,18 +66,61 @@ export async function generateBouquetImage(basePrompt, options = {}) {
}
const model = getImageModel();
-
const result = await model.generateContent(prompt);
- const parts = result.response.candidates?.[0]?.content?.parts ?? [];
+ return imageFromGeminiResult(result);
+}
- for (const part of parts) {
- if (part.inlineData?.data) {
- return {
- mimeType: part.inlineData.mimeType || 'image/png',
- base64: part.inlineData.data
- };
- }
+/**
+ * Edit an existing bouquet photo using the source image as reference.
+ * @param {{ base64: string, mimeType: string }} sourceImage
+ * @param {string} editPrompt
+ * @param {{ mask?: { base64: string, mimeType: string } | null }} [options]
+ * @returns {Promise}
+ */
+export async function editBouquetImage(sourceImage, editPrompt, options = {}) {
+ const provider = getImageProvider();
+ const mask = options.mask ?? null;
+
+ if (provider === 'mock' || sourceImage.mimeType === 'image/svg+xml') {
+ return mockGeneratedImage('Edited bouquet');
}
- throw new Error('Gemini image model did not return image data');
+ 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);
}
diff --git a/src/lib/server/gemini/mock.js b/src/lib/server/gemini/mock.js
index 4166f77..f737aa6 100644
--- a/src/lib/server/gemini/mock.js
+++ b/src/lib/server/gemini/mock.js
@@ -1,6 +1,8 @@
/** @typedef {import('../flowerFlow/jobStore.js').MoodAnalysis} MoodAnalysis */
/** @typedef {import('../flowerFlow/jobStore.js').BouquetRecipe} BouquetRecipe */
+import { formatStrictBouquetImagePrompt } from '../../flowerFlow/bouquetImageFormat.js';
+
/** @returns {MoodAnalysis} */
export function mockMoodAnalysis() {
return {
@@ -56,15 +58,7 @@ export function mockRecipe(userInput = {}) {
/** @param {BouquetRecipe} recipe */
export function mockImagePrompt(recipe) {
- return [
- 'Generate a realistic florist-style bouquet image.',
- 'Use real flowers only.',
- `Use ${recipe.mainFlowers.join(', ')} as the main flower, mixed with ${recipe.subFlowers.join(', ')}, and ${recipe.greenery.join(', ')}.`,
- `Use a ${recipe.colors.join(', ')} color palette.`,
- `Wrap it with ${recipe.wrapping}.`,
- 'White background, soft natural lighting, Korean florist style.',
- 'Vertical portrait composition with a 3:4 aspect ratio (width:height). Frame the full bouquet without cropping.'
- ].join(' ');
+ return formatStrictBouquetImagePrompt(recipe);
}
/** @param {string} [label] */
diff --git a/src/lib/server/gemini/text.js b/src/lib/server/gemini/text.js
index 56fa76c..f35ce60 100644
--- a/src/lib/server/gemini/text.js
+++ b/src/lib/server/gemini/text.js
@@ -3,9 +3,10 @@
/** @typedef {import('../flowerFlow/jobStore.js').UserInput} UserInput */
import { matchFlowersFromMood } from '../flowerFlow/flowerDB.js';
-import { BOUQUET_IMAGE_ASPECT_PROMPT } from '../../flowerFlow/bouquetImageFormat.js';
+import { formatStrictBouquetImagePrompt } from '../../flowerFlow/bouquetImageFormat.js';
+import { normalizeRecipeLists } from '../../flowerFlow/resolveRecipeFlowers.js';
import { getTextModel, isGeminiConfigured, parseJsonFromText } from './client.js';
-import { mockRecipe } from './mock.js';
+import { mockApplyRecipeEdit, mockRecipe } from './mock.js';
/**
* @param {MoodAnalysis} mood
@@ -19,7 +20,7 @@ export async function buildBouquetRecipe(mood, userInput = {}) {
: 'around ₩50,000';
if (!isGeminiConfigured()) {
- return mockRecipe(userInput);
+ return normalizeRecipeLists(mockRecipe(userInput));
}
const model = getTextModel();
@@ -67,7 +68,7 @@ Rules:
- Budget should be ${budget}.`;
const result = await model.generateContent(prompt);
- return /** @type {BouquetRecipe} */ (parseJsonFromText(result.response.text()));
+ return normalizeRecipeLists(/** @type {BouquetRecipe} */ (parseJsonFromText(result.response.text())));
}
/**
@@ -75,29 +76,7 @@ Rules:
* @returns {Promise}
*/
export async function buildImagePrompt(recipe) {
- if (!isGeminiConfigured()) {
- const { mockImagePrompt } = await import('./mock.js');
- return mockImagePrompt(recipe);
- }
-
- const model = getTextModel();
- const prompt = `Write one detailed image generation prompt for a realistic florist bouquet.
-Use this recipe:
-${JSON.stringify(recipe, null, 2)}
-
-Rules:
-- Real flowers only — use the exact flower names from the recipe
-- mainFlowers are the focal blooms; subFlowers add volume and line; greenery frames the bouquet
-- No fantasy colors or surreal shapes
-- White background, soft natural lighting
-- Korean florist style
-- Describe bouquet composition only (flower types, colors, wrapping, mood)
-- ${BOUQUET_IMAGE_ASPECT_PROMPT}
-- Do NOT specify alternate size variants; generate one final customer preview image
-- Return plain text only, no markdown`;
-
- const result = await model.generateContent(prompt);
- return result.response.text().trim();
+ return formatStrictBouquetImagePrompt(recipe);
}
/**
@@ -131,8 +110,7 @@ Return plain text only.`;
*/
export async function applyRecipeEdit(recipe, editPrompt) {
if (!isGeminiConfigured()) {
- const { mockApplyRecipeEdit } = await import('./mock.js');
- return mockApplyRecipeEdit(recipe, editPrompt);
+ return normalizeRecipeLists(mockApplyRecipeEdit(recipe, editPrompt));
}
const model = getTextModel();
@@ -160,8 +138,11 @@ Return JSON only with the same schema:
Rules:
- Change only what the edit request implies; keep unrelated fields the same.
- Use realistic florist flower names.
-- mainFlowers, subFlowers, and greenery must stay consistent with the edit.`;
+- If the edit changes flower types (swap, add, remove, or replace), update mainFlowers, subFlowers, and/or greenery so the recipe matches exactly.
+- Flower swaps (e.g. "change tulip to rose") must update the matching list entry; new flowers must use standard catalog names.
+- mainFlowers: 1-2 items. subFlowers: 1-4 items. greenery: 1-2 items.
+- The updated recipe is the sole source of truth for the next bouquet image — every listed flower must be included in the image prompt.`;
const result = await model.generateContent(prompt);
- return /** @type {BouquetRecipe} */ (parseJsonFromText(result.response.text()));
+ return normalizeRecipeLists(/** @type {BouquetRecipe} */ (parseJsonFromText(result.response.text())));
}
diff --git a/src/lib/server/gemini/vision.js b/src/lib/server/gemini/vision.js
index 32ff96e..718fe05 100644
--- a/src/lib/server/gemini/vision.js
+++ b/src/lib/server/gemini/vision.js
@@ -26,6 +26,8 @@ Return JSON only with this shape:
"energyLevel": "low" | "medium" | "high"
}
+Use plain color names only (e.g. ivory, navy, blush). Do not use hex codes or RGB values.
+
User context:
- relationship: ${userInput.relationship ?? 'unknown'}
- occasion: ${userInput.occasion ?? 'unknown'}
diff --git a/src/lib/server/openai/image.js b/src/lib/server/openai/image.js
index 0b10c2d..8bd2c94 100644
--- a/src/lib/server/openai/image.js
+++ b/src/lib/server/openai/image.js
@@ -1,5 +1,5 @@
import { env } from '$env/dynamic/private';
-import OpenAI from 'openai';
+import OpenAI, { toFile } from 'openai';
let client = null;
@@ -52,3 +52,53 @@ export async function generateOpenAIImage(prompt) {
throw new Error('OpenAI image model did not return image data');
}
+
+/**
+ * @param {string} prompt
+ * @param {{ base64: string, mimeType: string }} sourceImage
+ * @param {{ base64: string, mimeType: string } | null} [mask]
+ * @returns {Promise}
+ */
+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 });
+
+ /** @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',
+ n: 1
+ };
+
+ if (mask) {
+ const maskFile = await toFile(Buffer.from(mask.base64, 'base64'), 'mask.png', {
+ type: 'image/png'
+ });
+ params.mask = maskFile;
+ }
+
+ const response = await getOpenAIClient().images.edit(params);
+
+ 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');
+}
diff --git a/src/routes/api/flower-flow/edit-images/+server.js b/src/routes/api/flower-flow/edit-images/+server.js
index c2c8341..b8164c7 100644
--- a/src/routes/api/flower-flow/edit-images/+server.js
+++ b/src/routes/api/flower-flow/edit-images/+server.js
@@ -1,11 +1,15 @@
import { requireJob, updateJob } from '$lib/server/flowerFlow/jobStore.js';
+import { loadGeneratedImageBytes } from '$lib/server/flowerFlow/loadGeneratedImage.js';
+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 {
- generateBouquetImage,
+ editBouquetImage,
getImageProvider,
isImageGenerationConfigured
} from '$lib/server/gemini/image.js';
-import { buildImagePrompt, applyRecipeEdit } from '$lib/server/gemini/text.js';
+import { applyRecipeEdit } from '$lib/server/gemini/text.js';
import { json, readJsonBody, toErrorResponse } from '$lib/server/http.js';
/**
@@ -24,29 +28,6 @@ function isPointArray(value) {
);
}
-/**
- * @param {{ mode: string, prompt: string, selection: unknown }} instruction
- */
-function describeEditInstruction(instruction) {
- const lines = [
- 'EDIT REQUEST:',
- instruction.prompt,
- '',
- 'This is a refinement of one existing bouquet photo, not a new collage.',
- 'Preserve the same bouquet concept, camera angle, background, wrapping style, and realistic florist photography unless the edit request explicitly says otherwise.',
- 'Output exactly one bouquet in a single composition. Never show two bouquets, side-by-side views, comparison panels, or duplicated arrangements.'
- ];
-
- if (instruction.mode === 'area') {
- lines.push(
- 'The user drew a target area on the image. Apply the edit only to that visual region as much as possible, while keeping the rest of the bouquet unchanged.',
- `Selection points are normalized image coordinates: ${JSON.stringify(instruction.selection)}`
- );
- }
-
- return lines.join('\n');
-}
-
/** @type {import('./$types').RequestHandler} */
export async function POST({ request }) {
try {
@@ -74,14 +55,37 @@ export async function POST({ request }) {
return json({ error: 'recipe is missing. Run recipe first.', code: 'bad_request' }, 400);
}
+ if (!job.images?.primary) {
+ return json({ error: 'bouquet image is missing. Generate images first.', code: 'bad_request' }, 400);
+ }
+
+ const priorRecipe = normalizeRecipeLists(job.recipe);
const updatedRecipe = await applyRecipeEdit(job.recipe, prompt);
- const basePrompt = job.imagePrompt ?? (await buildImagePrompt(updatedRecipe));
- const editPrompt = `${basePrompt}\n\n${describeEditInstruction({ mode, prompt, selection })}`;
+ const recipeChanged = JSON.stringify(updatedRecipe) !== JSON.stringify(priorRecipe);
+
+ const sourceImage = await loadGeneratedImageBytes(job.images.primary);
+ const editPrompt = formatBouquetEditPrompt({
+ userPrompt: prompt,
+ mode,
+ selection,
+ recipe: updatedRecipe,
+ recipeChanged
+ });
+
+ const provider = getImageProvider();
+ const mask =
+ mode === 'area' && selection.length >= 3
+ ? buildAreaEditMask(
+ sourceImage,
+ selection,
+ provider === 'gemini' ? 'gemini' : 'openai'
+ )
+ : null;
console.log(
- `[flower-flow] edit-images job=${jobId.slice(0, 8)} provider=${getImageProvider()} mode=${mode} → generating...`
+ `[flower-flow] edit-images job=${jobId.slice(0, 8)} provider=${provider} mode=${mode}${mask ? ' (masked)' : ''} → editing...`
);
- const generatedImage = await generateBouquetImage(editPrompt, { edit: true });
+ const generatedImage = await editBouquetImage(sourceImage, editPrompt, { mask });
const images = await uploadGeneratedImages(
jobId,
generatedImage,
diff --git a/src/routes/api/flower-flow/generate-images/+server.js b/src/routes/api/flower-flow/generate-images/+server.js
index bb344ed..17b28c6 100644
--- a/src/routes/api/flower-flow/generate-images/+server.js
+++ b/src/routes/api/flower-flow/generate-images/+server.js
@@ -1,4 +1,5 @@
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,
@@ -29,11 +30,12 @@ function generateForJob(jobId, recipe) {
if (existing) return existing;
const task = (async () => {
- const imagePrompt = await buildImagePrompt(recipe);
+ const normalizedRecipe = normalizeRecipeLists(recipe);
+ const imagePrompt = await buildImagePrompt(normalizedRecipe);
const generatedImage = await generateBouquetImage(imagePrompt);
const images = await uploadGeneratedImages(jobId, generatedImage, `initial-${Date.now()}`);
- await updateJob(jobId, { imagePrompt, images });
- return { imagePrompt, images };
+ await updateJob(jobId, { imagePrompt, images, recipe: normalizedRecipe });
+ return { imagePrompt, images, recipe: normalizedRecipe };
})().finally(() => {
inFlight.delete(jobId);
});
@@ -73,7 +75,7 @@ export async function POST({ request }) {
console.log(
`[flower-flow] generate-images job=${jobId.slice(0, 8)} provider=${getImageProvider()} → generating...`
);
- const { imagePrompt, images } = await generateForJob(jobId, job.recipe);
+ const { imagePrompt, images, recipe: savedRecipe } = await generateForJob(jobId, job.recipe);
console.log(
`[flower-flow] generate-images job=${jobId.slice(0, 8)} OK (mock=${!isImageGenerationConfigured()})`
);
@@ -82,6 +84,7 @@ export async function POST({ request }) {
jobId,
imagePrompt,
images,
+ recipe: savedRecipe,
mock: !isImageGenerationConfigured()
});
} catch (error) {
diff --git a/src/routes/api/flower-flow/recipe/+server.js b/src/routes/api/flower-flow/recipe/+server.js
index 105c68d..b05c00f 100644
--- a/src/routes/api/flower-flow/recipe/+server.js
+++ b/src/routes/api/flower-flow/recipe/+server.js
@@ -1,4 +1,5 @@
import { requireJob, updateJob } from '$lib/server/flowerFlow/jobStore.js';
+import { normalizeRecipeLists } from '$lib/flowerFlow/resolveRecipeFlowers.js';
import { buildBouquetRecipe } from '$lib/server/gemini/text.js';
import { isGeminiConfigured } from '$lib/server/gemini/client.js';
import { json, readJsonBody, toErrorResponse } from '$lib/server/http.js';
@@ -26,7 +27,9 @@ export async function POST({ request }) {
}
const currentJob = await requireJob(jobId);
- const recipe = await buildBouquetRecipe(currentJob.moodAnalysis, currentJob.userInput);
+ const recipe = normalizeRecipeLists(
+ await buildBouquetRecipe(currentJob.moodAnalysis, currentJob.userInput)
+ );
await updateJob(jobId, { recipe });
return json({
diff --git a/src/routes/edit/+page.svelte b/src/routes/edit/+page.svelte
index a1f25c8..2b642ec 100644
--- a/src/routes/edit/+page.svelte
+++ b/src/routes/edit/+page.svelte
@@ -337,7 +337,7 @@