diff --git a/src/lib/components/ui/Artwork/Artwork.svelte b/src/lib/components/ui/Artwork/Artwork.svelte index 4429c21..3e0d4e2 100644 --- a/src/lib/components/ui/Artwork/Artwork.svelte +++ b/src/lib/components/ui/Artwork/Artwork.svelte @@ -25,7 +25,7 @@ mobile: row · desktop: 꽃 슬롯 높이 고정 → 설명 카드 길이와 무관하게 Y·크기 유지 -->
+ 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 @@