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:
@@ -1,4 +1,4 @@
|
||||
# Gemini — mood analysis, recipe, florist note
|
||||
# Gemini — mood analysis, recipe
|
||||
GEMINI_API_KEY=
|
||||
GEMINI_TEXT_MODEL=gemini-2.5-flash-lite
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import { env } from '$env/dynamic/public';
|
||||
|
||||
let {
|
||||
@@ -24,8 +23,11 @@
|
||||
let mapInstance = $state(null);
|
||||
/** @type {ReturnType<typeof window.kakao.maps.InfoWindow> | null} */
|
||||
let infoWindow = null;
|
||||
/** @type {SvelteMap<string, { marker: ReturnType<typeof window.kakao.maps.Marker>; shop: (typeof shops)[number] }>} */
|
||||
let shopMarkerMap = new SvelteMap();
|
||||
// 마커↔가게 내부 장부. 템플릿에서 반응형으로 읽지 않으므로 일반 Map 사용.
|
||||
// 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() {
|
||||
mapInstance?.relayout?.();
|
||||
@@ -186,6 +188,7 @@
|
||||
});
|
||||
|
||||
// 리스트에서 가게 선택 시에만 이동 (panTo가 바뀔 때)
|
||||
const SELECTED_MAP_LEVEL = 4;
|
||||
$effect(() => {
|
||||
const map = mapInstance;
|
||||
const target = panTo;
|
||||
@@ -195,7 +198,13 @@
|
||||
const centerLng = Number(target.lng);
|
||||
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);
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -100,17 +100,6 @@ export async function editImages(jobId, editInstruction) {
|
||||
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 */
|
||||
export async function fetchJob(jobId) {
|
||||
const response = await fetch(`/api/flower-flow/job?jobId=${encodeURIComponent(jobId)}`);
|
||||
|
||||
@@ -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.';
|
||||
|
||||
/**
|
||||
* 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}
|
||||
* @param {{ mainFlowers?: string[], subFlowers?: string[], greenery?: string[] }} recipe
|
||||
*/
|
||||
export function formatStrictBouquetImagePrompt(recipe) {
|
||||
export function getRecipeFlowerLists(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 {
|
||||
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 [
|
||||
'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')
|
||||
@@ -33,13 +44,27 @@ export function formatStrictBouquetImagePrompt(recipe) {
|
||||
'',
|
||||
'Hard constraints:',
|
||||
'- 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',
|
||||
`- ${BOUQUET_IMAGE_ASPECT_PROMPT}`,
|
||||
'- White background, soft natural lighting, front-facing, orderable from a real Korean florist'
|
||||
].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).
|
||||
* @param {{
|
||||
@@ -53,6 +78,35 @@ export function formatStrictBouquetImagePrompt(recipe) {
|
||||
*/
|
||||
export function formatBouquetEditPrompt(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 = [
|
||||
'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'
|
||||
];
|
||||
|
||||
if (recipeChanged && recipe) {
|
||||
if (recipeChanged) {
|
||||
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'}`
|
||||
'This edit changes the flower list. Update only the affected blooms in the photo; keep every other listed species exactly as before.'
|
||||
);
|
||||
}
|
||||
|
||||
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.'
|
||||
);
|
||||
if (recipe) {
|
||||
lines.push('', formatStrictRecipeConstraints(recipe));
|
||||
}
|
||||
|
||||
lines.push(
|
||||
|
||||
@@ -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
|
||||
* @returns {(typeof flowerCatalogLite)[number] | null}
|
||||
*/
|
||||
function matchCatalogFlower(label) {
|
||||
export function matchCatalogFlower(label) {
|
||||
if (!label?.trim()) return null;
|
||||
|
||||
const normalized = normalizeName(label);
|
||||
|
||||
for (const flower of flowerCatalogLite) {
|
||||
const catalogPrimary = primaryName(flower.name);
|
||||
if (
|
||||
normalized === catalogPrimary ||
|
||||
normalized.includes(catalogPrimary) ||
|
||||
catalogPrimary.includes(normalized)
|
||||
) {
|
||||
if (normalized === normalizeName(flower.name)) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -143,6 +169,63 @@ export function truncateDescription(text, maxLength = 140) {
|
||||
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 {number} [limit=2]
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createJob, updateJob } from '$lib/server/flowerFlow/jobStore.js';
|
||||
import {
|
||||
mockFloristNote,
|
||||
mockImagePrompt,
|
||||
mockMoodAnalysis,
|
||||
mockRecipe
|
||||
@@ -8,18 +7,14 @@ import {
|
||||
import { uploadGeneratedImages } from '$lib/server/flowerFlow/imageStorage.js';
|
||||
import { loadDevBouquetImage } from './loadFixtureImages.js';
|
||||
|
||||
/** @typedef {'options' | 'result'} DevSeedStage */
|
||||
|
||||
/**
|
||||
* AI 없이 서버 job + sessionStorage용 payload를 한 번에 만듭니다.
|
||||
* @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 recipe = mockRecipe(userInput);
|
||||
const imagePrompt = mockImagePrompt(recipe);
|
||||
const floristNote = stage === 'result' ? mockFloristNote(recipe) : null;
|
||||
|
||||
const job = await createJob(userInput);
|
||||
const images = await uploadGeneratedImages(job.id, loadDevBouquetImage(), `dev-${Date.now()}`);
|
||||
@@ -27,8 +22,7 @@ export async function seedDevJob(userInput, stage = 'result') {
|
||||
moodAnalysis,
|
||||
recipe,
|
||||
imagePrompt,
|
||||
images,
|
||||
...(stage === 'result' ? { floristNote } : {})
|
||||
images
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -37,7 +31,6 @@ export async function seedDevJob(userInput, stage = 'result') {
|
||||
recipe,
|
||||
imagePrompt,
|
||||
images,
|
||||
floristNote,
|
||||
mock: true
|
||||
};
|
||||
}
|
||||
|
||||
@@ -49,7 +49,6 @@ import { getSupabaseClient, throwSupabaseError } from '$lib/server/supabase.js';
|
||||
* @property {BouquetRecipe | null} recipe
|
||||
* @property {string | null} imagePrompt
|
||||
* @property {{ primary?: GeneratedImage }} images
|
||||
* @property {string | null} floristNote
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -64,8 +63,7 @@ function fromRow(row) {
|
||||
moodAnalysis: row.mood_analysis ?? null,
|
||||
recipe: row.recipe ?? null,
|
||||
imagePrompt: row.image_prompt ?? null,
|
||||
images: row.images ?? {},
|
||||
floristNote: row.florist_note ?? null
|
||||
images: row.images ?? {}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -81,7 +79,6 @@ function toRowPatch(patch) {
|
||||
if ('recipe' in patch) row.recipe = patch.recipe;
|
||||
if ('imagePrompt' in patch) row.image_prompt = patch.imagePrompt;
|
||||
if ('images' in patch) row.images = patch.images ?? {};
|
||||
if ('floristNote' in patch) row.florist_note = patch.floristNote;
|
||||
|
||||
return row;
|
||||
}
|
||||
@@ -99,8 +96,7 @@ export async function createJob(userInput = {}) {
|
||||
moodAnalysis: null,
|
||||
recipe: null,
|
||||
imagePrompt: null,
|
||||
images: {},
|
||||
floristNote: null
|
||||
images: {}
|
||||
};
|
||||
|
||||
const { error } = await getSupabaseClient()
|
||||
|
||||
@@ -14,7 +14,7 @@ export function isImageGenerationConfigured() {
|
||||
* @returns {Promise<GeneratedImage>}
|
||||
*/
|
||||
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}`;
|
||||
|
||||
if (!isOpenAIConfigured()) {
|
||||
|
||||
@@ -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").
|
||||
* @param {BouquetRecipe} recipe
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
/** @typedef {import('../flowerFlow/jobStore.js').MoodAnalysis} MoodAnalysis */
|
||||
/** @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 { normalizeRecipeLists } from '../../flowerFlow/resolveRecipeFlowers.js';
|
||||
import { getTextModel, isGeminiConfigured, parseJsonFromText } from './client.js';
|
||||
@@ -81,27 +81,19 @@ export async function buildImagePrompt(recipe) {
|
||||
return formatStrictBouquetImagePrompt(recipe);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {BouquetRecipe} recipe
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
export async function buildFloristNote(recipe) {
|
||||
if (!isGeminiConfigured()) {
|
||||
const { mockFloristNote } = await import('./mock.js');
|
||||
return mockFloristNote(recipe);
|
||||
/** Flower names allowed in edited recipes — same source as initial recipe generation. */
|
||||
function getFlowerDBCandidatesByRole() {
|
||||
/** @type {{ main: string[], filler: string[], line: string[], foliage: string[] }} */
|
||||
const groups = { main: [], filler: [], line: [], foliage: [] };
|
||||
|
||||
for (const flower of flowerDB) {
|
||||
if (flower.role === 'main') groups.main.push(flower.name);
|
||||
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();
|
||||
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();
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,10 +107,14 @@ export async function applyRecipeEdit(recipe, editPrompt) {
|
||||
return normalizeRecipeLists(mockApplyRecipeEdit(recipe, editPrompt));
|
||||
}
|
||||
|
||||
const candidates = getFlowerDBCandidatesByRole();
|
||||
const model = getTextModel();
|
||||
const prompt = `You are a professional florist assistant.
|
||||
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:
|
||||
${JSON.stringify(recipe, null, 2)}
|
||||
|
||||
@@ -139,11 +135,15 @@ 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.
|
||||
- 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.
|
||||
- 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.`;
|
||||
- Flower swaps (e.g. "change tulip to rose") must update the matching list entry using exact catalog names.
|
||||
- The updated recipe is the sole source of truth for the next bouquet image — every listed flower must appear in the photo without omission.`;
|
||||
|
||||
const result = await model.generateContent(prompt);
|
||||
return normalizeRecipeLists(
|
||||
|
||||
@@ -71,6 +71,35 @@ export async function padToOpenAIRequestSize(buffer) {
|
||||
.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.
|
||||
* @param {Buffer} maskBuffer
|
||||
@@ -82,7 +111,12 @@ export async function padMaskToOpenAIRequestSize(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({
|
||||
top: PAD_TOP,
|
||||
bottom: PAD_TOP,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { env } from '$env/dynamic/private';
|
||||
import OpenAI, { toFile } from 'openai';
|
||||
import {
|
||||
extractPaddedBouquetFrame,
|
||||
frameToBouquetOutput,
|
||||
padMaskToOpenAIRequestSize,
|
||||
padToOpenAIRequestSize,
|
||||
@@ -71,9 +72,9 @@ export async function generateOpenAIImage(prompt) {
|
||||
* @returns {Promise<import('../flowerFlow/jobStore.js').GeneratedImage>}
|
||||
*/
|
||||
export async function editOpenAIImage(prompt, sourceImage, mask = null) {
|
||||
const paddedSource = await padToOpenAIRequestSize(
|
||||
Buffer.from(sourceImage.base64, 'base64')
|
||||
);
|
||||
const sourceBuffer = 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' });
|
||||
|
||||
/** @type {import('openai').default.Images.ImageEditParams} */
|
||||
@@ -86,15 +87,21 @@ export async function editOpenAIImage(prompt, sourceImage, mask = null) {
|
||||
};
|
||||
|
||||
if (mask) {
|
||||
const paddedMask = await padMaskToOpenAIRequestSize(Buffer.from(mask.base64, 'base64'));
|
||||
params.mask = await toFile(paddedMask, 'mask.png', { type: 'image/png' });
|
||||
params.mask = await toFile(
|
||||
await padMaskToOpenAIRequestSize(Buffer.from(mask.base64, 'base64')),
|
||||
'mask.png',
|
||||
{ type: 'image/png' }
|
||||
);
|
||||
params.input_fidelity = 'high';
|
||||
}
|
||||
|
||||
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 {
|
||||
mimeType: 'image/png',
|
||||
base64: framed.toString('base64')
|
||||
base64: editedFrame.toString('base64')
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ export const RATE_LIMITS = {
|
||||
imageGeneration: { limit: 20, windowMs: 60 * 60 * 1000 },
|
||||
/** Bouquet photo edits (expensive). */
|
||||
imageEdit: { limit: 40, windowMs: 60 * 60 * 1000 },
|
||||
/** Text recipe + florist note endpoints. */
|
||||
/** Text recipe endpoints. */
|
||||
textAi: { limit: 60, windowMs: 60 * 60 * 1000 },
|
||||
/** Kakao shop search proxy. */
|
||||
mapShops: { limit: 120, windowMs: 60 * 60 * 1000 }
|
||||
|
||||
@@ -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({
|
||||
stage,
|
||||
@@ -42,8 +42,7 @@ export async function POST({ request }) {
|
||||
mode: 'moodboard',
|
||||
moodboard: DEV_MOODBOARD_UPLOAD,
|
||||
sns: DEV_SNS_UPLOAD
|
||||
},
|
||||
...(stage === 'result' ? { floristNote: seeded.floristNote } : {})
|
||||
}
|
||||
},
|
||||
// create 폼 초기값 참고용 (relationship/occasion/style/budget만)
|
||||
formDefaults: DEV_USER_INPUT
|
||||
|
||||
@@ -6,6 +6,7 @@ import { formatBouquetEditPrompt } from '$lib/flowerFlow/bouquetImageFormat.js';
|
||||
import { normalizeRecipeLists } from '$lib/flowerFlow/resolveRecipeFlowers.js';
|
||||
import { editBouquetImage, isImageGenerationConfigured } from '$lib/server/gemini/image.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 { enforceRateLimit, json, readJsonBody, toErrorResponse } from '$lib/server/http.js';
|
||||
|
||||
@@ -42,10 +43,25 @@ function editForJob(jobId, job, instruction) {
|
||||
|
||||
const task = (async () => {
|
||||
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 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({
|
||||
userPrompt: instruction.prompt,
|
||||
mode: instruction.mode,
|
||||
@@ -56,13 +72,13 @@ function editForJob(jobId, job, instruction) {
|
||||
|
||||
const mask =
|
||||
instruction.mode === 'area' && instruction.selection.length >= 3
|
||||
? buildAreaEditMask(sourceImage, instruction.selection)
|
||||
? buildAreaEditMask(normalizedSource, instruction.selection)
|
||||
: null;
|
||||
|
||||
console.log(
|
||||
`[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(
|
||||
jobId,
|
||||
generatedImage,
|
||||
@@ -71,8 +87,7 @@ function editForJob(jobId, job, instruction) {
|
||||
await updateJob(jobId, {
|
||||
recipe: updatedRecipe,
|
||||
imagePrompt: editPrompt,
|
||||
images,
|
||||
floristNote: null
|
||||
images
|
||||
});
|
||||
console.log(
|
||||
`[flower-flow] edit-images job=${jobId.slice(0, 8)} OK (mock=${!isImageGenerationConfigured()})`
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,6 @@ export async function GET({ url }) {
|
||||
recipe: job.recipe,
|
||||
imagePrompt: job.imagePrompt,
|
||||
images: job.images,
|
||||
floristNote: job.floristNote,
|
||||
mock: !isGeminiConfigured()
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import DescriptionCard from '$lib/components/ui/Artwork/DescriptionCard.svelte';
|
||||
import FlowContinueBar, { FLOW_CONTINUE_BUTTON } from '$lib/components/ui/FlowContinueBar.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 { getFlowString, saveFlow } from '$lib/flowerFlow/session.js';
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
let generatedImage = $state(null);
|
||||
let moodAnalysis = $state(null);
|
||||
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 }>} */
|
||||
let chatMessages = $state([]);
|
||||
/** @type {HTMLDivElement | null} */
|
||||
@@ -74,13 +73,31 @@
|
||||
});
|
||||
|
||||
/**
|
||||
* Map pointer position to image-relative 0–100% coords.
|
||||
* Compensates for object-contain letterboxing inside the overlay SVG box.
|
||||
* @param {PointerEvent} 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 {
|
||||
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))
|
||||
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 - offsetY) / contentHeight) * 100))
|
||||
};
|
||||
}
|
||||
|
||||
@@ -207,16 +224,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
continuing = true;
|
||||
error = '';
|
||||
|
||||
try {
|
||||
await finalizeJob(jobId);
|
||||
await goto(resolve('/result'));
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to continue to result';
|
||||
continuing = false;
|
||||
}
|
||||
await goto(resolve('/result'));
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
@@ -446,7 +454,7 @@
|
||||
<button
|
||||
type="button"
|
||||
aria-label={editing ? 'Applying edit' : 'Send edit'}
|
||||
disabled={!prompt.trim() || editing || continuing}
|
||||
disabled={!prompt.trim() || editing}
|
||||
onclick={applyEdit}
|
||||
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
|
||||
type="button"
|
||||
disabled={editing || continuing}
|
||||
disabled={editing}
|
||||
onclick={continueToResult}
|
||||
class={FLOW_CONTINUE_BUTTON}
|
||||
>
|
||||
{continuing ? 'Preparing result...' : 'Continue to result ->'}
|
||||
Continue to result ->
|
||||
</button>
|
||||
</FlowContinueBar>
|
||||
</section>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import MapPanel from '$lib/components/ui/map/MapPanel.svelte';
|
||||
import { fetchJob, toDataUrl } from '$lib/flowerFlow/api.js';
|
||||
import { buildFloristOrderMessage } from '$lib/flowerFlow/buildFloristOrderMessage.js';
|
||||
import { buildMapOrderDescription } from '$lib/flowerFlow/resolveRecipeFlowers.js';
|
||||
import { getFlowObject, getFlowString } from '$lib/flowerFlow/session.js';
|
||||
import { ARTWORK_CARD_DEFAULTS } from '$lib/flowerFlow/artworkCardCopy.js';
|
||||
import { getUserMapCenter } from '$lib/map/userLocation.js';
|
||||
@@ -18,7 +19,9 @@
|
||||
let error = $state('');
|
||||
let mock = $state(false);
|
||||
let selectedShopId = $state(null);
|
||||
let floristNote = $state('');
|
||||
let recipe = $state(null);
|
||||
let moodAnalysis = $state(null);
|
||||
let userInput = $state(null);
|
||||
let fitMapBounds = $state(true);
|
||||
let orderPlainText = $state('');
|
||||
let orderKoPlainText = $state('');
|
||||
@@ -36,7 +39,10 @@
|
||||
|
||||
const artworkDescription = $derived(
|
||||
selectedShopId
|
||||
? floristNote || 'Your selected bouquet design.'
|
||||
? buildMapOrderDescription(recipe, {
|
||||
moodAnalysis,
|
||||
userInput: { ...sessionUserInput, ...userInput }
|
||||
})
|
||||
: ARTWORK_CARD_DEFAULTS.map.description
|
||||
);
|
||||
|
||||
@@ -85,7 +91,9 @@
|
||||
|
||||
try {
|
||||
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;
|
||||
|
||||
const order = buildFloristOrderMessage({
|
||||
|
||||
@@ -182,7 +182,6 @@
|
||||
imagePrompt: null,
|
||||
images: null,
|
||||
imagesJobId: null,
|
||||
floristNote: null,
|
||||
mock: result.mock
|
||||
});
|
||||
await goto(resolve('/message'));
|
||||
|
||||
@@ -5,9 +5,7 @@ create table if not exists public.flower_jobs (
|
||||
mood_analysis jsonb,
|
||||
recipe jsonb,
|
||||
image_prompt text,
|
||||
images jsonb not null default '{}'::jsonb,
|
||||
selected_size text check (selected_size in ('S', 'M', 'L')),
|
||||
florist_note text
|
||||
images jsonb not null default '{}'::jsonb
|
||||
);
|
||||
|
||||
alter table public.flower_jobs enable row level security;
|
||||
|
||||
Reference in New Issue
Block a user