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_TEXT_MODEL=gemini-2.5-flash-lite

View File

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

View File

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

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.';
/**
* 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(

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

View File

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

View File

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

View File

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

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").
* @param {BouquetRecipe} recipe

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -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()})`

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,
imagePrompt: job.imagePrompt,
images: job.images,
floristNote: job.floristNote,
mock: !isGeminiConfigured()
});
} catch (error) {

View File

@@ -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 0100% 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>

View File

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

View File

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

View File

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