chore: QA fixes for edit, map, and result pages

* fix: align result cards with recipe and tighten bouquet edit prompts

* fix: improve masked area edits with aligned masks and inpainting prompts

* refactor: drop floristNote/finalize and add map description

* fix: prevent map page crash from marker effect loop

* chore: add eslint exception for KakaoMap shopMarkerMap
This commit is contained in:
Chaewon Lee
2026-06-16 00:01:27 +09:00
committed by GitHub
parent c4748cdc05
commit 0414393be7
21 changed files with 310 additions and 173 deletions

View File

@@ -1,4 +1,4 @@
# Gemini — mood analysis, recipe, florist note # Gemini — mood analysis, recipe
GEMINI_API_KEY= GEMINI_API_KEY=
GEMINI_TEXT_MODEL=gemini-2.5-flash-lite GEMINI_TEXT_MODEL=gemini-2.5-flash-lite

View File

@@ -1,6 +1,5 @@
<script> <script>
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { SvelteMap } from 'svelte/reactivity';
import { env } from '$env/dynamic/public'; import { env } from '$env/dynamic/public';
let { let {
@@ -24,8 +23,11 @@
let mapInstance = $state(null); let mapInstance = $state(null);
/** @type {ReturnType<typeof window.kakao.maps.InfoWindow> | null} */ /** @type {ReturnType<typeof window.kakao.maps.InfoWindow> | null} */
let infoWindow = null; let infoWindow = null;
/** @type {SvelteMap<string, { marker: ReturnType<typeof window.kakao.maps.Marker>; shop: (typeof shops)[number] }>} */ // 마커↔가게 내부 장부. 템플릿에서 반응형으로 읽지 않으므로 일반 Map 사용.
let shopMarkerMap = new SvelteMap(); // SvelteMap이면 markers $effect가 같은 맵을 읽고/쓰며 무한 루프(effect_update_depth_exceeded)가 남.
/** @type {Map<string, { marker: ReturnType<typeof window.kakao.maps.Marker>; shop: (typeof shops)[number] }>} */
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- 의도적 비반응형: 위 설명 참고
let shopMarkerMap = new Map();
function relayoutMap() { function relayoutMap() {
mapInstance?.relayout?.(); mapInstance?.relayout?.();
@@ -186,6 +188,7 @@
}); });
// 리스트에서 가게 선택 시에만 이동 (panTo가 바뀔 때) // 리스트에서 가게 선택 시에만 이동 (panTo가 바뀔 때)
const SELECTED_MAP_LEVEL = 4;
$effect(() => { $effect(() => {
const map = mapInstance; const map = mapInstance;
const target = panTo; const target = panTo;
@@ -195,7 +198,13 @@
const centerLng = Number(target.lng); const centerLng = Number(target.lng);
if (!Number.isFinite(centerLat) || !Number.isFinite(centerLng)) return; if (!Number.isFinite(centerLat) || !Number.isFinite(centerLng)) return;
map.panTo(new window.kakao.maps.LatLng(centerLat, centerLng)); const position = new window.kakao.maps.LatLng(centerLat, centerLng);
// 고른 가게를 또렷하게 보여주려고, 너무 멀리 있을 때만 가까이 확대한 뒤 이동
if (map.getLevel() > SELECTED_MAP_LEVEL) {
map.setLevel(SELECTED_MAP_LEVEL, { anchor: position, animate: true });
}
map.panTo(position);
}); });
/** /**

View File

@@ -100,17 +100,6 @@ export async function editImages(jobId, editInstruction) {
return parseResponse(response); return parseResponse(response);
} }
/** @param {string} jobId */
export async function finalizeJob(jobId) {
const response = await fetch('/api/flower-flow/finalize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jobId })
});
return parseResponse(response);
}
/** @param {string} jobId */ /** @param {string} jobId */
export async function fetchJob(jobId) { export async function fetchJob(jobId) {
const response = await fetch(`/api/flower-flow/job?jobId=${encodeURIComponent(jobId)}`); const response = await fetch(`/api/flower-flow/job?jobId=${encodeURIComponent(jobId)}`);

View File

@@ -6,19 +6,30 @@ 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.'; '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[] }} recipe
* @param {{ mainFlowers?: string[], subFlowers?: string[], greenery?: string[], colors?: string[], wrapping?: string, shape?: string }} recipe
* @returns {string}
*/ */
export function formatStrictBouquetImagePrompt(recipe) { export function getRecipeFlowerLists(recipe) {
const mains = (recipe.mainFlowers ?? []).filter(Boolean); const mains = (recipe.mainFlowers ?? []).filter(Boolean);
const subs = (recipe.subFlowers ?? []).filter(Boolean); const subs = (recipe.subFlowers ?? []).filter(Boolean);
const greenery = (recipe.greenery ?? []).filter(Boolean); const greenery = (recipe.greenery ?? []).filter(Boolean);
const allFlowers = [...mains, ...subs, ...greenery];
return {
mains,
subs,
greenery,
allFlowers: [...mains, ...subs, ...greenery]
};
}
/**
* Strict flower list + hard constraints shared by generation and edit prompts.
* @param {{ mainFlowers?: string[], subFlowers?: string[], greenery?: string[], colors?: string[], wrapping?: string, shape?: string }} recipe
* @returns {string}
*/
export function formatStrictRecipeConstraints(recipe) {
const { mains, subs, greenery, allFlowers } = getRecipeFlowerLists(recipe);
return [ return [
'Generate a realistic Korean florist bouquet product photo.',
'',
'STRICT RECIPE — the bouquet must contain ONLY these flowers and NO other flower species:', 'STRICT RECIPE — the bouquet must contain ONLY these flowers and NO other flower species:',
allFlowers.length > 0 allFlowers.length > 0
? allFlowers.map((flower) => `- ${flower}`).join('\n') ? allFlowers.map((flower) => `- ${flower}`).join('\n')
@@ -33,13 +44,27 @@ export function formatStrictBouquetImagePrompt(recipe) {
'', '',
'Hard constraints:', 'Hard constraints:',
'- Do NOT add any flower, filler, or foliage species not listed above', '- Do NOT add any flower, filler, or foliage species not listed above',
'- EVERY species listed above MUST appear in the final image', '- Include EVERY listed flower without omission — each must be clearly visible; none may be missing, hidden, or left out',
'- Do not swap or substitute any listed species unless the edit request explicitly requires that change',
'- Real cut flowers only; no fantasy colors or impossible hybrids', '- Real cut flowers only; no fantasy colors or impossible hybrids',
`- ${BOUQUET_IMAGE_ASPECT_PROMPT}`, `- ${BOUQUET_IMAGE_ASPECT_PROMPT}`,
'- White background, soft natural lighting, front-facing, orderable from a real Korean florist' '- White background, soft natural lighting, front-facing, orderable from a real Korean florist'
].join('\n'); ].join('\n');
} }
/**
* 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) {
return [
'Generate a realistic Korean florist bouquet product photo.',
'',
formatStrictRecipeConstraints(recipe)
].join('\n');
}
/** /**
* Prompt for editing an existing bouquet photo (reference image passed separately). * Prompt for editing an existing bouquet photo (reference image passed separately).
* @param {{ * @param {{
@@ -53,6 +78,35 @@ export function formatStrictBouquetImagePrompt(recipe) {
*/ */
export function formatBouquetEditPrompt(options) { export function formatBouquetEditPrompt(options) {
const { userPrompt, mode, selection, recipe, recipeChanged = false } = options; const { userPrompt, mode, selection, recipe, recipeChanged = false } = options;
const isAreaEdit = mode === 'area' && selection && selection.length >= 3;
if (isAreaEdit) {
return [
'You are editing the attached florist bouquet photograph with a binary mask.',
'This is a localized inpainting edit — NOT a full bouquet redesign or re-render.',
'',
`Edit request (masked region only): ${userPrompt}`,
'',
'How to edit inside the mask:',
'- Apply the edit request only to whatever is inside the transparent mask region (flowers, ribbon, wrapping, foliage, etc.)',
'- The request may be a color/style tweak OR a content swap — e.g. replace blooms in this area with roses, change ribbon color, adjust wrapping',
'- When swapping flowers inside the mask, render the requested species naturally in that region; blend stems, lighting, and edges with the surrounding bouquet',
'- Keep realistic material detail — petal texture, fabric folds, paper creases, shadows, and lighting — seamless with the rest of the photo',
'- Do not paste a flat color block; the edited area should look naturally photographed',
'- Do not use solid black unless the user explicitly asked for black',
'',
'Mask rules (mandatory):',
'- Transparent pixels in the attached mask = the ONLY area you may change',
'- Opaque pixels in the mask = leave completely unchanged',
'- Do NOT recolor, restyle, brighten, blur, regenerate, or swap species outside the mask',
'- Do NOT apply the edit request to the whole image',
'',
'Preserve everywhere outside the mask:',
'- All flowers, foliage, wrapping, ribbon, background, lighting, and framing exactly as in the input photo',
'',
'Output exactly one edited photo. No before/after collage.'
].join('\n');
}
const lines = [ const lines = [
'You are editing the attached florist bouquet photograph.', 'You are editing the attached florist bouquet photograph.',
@@ -66,23 +120,15 @@ export function formatBouquetEditPrompt(options) {
'- Every flower species and greenery not involved in the edit request' '- Every flower species and greenery not involved in the edit request'
]; ];
if (recipeChanged && recipe) { if (recipeChanged) {
lines.push( lines.push(
'', '',
'This edit changes the flower list. Update only the affected blooms; keep the rest of the arrangement intact:', 'This edit changes the flower list. Update only the affected blooms in the photo; keep every other listed species exactly as before.'
`Main blooms: ${(recipe.mainFlowers ?? []).join(', ') || 'none'}`,
`Filler/line: ${(recipe.subFlowers ?? []).join(', ') || 'none'}`,
`Greenery: ${(recipe.greenery ?? []).join(', ') || 'none'}`
); );
} }
if (mode === 'area' && selection && selection.length >= 3) { if (recipe) {
lines.push( lines.push('', formatStrictRecipeConstraints(recipe));
'',
'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( lines.push(

View File

@@ -20,24 +20,50 @@ function primaryName(name) {
} }
/** /**
* Match a recipe flower string (e.g. "Pink tulip") to a catalog entry. * Match a recipe flower string (e.g. "Pink tulip") to one catalog entry.
* Exact / primary-name matches first; modifier + species only as a last resort.
* @param {string} label * @param {string} label
* @returns {(typeof flowerCatalogLite)[number] | null} * @returns {(typeof flowerCatalogLite)[number] | null}
*/ */
function matchCatalogFlower(label) { export function matchCatalogFlower(label) {
if (!label?.trim()) return null;
const normalized = normalizeName(label); const normalized = normalizeName(label);
for (const flower of flowerCatalogLite) { for (const flower of flowerCatalogLite) {
const catalogPrimary = primaryName(flower.name); if (normalized === normalizeName(flower.name)) {
if (
normalized === catalogPrimary ||
normalized.includes(catalogPrimary) ||
catalogPrimary.includes(normalized)
) {
return flower; return flower;
} }
} }
const labelPrimary = primaryName(label);
/** @type {typeof flowerCatalogLite} */
const primaryMatches = [];
for (const flower of flowerCatalogLite) {
if (primaryName(flower.name) === labelPrimary) {
primaryMatches.push(flower);
}
}
if (primaryMatches.length === 1) {
return primaryMatches[0];
}
if (primaryMatches.length > 1) {
const exact = primaryMatches.find((flower) => normalizeName(flower.name) === normalized);
if (exact) return exact;
return primaryMatches.sort((a, b) => b.name.length - a.name.length)[0];
}
const bySpecificity = [...flowerCatalogLite].sort((a, b) => b.name.length - a.name.length);
for (const flower of bySpecificity) {
const catalogPrimary = primaryName(flower.name);
if (normalized === catalogPrimary) return flower;
if (normalized.endsWith(` ${catalogPrimary}`)) return flower;
}
return null; return null;
} }
@@ -143,6 +169,63 @@ export function truncateDescription(text, maxLength = 140) {
return `${trimmed.slice(0, maxLength - 1).trimEnd()}`; return `${trimmed.slice(0, maxLength - 1).trimEnd()}`;
} }
/**
* One-line context for the map order card (mood, recipient, or recipe concept).
* @param {{ moodKeywords?: string[], styleImpression?: string[] } | null | undefined} moodAnalysis
* @param {{ relationship?: string, notes?: string } | null | undefined} userInput
* @param {{ concept?: string } | null | undefined} recipe
*/
function buildMapOrderIntro(moodAnalysis, userInput, recipe) {
const recipient = userInput?.relationship?.trim();
const mood = pickKeywords(
[...(moodAnalysis?.moodKeywords ?? []), ...(moodAnalysis?.styleImpression ?? [])],
2
);
const hasCardMessage = Boolean(extractCardMessage(userInput));
if (hasCardMessage && recipient) {
return `A bouquet for ${recipient}, shaped around your card message`;
}
if (hasCardMessage) {
return 'A bouquet shaped around your card message';
}
if (mood && recipient) {
return `A ${mood} bouquet for ${recipient}`;
}
if (mood) {
return `A ${mood} bouquet from your moodboard`;
}
if (recipe?.concept?.trim()) {
return recipe.concept.trim();
}
if (recipient) {
return `A custom bouquet for ${recipient}`;
}
return 'Your custom bouquet design';
}
/**
* Map order card — short intro plus flower species (main → sub → greenery, capped).
* @param {{ mainFlowers?: string[], subFlowers?: string[], greenery?: string[], concept?: string } | null | undefined} recipe
* @param {{
* moodAnalysis?: { moodKeywords?: string[], styleImpression?: string[] } | null,
* userInput?: { relationship?: string, notes?: string } | null,
* maxFlowers?: number
* }} [options]
*/
export function buildMapOrderDescription(recipe, options = {}) {
const { moodAnalysis = null, userInput = null, maxFlowers = 4 } = options;
const flowers = resolveRecipeFlowers(recipe, () => '').slice(0, maxFlowers);
if (flowers.length === 0) {
return 'Your selected bouquet design.';
}
const intro = buildMapOrderIntro(moodAnalysis, userInput, recipe);
const flowerList = flowers.map((flower) => flower.name).join(', ');
return `${intro}: ${flowerList}.`;
}
/** /**
* @param {string[]} [items] * @param {string[]} [items]
* @param {number} [limit=2] * @param {number} [limit=2]

View File

@@ -1,6 +1,5 @@
import { createJob, updateJob } from '$lib/server/flowerFlow/jobStore.js'; import { createJob, updateJob } from '$lib/server/flowerFlow/jobStore.js';
import { import {
mockFloristNote,
mockImagePrompt, mockImagePrompt,
mockMoodAnalysis, mockMoodAnalysis,
mockRecipe mockRecipe
@@ -8,18 +7,14 @@ import {
import { uploadGeneratedImages } from '$lib/server/flowerFlow/imageStorage.js'; import { uploadGeneratedImages } from '$lib/server/flowerFlow/imageStorage.js';
import { loadDevBouquetImage } from './loadFixtureImages.js'; import { loadDevBouquetImage } from './loadFixtureImages.js';
/** @typedef {'options' | 'result'} DevSeedStage */
/** /**
* AI 없이 서버 job + sessionStorage용 payload를 한 번에 만듭니다. * AI 없이 서버 job + sessionStorage용 payload를 한 번에 만듭니다.
* @param {Record<string, unknown>} userInput * @param {Record<string, unknown>} userInput
* @param {DevSeedStage} [stage='result']
*/ */
export async function seedDevJob(userInput, stage = 'result') { export async function seedDevJob(userInput) {
const moodAnalysis = mockMoodAnalysis(); const moodAnalysis = mockMoodAnalysis();
const recipe = mockRecipe(userInput); const recipe = mockRecipe(userInput);
const imagePrompt = mockImagePrompt(recipe); const imagePrompt = mockImagePrompt(recipe);
const floristNote = stage === 'result' ? mockFloristNote(recipe) : null;
const job = await createJob(userInput); const job = await createJob(userInput);
const images = await uploadGeneratedImages(job.id, loadDevBouquetImage(), `dev-${Date.now()}`); const images = await uploadGeneratedImages(job.id, loadDevBouquetImage(), `dev-${Date.now()}`);
@@ -27,8 +22,7 @@ export async function seedDevJob(userInput, stage = 'result') {
moodAnalysis, moodAnalysis,
recipe, recipe,
imagePrompt, imagePrompt,
images, images
...(stage === 'result' ? { floristNote } : {})
}); });
return { return {
@@ -37,7 +31,6 @@ export async function seedDevJob(userInput, stage = 'result') {
recipe, recipe,
imagePrompt, imagePrompt,
images, images,
floristNote,
mock: true mock: true
}; };
} }

View File

@@ -49,7 +49,6 @@ import { getSupabaseClient, throwSupabaseError } from '$lib/server/supabase.js';
* @property {BouquetRecipe | null} recipe * @property {BouquetRecipe | null} recipe
* @property {string | null} imagePrompt * @property {string | null} imagePrompt
* @property {{ primary?: GeneratedImage }} images * @property {{ primary?: GeneratedImage }} images
* @property {string | null} floristNote
*/ */
/** /**
@@ -64,8 +63,7 @@ function fromRow(row) {
moodAnalysis: row.mood_analysis ?? null, moodAnalysis: row.mood_analysis ?? null,
recipe: row.recipe ?? null, recipe: row.recipe ?? null,
imagePrompt: row.image_prompt ?? null, imagePrompt: row.image_prompt ?? null,
images: row.images ?? {}, images: row.images ?? {}
floristNote: row.florist_note ?? null
}; };
} }
@@ -81,7 +79,6 @@ function toRowPatch(patch) {
if ('recipe' in patch) row.recipe = patch.recipe; if ('recipe' in patch) row.recipe = patch.recipe;
if ('imagePrompt' in patch) row.image_prompt = patch.imagePrompt; if ('imagePrompt' in patch) row.image_prompt = patch.imagePrompt;
if ('images' in patch) row.images = patch.images ?? {}; if ('images' in patch) row.images = patch.images ?? {};
if ('floristNote' in patch) row.florist_note = patch.floristNote;
return row; return row;
} }
@@ -99,8 +96,7 @@ export async function createJob(userInput = {}) {
moodAnalysis: null, moodAnalysis: null,
recipe: null, recipe: null,
imagePrompt: null, imagePrompt: null,
images: {}, images: {}
floristNote: null
}; };
const { error } = await getSupabaseClient() const { error } = await getSupabaseClient()

View File

@@ -14,7 +14,7 @@ export function isImageGenerationConfigured() {
* @returns {Promise<GeneratedImage>} * @returns {Promise<GeneratedImage>}
*/ */
export async function generateBouquetImage(basePrompt) { 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 suffix = `Generate one final bouquet image. ${BOUQUET_IMAGE_ASPECT_PROMPT} The STRICT RECIPE flower list above is mandatory: include every listed species without omission 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 prompt = `${basePrompt}\n\n${suffix}`;
if (!isOpenAIConfigured()) { if (!isOpenAIConfigured()) {

View File

@@ -75,11 +75,6 @@ export function mockGeneratedImage(label = 'Bouquet') {
}; };
} }
/** @param {BouquetRecipe} recipe */
export function mockFloristNote(recipe) {
return `A ${recipe.shape} built around ${recipe.mainFlowers.join(' and ')}, softened with ${recipe.subFlowers.join(', ')} and ${recipe.greenery.join(', ')}. The palette stays ${recipe.colors.join(', ')} with ${recipe.wrapping}. Budget target: ${recipe.budget}.`;
}
/** /**
* Apply a simple swap edit to the recipe in mock mode (e.g. "change tulip to rose"). * Apply a simple swap edit to the recipe in mock mode (e.g. "change tulip to rose").
* @param {BouquetRecipe} recipe * @param {BouquetRecipe} recipe

View File

@@ -2,7 +2,7 @@
/** @typedef {import('../flowerFlow/jobStore.js').MoodAnalysis} MoodAnalysis */ /** @typedef {import('../flowerFlow/jobStore.js').MoodAnalysis} MoodAnalysis */
/** @typedef {import('../flowerFlow/jobStore.js').UserInput} UserInput */ /** @typedef {import('../flowerFlow/jobStore.js').UserInput} UserInput */
import { matchFlowersFromMood } from '../flowerFlow/flowerDB.js'; import { flowerDB, matchFlowersFromMood } from '../flowerFlow/flowerDB.js';
import { formatStrictBouquetImagePrompt } from '../../flowerFlow/bouquetImageFormat.js'; import { formatStrictBouquetImagePrompt } from '../../flowerFlow/bouquetImageFormat.js';
import { normalizeRecipeLists } from '../../flowerFlow/resolveRecipeFlowers.js'; import { normalizeRecipeLists } from '../../flowerFlow/resolveRecipeFlowers.js';
import { getTextModel, isGeminiConfigured, parseJsonFromText } from './client.js'; import { getTextModel, isGeminiConfigured, parseJsonFromText } from './client.js';
@@ -81,27 +81,19 @@ export async function buildImagePrompt(recipe) {
return formatStrictBouquetImagePrompt(recipe); return formatStrictBouquetImagePrompt(recipe);
} }
/** /** Flower names allowed in edited recipes — same source as initial recipe generation. */
* @param {BouquetRecipe} recipe function getFlowerDBCandidatesByRole() {
* @returns {Promise<string>} /** @type {{ main: string[], filler: string[], line: string[], foliage: string[] }} */
*/ const groups = { main: [], filler: [], line: [], foliage: [] };
export async function buildFloristNote(recipe) {
if (!isGeminiConfigured()) { for (const flower of flowerDB) {
const { mockFloristNote } = await import('./mock.js'); if (flower.role === 'main') groups.main.push(flower.name);
return mockFloristNote(recipe); else if (flower.role === 'filler') groups.filler.push(flower.name);
else if (flower.role === 'line') groups.line.push(flower.name);
else if (flower.role === 'foliage') groups.foliage.push(flower.name);
} }
const model = getTextModel(); return groups;
const prompt = `Write a concise florist note for a customer-facing result screen.
Use this bouquet recipe:
${JSON.stringify(recipe, null, 2)}
Tone: warm, professional, specific.
Mention why the main, accent, and greenery choices work together as one cohesive bouquet.
Return plain text only.`;
const result = await model.generateContent(prompt);
return result.response.text().trim();
} }
/** /**
@@ -115,10 +107,14 @@ export async function applyRecipeEdit(recipe, editPrompt) {
return normalizeRecipeLists(mockApplyRecipeEdit(recipe, editPrompt)); return normalizeRecipeLists(mockApplyRecipeEdit(recipe, editPrompt));
} }
const candidates = getFlowerDBCandidatesByRole();
const model = getTextModel(); const model = getTextModel();
const prompt = `You are a professional florist assistant. const prompt = `You are a professional florist assistant.
Update this bouquet recipe so it matches the customer's edit request. Update this bouquet recipe so it matches the customer's edit request.
Allowed flowers from the catalog (use ONLY exact names from these lists):
${JSON.stringify(candidates, null, 2)}
Current recipe: Current recipe:
${JSON.stringify(recipe, null, 2)} ${JSON.stringify(recipe, null, 2)}
@@ -139,11 +135,15 @@ Return JSON only with the same schema:
Rules: Rules:
- Change only what the edit request implies; keep unrelated fields the same. - Change only what the edit request implies; keep unrelated fields the same.
- Use realistic florist flower names. - Use ONLY exact candidate names from the catalog lists above. Do not invent, rename, or substitute flowers.
- If the edit only changes ribbon color, wrapping look, or other local styling without adding/removing/swapping flower species, keep mainFlowers, subFlowers, and greenery identical.
- For localized masked edits (prompt mentions "selected region" or "masked edit"): update flower lists only when the request adds, removes, or swaps a species in that region (e.g. "change this part to roses"); otherwise keep mainFlowers, subFlowers, and greenery identical.
- mainFlowers must come from candidates.main only (1-2 items).
- subFlowers must combine candidates.filler and/or candidates.line only (1-4 items total).
- greenery must come from candidates.foliage only (1-2 items).
- If the edit changes flower types (swap, add, remove, or replace), update mainFlowers, subFlowers, and/or greenery so the recipe matches exactly. - 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. - Flower swaps (e.g. "change tulip to rose") must update the matching list entry using exact 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 appear in the photo without omission.`;
- 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); const result = await model.generateContent(prompt);
return normalizeRecipeLists( return normalizeRecipeLists(

View File

@@ -71,6 +71,35 @@ export async function padToOpenAIRequestSize(buffer) {
.toBuffer(); .toBuffer();
} }
/**
* Reverse {@link padToOpenAIRequestSize} — extract the 768×1024 bouquet from a padded API result.
* @param {Buffer} buffer
* @returns {Promise<Buffer>}
*/
export async function extractPaddedBouquetFrame(buffer) {
const meta = await sharp(buffer).metadata();
const width = meta.width ?? 0;
const height = meta.height ?? 0;
if (width === BOUQUET_OUTPUT_WIDTH && height === BOUQUET_OUTPUT_HEIGHT) {
return buffer;
}
if (width === OPENAI_REQUEST_WIDTH && height === OPENAI_REQUEST_HEIGHT) {
return sharp(buffer)
.extract({
left: PAD_LEFT,
top: PAD_TOP,
width: BOUQUET_OUTPUT_WIDTH,
height: BOUQUET_OUTPUT_HEIGHT
})
.png()
.toBuffer();
}
return frameToBouquetOutput(buffer);
}
/** /**
* Pad an OpenAI edit mask (transparent=edit, opaque=preserve) to the request canvas. * Pad an OpenAI edit mask (transparent=edit, opaque=preserve) to the request canvas.
* @param {Buffer} maskBuffer * @param {Buffer} maskBuffer
@@ -82,7 +111,12 @@ export async function padMaskToOpenAIRequestSize(maskBuffer) {
return maskBuffer; return maskBuffer;
} }
return sharp(maskBuffer) const sized = await sharp(maskBuffer)
.resize(BOUQUET_OUTPUT_WIDTH, BOUQUET_OUTPUT_HEIGHT, { fit: 'fill' })
.png()
.toBuffer();
return sharp(sized)
.extend({ .extend({
top: PAD_TOP, top: PAD_TOP,
bottom: PAD_TOP, bottom: PAD_TOP,

View File

@@ -1,6 +1,7 @@
import { env } from '$env/dynamic/private'; import { env } from '$env/dynamic/private';
import OpenAI, { toFile } from 'openai'; import OpenAI, { toFile } from 'openai';
import { import {
extractPaddedBouquetFrame,
frameToBouquetOutput, frameToBouquetOutput,
padMaskToOpenAIRequestSize, padMaskToOpenAIRequestSize,
padToOpenAIRequestSize, padToOpenAIRequestSize,
@@ -71,9 +72,9 @@ export async function generateOpenAIImage(prompt) {
* @returns {Promise<import('../flowerFlow/jobStore.js').GeneratedImage>} * @returns {Promise<import('../flowerFlow/jobStore.js').GeneratedImage>}
*/ */
export async function editOpenAIImage(prompt, sourceImage, mask = null) { export async function editOpenAIImage(prompt, sourceImage, mask = null) {
const paddedSource = await padToOpenAIRequestSize( const sourceBuffer = Buffer.from(sourceImage.base64, 'base64');
Buffer.from(sourceImage.base64, 'base64') const normalizedSource = await frameToBouquetOutput(sourceBuffer);
); const paddedSource = await padToOpenAIRequestSize(normalizedSource);
const imageFile = await toFile(paddedSource, 'bouquet.png', { type: 'image/png' }); const imageFile = await toFile(paddedSource, 'bouquet.png', { type: 'image/png' });
/** @type {import('openai').default.Images.ImageEditParams} */ /** @type {import('openai').default.Images.ImageEditParams} */
@@ -86,15 +87,21 @@ export async function editOpenAIImage(prompt, sourceImage, mask = null) {
}; };
if (mask) { if (mask) {
const paddedMask = await padMaskToOpenAIRequestSize(Buffer.from(mask.base64, 'base64')); params.mask = await toFile(
params.mask = await toFile(paddedMask, 'mask.png', { type: 'image/png' }); await padMaskToOpenAIRequestSize(Buffer.from(mask.base64, 'base64')),
'mask.png',
{ type: 'image/png' }
);
params.input_fidelity = 'high';
} }
const response = await getOpenAIClient().images.edit(params); const response = await getOpenAIClient().images.edit(params);
const framed = await frameToBouquetOutput(await readImageBytes(response.data)); const editedFrame = mask
? await extractPaddedBouquetFrame(await readImageBytes(response.data))
: await frameToBouquetOutput(await readImageBytes(response.data));
return { return {
mimeType: 'image/png', mimeType: 'image/png',
base64: framed.toString('base64') base64: editedFrame.toString('base64')
}; };
} }

View File

@@ -10,7 +10,7 @@ export const RATE_LIMITS = {
imageGeneration: { limit: 20, windowMs: 60 * 60 * 1000 }, imageGeneration: { limit: 20, windowMs: 60 * 60 * 1000 },
/** Bouquet photo edits (expensive). */ /** Bouquet photo edits (expensive). */
imageEdit: { limit: 40, windowMs: 60 * 60 * 1000 }, imageEdit: { limit: 40, windowMs: 60 * 60 * 1000 },
/** Text recipe + florist note endpoints. */ /** Text recipe endpoints. */
textAi: { limit: 60, windowMs: 60 * 60 * 1000 }, textAi: { limit: 60, windowMs: 60 * 60 * 1000 },
/** Kakao shop search proxy. */ /** Kakao shop search proxy. */
mapShops: { limit: 120, windowMs: 60 * 60 * 1000 } mapShops: { limit: 120, windowMs: 60 * 60 * 1000 }

View File

@@ -20,7 +20,7 @@ export async function POST({ request }) {
// 기본값 사용 // 기본값 사용
} }
const seeded = await seedDevJob(DEV_USER_INPUT_WITH_NOTES, stage); const seeded = await seedDevJob(DEV_USER_INPUT_WITH_NOTES);
return json({ return json({
stage, stage,
@@ -42,8 +42,7 @@ export async function POST({ request }) {
mode: 'moodboard', mode: 'moodboard',
moodboard: DEV_MOODBOARD_UPLOAD, moodboard: DEV_MOODBOARD_UPLOAD,
sns: DEV_SNS_UPLOAD sns: DEV_SNS_UPLOAD
}, }
...(stage === 'result' ? { floristNote: seeded.floristNote } : {})
}, },
// create 폼 초기값 참고용 (relationship/occasion/style/budget만) // create 폼 초기값 참고용 (relationship/occasion/style/budget만)
formDefaults: DEV_USER_INPUT formDefaults: DEV_USER_INPUT

View File

@@ -6,6 +6,7 @@ import { formatBouquetEditPrompt } from '$lib/flowerFlow/bouquetImageFormat.js';
import { normalizeRecipeLists } from '$lib/flowerFlow/resolveRecipeFlowers.js'; import { normalizeRecipeLists } from '$lib/flowerFlow/resolveRecipeFlowers.js';
import { editBouquetImage, isImageGenerationConfigured } from '$lib/server/gemini/image.js'; import { editBouquetImage, isImageGenerationConfigured } from '$lib/server/gemini/image.js';
import { applyRecipeEdit } from '$lib/server/gemini/text.js'; import { applyRecipeEdit } from '$lib/server/gemini/text.js';
import { frameToBouquetOutput } from '$lib/server/openai/bouquetImageFrame.js';
import { RATE_LIMITS } from '$lib/server/rateLimit.js'; import { RATE_LIMITS } from '$lib/server/rateLimit.js';
import { enforceRateLimit, json, readJsonBody, toErrorResponse } from '$lib/server/http.js'; import { enforceRateLimit, json, readJsonBody, toErrorResponse } from '$lib/server/http.js';
@@ -42,10 +43,25 @@ function editForJob(jobId, job, instruction) {
const task = (async () => { const task = (async () => {
const priorRecipe = normalizeRecipeLists(job.recipe); const priorRecipe = normalizeRecipeLists(job.recipe);
const updatedRecipe = await applyRecipeEdit(job.recipe, instruction.prompt); const recipeEditPrompt =
instruction.mode === 'area'
? `Localized masked edit (selected region of the photo only): ${instruction.prompt}`
: instruction.prompt;
const updatedRecipe = normalizeRecipeLists(
await applyRecipeEdit(job.recipe, recipeEditPrompt)
);
const recipeChanged = JSON.stringify(updatedRecipe) !== JSON.stringify(priorRecipe); const recipeChanged = JSON.stringify(updatedRecipe) !== JSON.stringify(priorRecipe);
const sourceImage = await loadGeneratedImageBytes(job.images.primary); const sourceImage = await loadGeneratedImageBytes(job.images.primary);
const normalizedBytes = await frameToBouquetOutput(
Buffer.from(sourceImage.base64, 'base64')
);
/** @type {{ base64: string, mimeType: string }} */
const normalizedSource = {
base64: normalizedBytes.toString('base64'),
mimeType: 'image/png'
};
const editPrompt = formatBouquetEditPrompt({ const editPrompt = formatBouquetEditPrompt({
userPrompt: instruction.prompt, userPrompt: instruction.prompt,
mode: instruction.mode, mode: instruction.mode,
@@ -56,13 +72,13 @@ function editForJob(jobId, job, instruction) {
const mask = const mask =
instruction.mode === 'area' && instruction.selection.length >= 3 instruction.mode === 'area' && instruction.selection.length >= 3
? buildAreaEditMask(sourceImage, instruction.selection) ? buildAreaEditMask(normalizedSource, instruction.selection)
: null; : null;
console.log( console.log(
`[flower-flow] edit-images job=${jobId.slice(0, 8)} mode=${instruction.mode}${mask ? ' (masked)' : ''} → editing...` `[flower-flow] edit-images job=${jobId.slice(0, 8)} mode=${instruction.mode}${mask ? ' (masked)' : ''} → editing...`
); );
const generatedImage = await editBouquetImage(sourceImage, editPrompt, { mask }); const generatedImage = await editBouquetImage(normalizedSource, editPrompt, { mask });
const images = await uploadGeneratedImages( const images = await uploadGeneratedImages(
jobId, jobId,
generatedImage, generatedImage,
@@ -71,8 +87,7 @@ function editForJob(jobId, job, instruction) {
await updateJob(jobId, { await updateJob(jobId, {
recipe: updatedRecipe, recipe: updatedRecipe,
imagePrompt: editPrompt, imagePrompt: editPrompt,
images, images
floristNote: null
}); });
console.log( console.log(
`[flower-flow] edit-images job=${jobId.slice(0, 8)} OK (mock=${!isImageGenerationConfigured()})` `[flower-flow] edit-images job=${jobId.slice(0, 8)} OK (mock=${!isImageGenerationConfigured()})`

View File

@@ -1,41 +0,0 @@
import { requireJob, updateJob } from '$lib/server/flowerFlow/jobStore.js';
import { buildFloristNote } from '$lib/server/gemini/text.js';
import { isGeminiConfigured } from '$lib/server/gemini/client.js';
import { RATE_LIMITS } from '$lib/server/rateLimit.js';
import { json, readJsonBody, enforceRateLimit, toErrorResponse } from '$lib/server/http.js';
/** @type {import('./$types').RequestHandler} */
export async function POST({ request, getClientAddress }) {
try {
const limited = enforceRateLimit(getClientAddress(), RATE_LIMITS.textAi, 'finalize');
if (limited) return limited;
const body = await readJsonBody(request);
const jobId = typeof body.jobId === 'string' ? body.jobId : '';
if (!jobId) {
return json({ error: 'jobId is required' }, 400);
}
const job = await requireJob(jobId);
const selectedImage = job.images?.primary;
if (!selectedImage) {
return json({ error: 'generated image is missing. Run generate-images first.' }, 400);
}
const floristNote = job.recipe ? await buildFloristNote(job.recipe) : null;
await updateJob(jobId, { floristNote });
return json({
jobId,
selectedImage,
floristNote,
recipe: job.recipe,
mock: !isGeminiConfigured()
});
} catch (error) {
return toErrorResponse(error);
}
}

View File

@@ -20,7 +20,6 @@ export async function GET({ url }) {
recipe: job.recipe, recipe: job.recipe,
imagePrompt: job.imagePrompt, imagePrompt: job.imagePrompt,
images: job.images, images: job.images,
floristNote: job.floristNote,
mock: !isGeminiConfigured() mock: !isGeminiConfigured()
}); });
} catch (error) { } catch (error) {

View File

@@ -5,7 +5,7 @@
import DescriptionCard from '$lib/components/ui/Artwork/DescriptionCard.svelte'; import DescriptionCard from '$lib/components/ui/Artwork/DescriptionCard.svelte';
import FlowContinueBar, { FLOW_CONTINUE_BUTTON } from '$lib/components/ui/FlowContinueBar.svelte'; import FlowContinueBar, { FLOW_CONTINUE_BUTTON } from '$lib/components/ui/FlowContinueBar.svelte';
import Header from '$lib/components/ui/Header.svelte'; import Header from '$lib/components/ui/Header.svelte';
import { editImages, fetchJob, finalizeJob, toDataUrl } from '$lib/flowerFlow/api.js'; import { editImages, fetchJob, toDataUrl } from '$lib/flowerFlow/api.js';
import { buildBriefBouquetTitle } from '$lib/flowerFlow/resolveRecipeFlowers.js'; import { buildBriefBouquetTitle } from '$lib/flowerFlow/resolveRecipeFlowers.js';
import { getFlowString, saveFlow } from '$lib/flowerFlow/session.js'; import { getFlowString, saveFlow } from '$lib/flowerFlow/session.js';
@@ -27,7 +27,6 @@
let generatedImage = $state(null); let generatedImage = $state(null);
let moodAnalysis = $state(null); let moodAnalysis = $state(null);
let editing = $state(false); let editing = $state(false);
let continuing = $state(false);
/** @type {Array<{ id: string, role: 'user' | 'assistant', prompt?: string, mode?: string, status?: 'pending' | 'done' | 'error', afterImage?: { mimeType: string, base64: string } | null, error?: string }>} */ /** @type {Array<{ id: string, role: 'user' | 'assistant', prompt?: string, mode?: string, status?: 'pending' | 'done' | 'error', afterImage?: { mimeType: string, base64: string } | null, error?: string }>} */
let chatMessages = $state([]); let chatMessages = $state([]);
/** @type {HTMLDivElement | null} */ /** @type {HTMLDivElement | null} */
@@ -74,13 +73,31 @@
}); });
/** /**
* Map pointer position to image-relative 0100% coords.
* Compensates for object-contain letterboxing inside the overlay SVG box.
* @param {PointerEvent} event * @param {PointerEvent} event
*/ */
function getPoint(event) { function getPoint(event) {
const rect = /** @type {SVGElement} */ (event.currentTarget).getBoundingClientRect(); const svg = /** @type {SVGElement} */ (event.currentTarget);
const rect = svg.getBoundingClientRect();
const img = svg.parentElement?.querySelector('img');
if (!img?.naturalWidth || !img.naturalHeight) {
return {
x: Math.max(0, Math.min(100, ((event.clientX - rect.left) / rect.width) * 100)),
y: Math.max(0, Math.min(100, ((event.clientY - rect.top) / rect.height) * 100))
};
}
const scale = Math.min(rect.width / img.naturalWidth, rect.height / img.naturalHeight);
const contentWidth = img.naturalWidth * scale;
const contentHeight = img.naturalHeight * scale;
const offsetX = (rect.width - contentWidth) / 2;
const offsetY = (rect.height - contentHeight) / 2;
return { return {
x: Math.max(0, Math.min(100, ((event.clientX - rect.left) / rect.width) * 100)), x: Math.max(0, Math.min(100, ((event.clientX - rect.left - offsetX) / contentWidth) * 100)),
y: Math.max(0, Math.min(100, ((event.clientY - rect.top) / rect.height) * 100)) y: Math.max(0, Math.min(100, ((event.clientY - rect.top - offsetY) / contentHeight) * 100))
}; };
} }
@@ -207,16 +224,7 @@
return; return;
} }
continuing = true; await goto(resolve('/result'));
error = '';
try {
await finalizeJob(jobId);
await goto(resolve('/result'));
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to continue to result';
continuing = false;
}
} }
onMount(async () => { onMount(async () => {
@@ -446,7 +454,7 @@
<button <button
type="button" type="button"
aria-label={editing ? 'Applying edit' : 'Send edit'} aria-label={editing ? 'Applying edit' : 'Send edit'}
disabled={!prompt.trim() || editing || continuing} disabled={!prompt.trim() || editing}
onclick={applyEdit} onclick={applyEdit}
class="flex size-9 shrink-0 items-center justify-center rounded-full bg-pill text-surface transition-opacity disabled:opacity-40" class="flex size-9 shrink-0 items-center justify-center rounded-full bg-pill text-surface transition-opacity disabled:opacity-40"
> >
@@ -476,11 +484,11 @@
<button <button
type="button" type="button"
disabled={editing || continuing} disabled={editing}
onclick={continueToResult} onclick={continueToResult}
class={FLOW_CONTINUE_BUTTON} class={FLOW_CONTINUE_BUTTON}
> >
{continuing ? 'Preparing result...' : 'Continue to result ->'} Continue to result ->
</button> </button>
</FlowContinueBar> </FlowContinueBar>
</section> </section>

View File

@@ -7,6 +7,7 @@
import MapPanel from '$lib/components/ui/map/MapPanel.svelte'; import MapPanel from '$lib/components/ui/map/MapPanel.svelte';
import { fetchJob, toDataUrl } from '$lib/flowerFlow/api.js'; import { fetchJob, toDataUrl } from '$lib/flowerFlow/api.js';
import { buildFloristOrderMessage } from '$lib/flowerFlow/buildFloristOrderMessage.js'; import { buildFloristOrderMessage } from '$lib/flowerFlow/buildFloristOrderMessage.js';
import { buildMapOrderDescription } from '$lib/flowerFlow/resolveRecipeFlowers.js';
import { getFlowObject, getFlowString } from '$lib/flowerFlow/session.js'; import { getFlowObject, getFlowString } from '$lib/flowerFlow/session.js';
import { ARTWORK_CARD_DEFAULTS } from '$lib/flowerFlow/artworkCardCopy.js'; import { ARTWORK_CARD_DEFAULTS } from '$lib/flowerFlow/artworkCardCopy.js';
import { getUserMapCenter } from '$lib/map/userLocation.js'; import { getUserMapCenter } from '$lib/map/userLocation.js';
@@ -18,7 +19,9 @@
let error = $state(''); let error = $state('');
let mock = $state(false); let mock = $state(false);
let selectedShopId = $state(null); let selectedShopId = $state(null);
let floristNote = $state(''); let recipe = $state(null);
let moodAnalysis = $state(null);
let userInput = $state(null);
let fitMapBounds = $state(true); let fitMapBounds = $state(true);
let orderPlainText = $state(''); let orderPlainText = $state('');
let orderKoPlainText = $state(''); let orderKoPlainText = $state('');
@@ -36,7 +39,10 @@
const artworkDescription = $derived( const artworkDescription = $derived(
selectedShopId selectedShopId
? floristNote || 'Your selected bouquet design.' ? buildMapOrderDescription(recipe, {
moodAnalysis,
userInput: { ...sessionUserInput, ...userInput }
})
: ARTWORK_CARD_DEFAULTS.map.description : ARTWORK_CARD_DEFAULTS.map.description
); );
@@ -85,7 +91,9 @@
try { try {
const job = await fetchJob(jobId); const job = await fetchJob(jobId);
floristNote = job.floristNote ?? ''; recipe = job.recipe ?? null;
moodAnalysis = job.moodAnalysis ?? null;
userInput = job.userInput ?? null;
selectedImage = job.images?.primary ?? null; selectedImage = job.images?.primary ?? null;
const order = buildFloristOrderMessage({ const order = buildFloristOrderMessage({

View File

@@ -182,7 +182,6 @@
imagePrompt: null, imagePrompt: null,
images: null, images: null,
imagesJobId: null, imagesJobId: null,
floristNote: null,
mock: result.mock mock: result.mock
}); });
await goto(resolve('/message')); await goto(resolve('/message'));

View File

@@ -5,9 +5,7 @@ create table if not exists public.flower_jobs (
mood_analysis jsonb, mood_analysis jsonb,
recipe jsonb, recipe jsonb,
image_prompt text, image_prompt text,
images jsonb not null default '{}'::jsonb, images jsonb not null default '{}'::jsonb
selected_size text check (selected_size in ('S', 'M', 'L')),
florist_note text
); );
alter table public.flower_jobs enable row level security; alter table public.flower_jobs enable row level security;