fix: edit bouquet photos in-place with recipe sync and area masks

* fix: edit bouquet photos in-place with recipe sync and area masks

* fix: silence MapPanel state_referenced_locally warning
This commit is contained in:
Chaewon Lee
2026-06-15 09:16:06 +09:00
committed by GitHub
parent e0f6058ff3
commit 063a193396
15 changed files with 605 additions and 101 deletions

View File

@@ -25,7 +25,7 @@
mobile: row · desktop: 꽃 슬롯 높이 고정 → 설명 카드 길이와 무관하게 Y·크기 유지
-->
<div
class="mx-auto flex min-h-0 w-full max-w-100 flex-1 flex-row items-start gap-8 px-6 py-6 lg:flex-col lg:items-center lg:justify-start lg:gap-4 lg:px-6 lg:pb-8 lg:pt-[calc(50%-5rem)]"
class="mx-auto flex min-h-0 w-full max-w-100 flex-1 flex-row items-start gap-8 px-6 pt-6 pb-8 lg:flex-col lg:items-center lg:justify-start lg:gap-4 lg:px-6 lg:pb-12 lg:pt-[calc(50%-5rem)]"
>
<div
class="flex h-[11rem] shrink-0 items-end justify-center sm:h-[13rem] lg:h-[min(24rem,36vh)] lg:w-full"

View File

@@ -1,4 +1,5 @@
<script>
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;

View File

@@ -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');
}

View File

@@ -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 = [];

View File

@@ -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');
}

View File

@@ -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
};
}

View File

@@ -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<GeneratedImage>}
*/
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<GeneratedImage>}
*/
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);
}

View File

@@ -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] */

View File

@@ -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<string>}
*/
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())));
}

View File

@@ -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'}

View File

@@ -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<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 });
/** @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');
}

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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({

View File

@@ -337,7 +337,7 @@
<main class="flex min-h-0 flex-1 flex-col lg:flex-row">
<section
class="flex min-h-0 w-full shrink-0 flex-col border-b border-line px-6 py-6 lg:w-[44%] lg:border-r lg:border-b-0 lg:px-10 lg:py-8"
class="flex min-h-0 w-full shrink-0 flex-col border-b border-line px-6 py-6 lg:w-[44%] lg:border-r lg:border-b-0 lg:px-10 lg:py-8 lg:pb-12"
>
<div class="mx-auto flex min-h-0 w-full max-w-100 flex-1 flex-col items-center justify-center gap-6">
<div class="w-full max-w-24 overflow-hidden bg-track shadow-sm ring-1 ring-black/5 sm:max-w-28 lg:max-w-75">