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_API_KEY=
|
||||||
GEMINI_TEXT_MODEL=gemini-2.5-flash-lite
|
GEMINI_TEXT_MODEL=gemini-2.5-flash-lite
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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)}`);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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')
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()})`
|
||||||
|
|||||||
@@ -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,
|
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) {
|
||||||
|
|||||||
@@ -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 0–100% 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>
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user