diff --git a/.env.example b/.env.example index b023ab9..383adfb 100644 --- a/.env.example +++ b/.env.example @@ -9,7 +9,7 @@ IMAGE_PROVIDER=openai OPENAI_API_KEY=your_openai_api_key_here OPENAI_IMAGE_MODEL=gpt-image-1 # Bouquet preview (generating flow) -OPENAI_IMAGE_SIZE=1024x1024 +OPENAI_IMAGE_SIZE=1024x1536 # Flower catalog batch (scripts/generate-flower-catalog.js) — portrait cards OPENAI_IMAGE_CATALOG_SIZE=1024x1536 OPENAI_IMAGE_CATALOG_QUALITY=low diff --git a/src/lib/components/ui/Artwork/Artwork.svelte b/src/lib/components/ui/Artwork/Artwork.svelte index 4a0ab29..57a0953 100644 --- a/src/lib/components/ui/Artwork/Artwork.svelte +++ b/src/lib/components/ui/Artwork/Artwork.svelte @@ -33,7 +33,7 @@ Selected bouquet {:else} diff --git a/src/lib/components/ui/Artwork/DescriptionCard.svelte b/src/lib/components/ui/Artwork/DescriptionCard.svelte index a3a9729..dbdfec7 100644 --- a/src/lib/components/ui/Artwork/DescriptionCard.svelte +++ b/src/lib/components/ui/Artwork/DescriptionCard.svelte @@ -3,6 +3,6 @@
-

{title}

-

{description}

+

{title}

+

{description}

diff --git a/src/lib/components/ui/FlowContinueBar.svelte b/src/lib/components/ui/FlowContinueBar.svelte new file mode 100644 index 0000000..eff60ef --- /dev/null +++ b/src/lib/components/ui/FlowContinueBar.svelte @@ -0,0 +1,21 @@ + + + + +
+ {@render children()} +
diff --git a/src/lib/components/ui/Header.svelte b/src/lib/components/ui/Header.svelte index b85d323..e9cc98b 100644 --- a/src/lib/components/ui/Header.svelte +++ b/src/lib/components/ui/Header.svelte @@ -1,4 +1,5 @@
-
+ AI Florist -
+
{#each dots as dot (dot)} diff --git a/src/lib/components/ui/create/ContextForm.svelte b/src/lib/components/ui/create/ContextForm.svelte index c5b93d3..19cc2fd 100644 --- a/src/lib/components/ui/create/ContextForm.svelte +++ b/src/lib/components/ui/create/ContextForm.svelte @@ -42,7 +42,7 @@ {/if}

- {style ?? '—'} | ₩{budget.toLocaleString('ko-KR')} + {style ?? '...'} | ₩{budget.toLocaleString('ko-KR')}

{/if}
diff --git a/src/lib/components/ui/result/BouquetFlowerCard.svelte b/src/lib/components/ui/result/BouquetFlowerCard.svelte new file mode 100644 index 0000000..749aa74 --- /dev/null +++ b/src/lib/components/ui/result/BouquetFlowerCard.svelte @@ -0,0 +1,131 @@ + + + + + diff --git a/src/lib/components/ui/result/BouquetFlowerCarousel.svelte b/src/lib/components/ui/result/BouquetFlowerCarousel.svelte new file mode 100644 index 0000000..6d2ac40 --- /dev/null +++ b/src/lib/components/ui/result/BouquetFlowerCarousel.svelte @@ -0,0 +1,29 @@ + + +{#if flowers.length === 0} +

Flower details will appear once the bouquet recipe is ready.

+{:else} +
+

Flowers in your bouquet

+ +
+ {#each flowers as flower (flower.id)} + + {/each} +
+
+{/if} diff --git a/src/lib/components/ui/upload/MoodboardGrid.svelte b/src/lib/components/ui/upload/MoodboardGrid.svelte index 1e43c3d..4838179 100644 --- a/src/lib/components/ui/upload/MoodboardGrid.svelte +++ b/src/lib/components/ui/upload/MoodboardGrid.svelte @@ -4,11 +4,7 @@ import { hydrateDevUpload } from '$lib/dev/hydrateUpload.js'; import { getFlowObject, isDevSeeded } from '$lib/flowerFlow/session.js'; - let { - primaryFile = $bindable(null), - uploadedTiles = $bindable(), - caption = 'build their moodboard!' - } = $props(); + let { primaryFile = $bindable(null), uploadedTiles = $bindable() } = $props(); let colorFile = $state(null); let seasonFile = $state(null); @@ -58,170 +54,28 @@
-
- (01) - (02) - (03) - (04) - {caption} - - - - - -
+ + + +
diff --git a/src/lib/components/ui/upload/SnsFeedUpload.svelte b/src/lib/components/ui/upload/SnsFeedUpload.svelte index 9d311a5..0814e19 100644 --- a/src/lib/components/ui/upload/SnsFeedUpload.svelte +++ b/src/lib/components/ui/upload/SnsFeedUpload.svelte @@ -4,8 +4,7 @@ import { hydrateDevUpload } from '$lib/dev/hydrateUpload.js'; import { getFlowObject, isDevSeeded } from '$lib/flowerFlow/session.js'; - let { primaryFile = $bindable(null), hasImage = $bindable(), caption = 'upload their feed!' } = - $props(); + let { primaryFile = $bindable(null), hasImage = $bindable() } = $props(); let firstFile = $state(null); @@ -36,12 +35,7 @@
-
- (01) - {caption} - - -
+
diff --git a/src/lib/dev/fixtures.js b/src/lib/dev/fixtures.js index 1f85f31..d93b316 100644 --- a/src/lib/dev/fixtures.js +++ b/src/lib/dev/fixtures.js @@ -6,7 +6,7 @@ export const DEV_USER_INPUT = { budget: 50_000 }; -export const DEV_CARD_MESSAGE = 'Wishing you the happiest birthday — with love always.'; +export const DEV_CARD_MESSAGE = 'Wishing you the happiest birthday, with love always.'; /** message Continue 후 userInput.notes 형태 */ export const DEV_USER_INPUT_WITH_NOTES = { diff --git a/src/lib/flowerFlow/bouquetImageFormat.js b/src/lib/flowerFlow/bouquetImageFormat.js new file mode 100644 index 0000000..50ae76a --- /dev/null +++ b/src/lib/flowerFlow/bouquetImageFormat.js @@ -0,0 +1,6 @@ +/** Bouquet preview framing shared by Artwork, edit chat, and image generation prompts. */ + +export const BOUQUET_IMAGE_ASPECT = '3:4'; + +export const BOUQUET_IMAGE_ASPECT_PROMPT = + 'Vertical portrait composition with a 3:4 aspect ratio (width:height). Frame the full bouquet without cropping stems or wrapping.'; diff --git a/src/lib/flowerFlow/flowerCatalogKo.js b/src/lib/flowerFlow/flowerCatalogKo.js new file mode 100644 index 0000000..e7f7f59 --- /dev/null +++ b/src/lib/flowerFlow/flowerCatalogKo.js @@ -0,0 +1,107 @@ +/** Korean display names and 꽃말 keyed by flower id */ + +/** @type {Record} */ +export const flowerCatalogKoById = { + 1: { nameKo: '수선화', wordOfFlowerKo: '자존' }, + 2: { nameKo: '스톡', wordOfFlowerKo: '변함없는 아름다움' }, + 3: { nameKo: '아마릴리스', wordOfFlowerKo: '화려한 미' }, + 4: { nameKo: '스위트피', wordOfFlowerKo: '행복한 만남' }, + 5: { nameKo: '안스리움', wordOfFlowerKo: '열정' }, + 6: { nameKo: '프리지아', wordOfFlowerKo: '순결' }, + 7: { nameKo: '튤립', wordOfFlowerKo: '자애' }, + 8: { nameKo: '히아신스', wordOfFlowerKo: '사랑의 기쁨' }, + 9: { nameKo: '라넌큘러스', wordOfFlowerKo: '매력' }, + 10: { nameKo: '라일락', wordOfFlowerKo: '첫사랑' }, + 11: { nameKo: '붓꽃', wordOfFlowerKo: '좋은 소식' }, + 12: { nameKo: '모란', wordOfFlowerKo: '부귀영화' }, + 13: { nameKo: '작약', wordOfFlowerKo: '수줍음' }, + 14: { nameKo: '장미', wordOfFlowerKo: '열정적인 사랑' }, + 15: { nameKo: '눈꽃동백', wordOfFlowerKo: '우아함' }, + 16: { nameKo: '카네이션', wordOfFlowerKo: '여성의 사랑' }, + 17: { nameKo: '클레마티스', wordOfFlowerKo: '고귀함' }, + 18: { nameKo: '백합', wordOfFlowerKo: '순결' }, + 19: { nameKo: '수국', wordOfFlowerKo: '변덕' }, + 20: { nameKo: '아가판서스', wordOfFlowerKo: '연애편지' }, + 21: { nameKo: '알리움', wordOfFlowerKo: '끝없는 슬픔' }, + 22: { nameKo: '도라지', wordOfFlowerKo: '우쭐함' }, + 23: { nameKo: '떡잎', wordOfFlowerKo: '굳건한 애정' }, + 24: { nameKo: '양귀비', wordOfFlowerKo: '위로' }, + 25: { nameKo: '다알리아', wordOfFlowerKo: '감사' }, + 26: { nameKo: '연꽃', wordOfFlowerKo: '순결' }, + 27: { nameKo: '용담', wordOfFlowerKo: '슬플 때 사랑한다' }, + 28: { nameKo: '해바라기', wordOfFlowerKo: '숭배' }, + 29: { nameKo: '국화', wordOfFlowerKo: '순결' }, + 30: { nameKo: '맨드라미', wordOfFlowerKo: '뜨거운 사랑' }, + 31: { nameKo: '아네모네', wordOfFlowerKo: '진실' }, + 32: { nameKo: '코스모스', wordOfFlowerKo: '순수한 마음' }, + 33: { nameKo: '상사화', wordOfFlowerKo: '다시 만날 수 없는 사랑' }, + 34: { nameKo: '거베라', wordOfFlowerKo: '신비' }, + 35: { nameKo: '칼라', wordOfFlowerKo: '기쁨' }, + 36: { nameKo: '극락조', wordOfFlowerKo: '신비' }, + 37: { nameKo: '헬레보어', wordOfFlowerKo: '존재의 이유' }, + 38: { nameKo: '리시안셔스', wordOfFlowerKo: '감사' }, + 39: { nameKo: '스카비오사', wordOfFlowerKo: '모든 것을 잃음' }, + 40: { nameKo: '왁스플라워', wordOfFlowerKo: '영원한 사랑' }, + 41: { nameKo: '카스피아', wordOfFlowerKo: '추억' }, + 42: { nameKo: '루스커스', wordOfFlowerKo: '인내' }, + 43: { nameKo: '베로니카', wordOfFlowerKo: '충실' }, + 44: { nameKo: '솔리더고', wordOfFlowerKo: '격려' }, + 45: { nameKo: '부바르디아', wordOfFlowerKo: '열정' }, + 46: { nameKo: '트위디아', wordOfFlowerKo: '나를 믿어줘' }, + 47: { nameKo: '크라스페디아', wordOfFlowerKo: '건강' }, + 48: { nameKo: '아마란스', wordOfFlowerKo: '불멸' }, + 49: { nameKo: '애미', wordOfFlowerKo: '안식처' }, + 50: { nameKo: '니겔라', wordOfFlowerKo: '혼란' }, + 51: { nameKo: '브루니아', wordOfFlowerKo: '단결' }, + 52: { nameKo: '금어초', wordOfFlowerKo: '욕망' }, + 53: { nameKo: '루핀', wordOfFlowerKo: '삶에 대한 열망' }, + 54: { nameKo: '글라디올러스', wordOfFlowerKo: '비밀스러운 만남' }, + 55: { nameKo: '디기탈리스', wordOfFlowerKo: '뜨거운 사랑' }, + 56: { nameKo: '델피니움', wordOfFlowerKo: '내 마음을 알아줘' }, + 57: { nameKo: '살비아', wordOfFlowerKo: '타오르는 마음' }, + 58: { nameKo: '머스카리', wordOfFlowerKo: '실망' }, + 59: { nameKo: '물망초', wordOfFlowerKo: '진실한 사랑' }, + 60: { nameKo: '스타티스', wordOfFlowerKo: '영원한 사랑' }, + 61: { nameKo: '아스틸베', wordOfFlowerKo: '수줍음' }, + 62: { nameKo: '헬리크리섬', wordOfFlowerKo: '영원히 기억해' }, + 63: { nameKo: '수레국화', wordOfFlowerKo: '행복' }, + 64: { nameKo: '꽈리', wordOfFlowerKo: '거짓' }, + 65: { nameKo: '천일홍', wordOfFlowerKo: '불멸' }, + 66: { nameKo: '심듀움', wordOfFlowerKo: '희망' }, + 67: { nameKo: '안개꽃', wordOfFlowerKo: '순수한 기쁨' }, + 68: { nameKo: '카틀레야', wordOfFlowerKo: '당신은 아름다워요' }, + 69: { nameKo: '온시디움', wordOfFlowerKo: '순수한 마음' }, + 70: { nameKo: '덴드로비움', wordOfFlowerKo: '아름다움' }, + 71: { nameKo: '반다', wordOfFlowerKo: '애정의 표시' }, + 72: { nameKo: '호랑가시나무', wordOfFlowerKo: '보호' }, + 73: { nameKo: '남천', wordOfFlowerKo: '변치 않는 사랑' }, + 74: { nameKo: '고양이버들', wordOfFlowerKo: '자유' }, + 75: { nameKo: '떡갈고사리', wordOfFlowerKo: '매력' }, + 76: { nameKo: '목화', wordOfFlowerKo: '어머니의 사랑' }, + 77: { nameKo: '편백', wordOfFlowerKo: '침착' }, + 78: { nameKo: '뷰티베리', wordOfFlowerKo: '지혜' }, + 79: { nameKo: '갈대', wordOfFlowerKo: '신념' }, + 80: { nameKo: '조', wordOfFlowerKo: '평등' }, + 81: { nameKo: '민트', wordOfFlowerKo: '덕' }, + 82: { nameKo: '아스파라거스', wordOfFlowerKo: '변함없음' }, + 83: { nameKo: '억새', wordOfFlowerKo: '은퇴' }, + 84: { nameKo: '유칼립트스', wordOfFlowerKo: '추억' }, + 85: { nameKo: '담쟁이덩굴', wordOfFlowerKo: '굳건한 마음' }, + 86: { nameKo: '올리브', wordOfFlowerKo: '평화' }, + 87: { nameKo: '조피아', wordOfFlowerKo: '포옹' }, + 88: { nameKo: '벚꽃', wordOfFlowerKo: '정신적인 아름다움' }, + 89: { nameKo: '개나리', wordOfFlowerKo: '희망' }, + 90: { nameKo: '매화', wordOfFlowerKo: '순수한 마음' }, + 91: { nameKo: '목련', wordOfFlowerKo: '자연에 대한 사랑' }, + 92: { nameKo: '홍단', wordOfFlowerKo: '우정' }, + 93: { nameKo: '모과꽃', wordOfFlowerKo: '신뢰' } +}; + +/** @param {number} id @param {string} fallbackName @param {string} fallbackWord */ +export function getFlowerKo(id, fallbackName, fallbackWord) { + const entry = flowerCatalogKoById[id]; + return { + nameKo: entry?.nameKo ?? fallbackName, + wordOfFlowerKo: entry?.wordOfFlowerKo ?? fallbackWord + }; +} diff --git a/src/lib/flowerFlow/flowerCatalogLite.js b/src/lib/flowerFlow/flowerCatalogLite.js new file mode 100644 index 0000000..b936e1d --- /dev/null +++ b/src/lib/flowerFlow/flowerCatalogLite.js @@ -0,0 +1,470 @@ +/** Client-safe flower catalog (id, name, wordOfFlower) */ + +/** @type {{ id: number, name: string, wordOfFlower: string }[]} */ +export const flowerCatalogLite = [ + { + "id": 1, + "name": "Daffodil", + "wordOfFlower": "self-love" + }, + { + "id": 2, + "name": "Stock", + "wordOfFlower": "lasting beauty" + }, + { + "id": 3, + "name": "Amaryllis", + "wordOfFlower": "dazzling beauty" + }, + { + "id": 4, + "name": "Sweet Pea", + "wordOfFlower": "delicate pleasures" + }, + { + "id": 5, + "name": "Anthurium", + "wordOfFlower": "hospitality" + }, + { + "id": 6, + "name": "Freesia", + "wordOfFlower": "purity" + }, + { + "id": 7, + "name": "Tulip", + "wordOfFlower": "benevolence" + }, + { + "id": 8, + "name": "Hyacinth", + "wordOfFlower": "joy of the heart" + }, + { + "id": 9, + "name": "Ranunculus", + "wordOfFlower": "radiant charm" + }, + { + "id": 10, + "name": "Lilac", + "wordOfFlower": "memories of youth" + }, + { + "id": 11, + "name": "Iris", + "wordOfFlower": "good news" + }, + { + "id": 12, + "name": "Tree Peony", + "wordOfFlower": "wealth and honor" + }, + { + "id": 13, + "name": "Peony", + "wordOfFlower": "shyness" + }, + { + "id": 14, + "name": "Rose", + "wordOfFlower": "passionate love" + }, + { + "id": 15, + "name": "Snowball Viburnum", + "wordOfFlower": "grace" + }, + { + "id": 16, + "name": "Carnation", + "wordOfFlower": "a woman's affection" + }, + { + "id": 17, + "name": "Clematis", + "wordOfFlower": "nobility" + }, + { + "id": 18, + "name": "Lily", + "wordOfFlower": "purity" + }, + { + "id": 19, + "name": "Hydrangea", + "wordOfFlower": "heartlessness" + }, + { + "id": 20, + "name": "Agapanthus", + "wordOfFlower": "love letter" + }, + { + "id": 21, + "name": "Allium", + "wordOfFlower": "endless sorrow" + }, + { + "id": 22, + "name": "Bellflower (Campanula)", + "wordOfFlower": "a coquettish look" + }, + { + "id": 23, + "name": "China Aster (Callistephus)", + "wordOfFlower": "trusting love" + }, + { + "id": 24, + "name": "Poppy (Papaver)", + "wordOfFlower": "consolation" + }, + { + "id": 25, + "name": "Dahlia", + "wordOfFlower": "gratitude" + }, + { + "id": 26, + "name": "Lotus", + "wordOfFlower": "purity" + }, + { + "id": 27, + "name": "Gentian", + "wordOfFlower": "I love you when you're sad" + }, + { + "id": 28, + "name": "Sunflower", + "wordOfFlower": "adoration" + }, + { + "id": 29, + "name": "Chrysanthemum", + "wordOfFlower": "purity" + }, + { + "id": 30, + "name": "Cockscomb (Celosia)", + "wordOfFlower": "ardent love" + }, + { + "id": 31, + "name": "Anemone", + "wordOfFlower": "sincerity" + }, + { + "id": 32, + "name": "Cosmos", + "wordOfFlower": "a girl's pure heart" + }, + { + "id": 33, + "name": "Red Spider Lily (Lycoris radiata)", + "wordOfFlower": "true love" + }, + { + "id": 34, + "name": "Gerbera", + "wordOfFlower": "mystery" + }, + { + "id": 35, + "name": "Calla Lily", + "wordOfFlower": "joy" + }, + { + "id": 36, + "name": "Bird of Paradise (Strelitzia)", + "wordOfFlower": "mystery" + }, + { + "id": 37, + "name": "Hellebore", + "wordOfFlower": "reason for being" + }, + { + "id": 38, + "name": "Lisianthus (Eustoma)", + "wordOfFlower": "appreciation" + }, + { + "id": 39, + "name": "Scabiosa", + "wordOfFlower": "I have lost all" + }, + { + "id": 40, + "name": "Wax Flower (Chamelaucium)", + "wordOfFlower": "lasting love" + }, + { + "id": 41, + "name": "Caspia (Limonium)", + "wordOfFlower": "remembrance" + }, + { + "id": 42, + "name": "Ruscus", + "wordOfFlower": "endurance" + }, + { + "id": 43, + "name": "Veronica (Speedwell)", + "wordOfFlower": "fidelity" + }, + { + "id": 44, + "name": "Solidago (Goldenrod)", + "wordOfFlower": "encouragement" + }, + { + "id": 45, + "name": "Bouvardia", + "wordOfFlower": "enthusiasm" + }, + { + "id": 46, + "name": "Tweedia (Oxypetalum)", + "wordOfFlower": "believe in me" + }, + { + "id": 47, + "name": "Craspedia (Billy Balls)", + "wordOfFlower": "good health" + }, + { + "id": 48, + "name": "Amaranthus", + "wordOfFlower": "immortality" + }, + { + "id": 49, + "name": "Queen Anne's Lace (Ammi)", + "wordOfFlower": "sanctuary" + }, + { + "id": 50, + "name": "Nigella (Love-in-a-mist)", + "wordOfFlower": "perplexity" + }, + { + "id": 51, + "name": "Brunia", + "wordOfFlower": "unity" + }, + { + "id": 52, + "name": "Snapdragon", + "wordOfFlower": "desire" + }, + { + "id": 53, + "name": "Lupine", + "wordOfFlower": "lust for life" + }, + { + "id": 54, + "name": "Gladiolus", + "wordOfFlower": "secret meeting" + }, + { + "id": 55, + "name": "Foxglove (Digitalis)", + "wordOfFlower": "ardent love" + }, + { + "id": 56, + "name": "Delphinium", + "wordOfFlower": "understand my heart" + }, + { + "id": 57, + "name": "Salvia (Scarlet Sage)", + "wordOfFlower": "burning heart" + }, + { + "id": 58, + "name": "Grape Hyacinth (Muscari)", + "wordOfFlower": "disappointment" + }, + { + "id": 59, + "name": "Forget-me-not", + "wordOfFlower": "true love" + }, + { + "id": 60, + "name": "Statice (Limonium)", + "wordOfFlower": "eternal love" + }, + { + "id": 61, + "name": "Astilbe", + "wordOfFlower": "bashfulness" + }, + { + "id": 62, + "name": "Strawflower (Helichrysum)", + "wordOfFlower": "always remember" + }, + { + "id": 63, + "name": "Cornflower (Centaurea)", + "wordOfFlower": "happiness" + }, + { + "id": 64, + "name": "Chinese Lantern (Physalis)", + "wordOfFlower": "falsehood" + }, + { + "id": 65, + "name": "Globe Amaranth (Gomphrena)", + "wordOfFlower": "immortality" + }, + { + "id": 66, + "name": "Showy Stonecrop (Sedum)", + "wordOfFlower": "hope" + }, + { + "id": 67, + "name": "Baby's Breath (Gypsophila)", + "wordOfFlower": "earnest joy" + }, + { + "id": 68, + "name": "Cattleya", + "wordOfFlower": "you are a beauty" + }, + { + "id": 69, + "name": "Oncidium", + "wordOfFlower": "innocent heart" + }, + { + "id": 70, + "name": "Dendrobium", + "wordOfFlower": "a beauty" + }, + { + "id": 71, + "name": "Vanda Orchid", + "wordOfFlower": "a token of affection" + }, + { + "id": 72, + "name": "Holly", + "wordOfFlower": "protection" + }, + { + "id": 73, + "name": "Nandina (Heavenly Bamboo)", + "wordOfFlower": "enduring love" + }, + { + "id": 74, + "name": "Pussy Willow (Salix gracilistyla)", + "wordOfFlower": "freedom" + }, + { + "id": 75, + "name": "Maidenhair Fern (Adiantum)", + "wordOfFlower": "charm" + }, + { + "id": 76, + "name": "Cotton", + "wordOfFlower": "mother's love" + }, + { + "id": 77, + "name": "Hosta (Plantain Lily)", + "wordOfFlower": "composure" + }, + { + "id": 78, + "name": "Beautyberry (Callicarpa)", + "wordOfFlower": "intelligence" + }, + { + "id": 79, + "name": "Reed", + "wordOfFlower": "faith" + }, + { + "id": 80, + "name": "Foxtail Millet", + "wordOfFlower": "equality" + }, + { + "id": 81, + "name": "Mint", + "wordOfFlower": "virtue" + }, + { + "id": 82, + "name": "Asparagus Fern", + "wordOfFlower": "constancy" + }, + { + "id": 83, + "name": "Silver Grass (Miscanthus)", + "wordOfFlower": "retirement" + }, + { + "id": 84, + "name": "Eucalyptus", + "wordOfFlower": "memories" + }, + { + "id": 85, + "name": "Ivy (Hedera)", + "wordOfFlower": "a steadfast heart" + }, + { + "id": 86, + "name": "Olive", + "wordOfFlower": "peace" + }, + { + "id": 87, + "name": "Mock Orange (Pittosporum tobira)", + "wordOfFlower": "embrace" + }, + { + "id": 88, + "name": "Cherry Blossom", + "wordOfFlower": "spiritual beauty" + }, + { + "id": 89, + "name": "Forsythia", + "wordOfFlower": "hope" + }, + { + "id": 90, + "name": "Plum Blossom (Prunus mume)", + "wordOfFlower": "pure heart" + }, + { + "id": 91, + "name": "Magnolia", + "wordOfFlower": "love of nature" + }, + { + "id": 92, + "name": "Redbud (Cercis chinensis)", + "wordOfFlower": "friendship" + }, + { + "id": 93, + "name": "Flowering Quince (Chaenomeles)", + "wordOfFlower": "trust" + } +]; diff --git a/src/lib/flowerFlow/resolveRecipeFlowers.js b/src/lib/flowerFlow/resolveRecipeFlowers.js new file mode 100644 index 0000000..a2a4816 --- /dev/null +++ b/src/lib/flowerFlow/resolveRecipeFlowers.js @@ -0,0 +1,222 @@ +import { flowerCatalogLite } from './flowerCatalogLite.js'; +import { getFlowerKo } from './flowerCatalogKo.js'; + +/** + * @typedef {{ id: number, name: string, nameKo: string, wordOfFlower: string, wordOfFlowerKo: string, imageSrc: string, label: string, role: 'main' | 'sub' | 'greenery' }} RecipeFlowerCard + */ + +/** @param {string} name */ +function normalizeName(name) { + return name + .toLowerCase() + .replace(/[()'".]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +/** @param {string} name */ +function primaryName(name) { + return normalizeName(name.split('(')[0]); +} + +/** + * Match a recipe flower string (e.g. "Pink tulip") to a catalog entry. + * @param {string} label + * @returns {(typeof flowerCatalogLite)[number] | null} + */ +function matchCatalogFlower(label) { + const normalized = normalizeName(label); + + for (const flower of flowerCatalogLite) { + const catalogPrimary = primaryName(flower.name); + if ( + normalized === catalogPrimary || + normalized.includes(catalogPrimary) || + catalogPrimary.includes(normalized) + ) { + return flower; + } + } + + return null; +} + +/** + * @param {{ mainFlowers?: string[], subFlowers?: string[], greenery?: string[] } | null | undefined} recipe + * @param {(id: number) => string} getImageSrc + * @returns {RecipeFlowerCard[]} + */ +export function resolveRecipeFlowers(recipe, getImageSrc) { + if (!recipe) return []; + + /** @type {RecipeFlowerCard[]} */ + const cards = []; + /** @type {Set} */ + const seenIds = new Set(); + + /** @param {string[] | undefined} labels @param {'main' | 'sub' | 'greenery'} role */ + const addFlowers = (labels, role) => { + for (const label of labels ?? []) { + if (!label) continue; + + const match = matchCatalogFlower(label); + if (!match || seenIds.has(match.id)) continue; + + seenIds.add(match.id); + const ko = getFlowerKo(match.id, match.name, match.wordOfFlower); + cards.push({ + id: match.id, + name: match.name, + nameKo: ko.nameKo, + wordOfFlower: match.wordOfFlower, + wordOfFlowerKo: ko.wordOfFlowerKo, + label, + role, + imageSrc: getImageSrc(match.id) + }); + } + }; + + addFlowers(recipe.mainFlowers, 'main'); + addFlowers(recipe.subFlowers, 'sub'); + addFlowers(recipe.greenery, 'greenery'); + + return cards; +} + +/** + * @param {string | null | undefined} text + * @param {number} [maxLength=140] + */ +export function truncateDescription(text, maxLength = 140) { + if (!text?.trim()) return ''; + + const trimmed = text.trim(); + if (trimmed.length <= maxLength) return trimmed; + + return `${trimmed.slice(0, maxLength - 1).trimEnd()}…`; +} + +/** + * @param {string[]} [items] + * @param {number} [limit=2] + */ +function pickKeywords(items, limit = 2) { + if (!items?.length) return ''; + return items.filter(Boolean).slice(0, limit).join(', '); +} + +/** + * Short mood-led title for the result description card. + * @param {{ moodKeywords?: string[], styleImpression?: string[] } | null | undefined} moodAnalysis + */ +export function buildBriefBouquetTitle(moodAnalysis) { + if (!moodAnalysis) return 'Your bouquet'; + + const keywords = [ + ...(moodAnalysis.styleImpression ?? []), + ...(moodAnalysis.moodKeywords ?? []) + ].filter(Boolean); + + if (keywords.length === 0) return 'Your bouquet'; + + return keywords + .slice(0, 2) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' & '); +} + +/** + * @param {{ notes?: string } | null | undefined} userInput + */ +export function extractCardMessage(userInput) { + const notes = userInput?.notes?.trim(); + if (!notes) return ''; + + const prefix = 'Card message: '; + return notes.startsWith(prefix) ? notes.slice(prefix.length).trim() : notes; +} + +/** + * @param {{ mainFlowers?: string[] } | null | undefined} recipe + */ +function getPrimaryFlowerFromRecipe(recipe) { + const label = recipe?.mainFlowers?.[0]; + if (!label) return null; + return matchCatalogFlower(label); +} + +/** + * Why this bouquet fits — mood from images, message, and main flower language. + * @param {{ moodKeywords?: string[], styleImpression?: string[], colorPalette?: string[] } | null | undefined} moodAnalysis + * @param {{ relationship?: string, notes?: string } | null | undefined} userInput + * @param {{ mainFlowers?: string[] } | null | undefined} recipe + */ +export function buildBouquetRationale(moodAnalysis, userInput, recipe) { + const recipient = userInput?.relationship?.trim(); + const subject = recipient ? `${recipient}'s` : 'The'; + const cardMessage = extractCardMessage(userInput); + const mainFlower = getPrimaryFlowerFromRecipe(recipe); + + const mood = pickKeywords( + [...(moodAnalysis?.moodKeywords ?? []), ...(moodAnalysis?.styleImpression ?? [])], + 2 + ); + const colors = pickKeywords(moodAnalysis?.colorPalette, 2); + + /** @type {string[]} */ + const parts = []; + + if (mood && colors) { + parts.push(`${subject} ${mood} mood and ${colors} tones came through in the moodboard.`); + } else if (mood) { + parts.push(`${subject} ${mood} mood came through in the moodboard.`); + } else if (colors) { + parts.push(`${subject} ${colors} tones came through in the moodboard.`); + } else if (!moodAnalysis) { + parts.push('We shaped this bouquet from the feeling in the images.'); + } + + if (cardMessage && mainFlower) { + const messageRef = + cardMessage.length <= 40 ? `your message, "${cardMessage}"` : 'your message'; + parts.push( + `For ${messageRef}, ${mainFlower.name} (${mainFlower.wordOfFlower}) felt like the right fit.` + ); + } else if (cardMessage) { + parts.push('Your message helped guide the flowers we chose.'); + } else if (mainFlower) { + parts.push(`${mainFlower.name} (${mainFlower.wordOfFlower}) anchors the bouquet.`); + } + + if (parts.length === 0) { + return recipient + ? `This bouquet reflects the feeling in ${recipient}'s images.` + : 'This bouquet reflects the feeling in the images.'; + } + + return parts.join(' '); +} + +/** + * @param {{ concept?: string, shape?: string, mainFlowers?: string[], wrapping?: string } | null | undefined} recipe + * @param {number} [maxLength=140] + */ +export function buildBriefBouquetDescription(recipe, maxLength = 140) { + if (!recipe) return 'A bouquet shaped from their mood.'; + + const mains = recipe.mainFlowers?.slice(0, 2).join(' and '); + const parts = []; + + if (recipe.shape) parts.push(recipe.shape); + if (mains) parts.push(`featuring ${mains}`); + if (recipe.wrapping) { + const wrap = recipe.wrapping.split(' with ')[0]; + parts.push(`wrapped in ${wrap}`); + } + + const text = parts.join(', '); + if (!text) return truncateDescription(recipe.concept, maxLength); + + return truncateDescription(text.charAt(0).toUpperCase() + text.slice(1), maxLength); +} diff --git a/src/lib/server/gemini/image.js b/src/lib/server/gemini/image.js index a9402a2..45bdcb1 100644 --- a/src/lib/server/gemini/image.js +++ b/src/lib/server/gemini/image.js @@ -1,6 +1,7 @@ /** @typedef {import('../flowerFlow/jobStore.js').GeneratedImage} GeneratedImage */ import { env } from '$env/dynamic/private'; +import { BOUQUET_IMAGE_ASPECT_PROMPT } from '../../flowerFlow/bouquetImageFormat.js'; import { getImageModel, isGeminiConfigured } from './client.js'; import { mockGeneratedImage } from './mock.js'; import { generateOpenAIImage, isOpenAIConfigured } from '../openai/image.js'; @@ -26,8 +27,8 @@ export function isImageGenerationConfigured() { */ export async function generateBouquetImage(basePrompt, options = {}) { const suffix = options.edit - ? 'Generate exactly one edited bouquet image. Show a single bouquet only, centered in frame. Do not show two bouquets, no side-by-side comparison, no before/after layout, and no duplicate arrangements. Keep it realistic, orderable from a real florist, front-facing, and suitable for a customer preview.' - : 'Generate one final bouquet image. Keep it realistic, orderable from a real florist, front-facing, and suitable for a customer preview.'; + ? `Generate exactly one edited bouquet image. Show a single bouquet only, centered in frame. Do not show two bouquets, no side-by-side comparison, no before/after layout, and no duplicate arrangements. ${BOUQUET_IMAGE_ASPECT_PROMPT} Keep it realistic, orderable from a real florist, front-facing, and suitable for a customer preview.` + : `Generate one final bouquet image. ${BOUQUET_IMAGE_ASPECT_PROMPT} Keep it realistic, orderable from a real florist, front-facing, and suitable for a customer preview.`; const prompt = `${basePrompt}\n\n${suffix}`; const provider = getImageProvider(); diff --git a/src/lib/server/gemini/mock.js b/src/lib/server/gemini/mock.js index c79b43e..4166f77 100644 --- a/src/lib/server/gemini/mock.js +++ b/src/lib/server/gemini/mock.js @@ -21,9 +21,30 @@ export function mockRecipe(userInput = {}) { ? `around ₩${userInput.budget.toLocaleString('en-US')}` : 'around ₩50,000'; + const notes = userInput.notes?.toLowerCase() ?? ''; + + /** @type {string[]} */ + let mainFlowers = ['Pink tulip']; + /** @type {string} */ + let concept = 'Soft Romantic Tulip Bouquet'; + + if (/love|사랑/.test(notes)) { + mainFlowers = ['Red rose']; + concept = 'Romantic Rose Bouquet'; + } else if (/thank|grateful|감사/.test(notes)) { + mainFlowers = ['Dahlia']; + concept = 'Grateful Dahlia Bouquet'; + } else if (/proud|congratul/.test(notes)) { + mainFlowers = ['Sunflower']; + concept = 'Celebratory Sunflower Bouquet'; + } else if (/birthday|happy/.test(notes)) { + mainFlowers = ['Gerbera']; + concept = 'Cheerful Gerbera Bouquet'; + } + return { - concept: 'Soft Romantic Tulip Bouquet', - mainFlowers: ['Pink tulip'], + concept, + mainFlowers, subFlowers: ["Baby's breath", 'Seasonal white flowers'], greenery: ['Eucalyptus'], colors: ['pale pink', 'ivory', 'soft green'], @@ -41,7 +62,8 @@ export function mockImagePrompt(recipe) { `Use ${recipe.mainFlowers.join(', ')} as the main flower, mixed with ${recipe.subFlowers.join(', ')}, and ${recipe.greenery.join(', ')}.`, `Use a ${recipe.colors.join(', ')} color palette.`, `Wrap it with ${recipe.wrapping}.`, - 'White background, soft natural lighting, Korean florist style.' + 'White background, soft natural lighting, Korean florist style.', + 'Vertical portrait composition with a 3:4 aspect ratio (width:height). Frame the full bouquet without cropping.' ].join(' '); } @@ -63,3 +85,47 @@ export function mockGeneratedImage(label = 'Bouquet') { 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 + * @param {string} editPrompt + * @returns {BouquetRecipe} + */ +export function mockApplyRecipeEdit(recipe, editPrompt) { + /** @type {BouquetRecipe} */ + const updated = structuredClone(recipe); + const lower = editPrompt.toLowerCase(); + + const swapMatch = + lower.match(/(?:change|replace|swap)\s+(.+?)\s+(?:to|with|into)\s+(.+)/) ?? + lower.match(/(.+?)\s+(?:to|into)\s+(.+)/); + + if (!swapMatch) return updated; + + const fromToken = swapMatch[1].trim().replace(/[.!?]$/, ''); + const toToken = swapMatch[2].trim().replace(/[.!?]$/, ''); + if (!fromToken || !toToken) return updated; + + /** @param {string[]} labels */ + const replaceInList = (labels) => + labels.map((label) => { + if (!label.toLowerCase().includes(fromToken)) return label; + + const colorPrefix = label.match(/^(\w+)\s+/i)?.[1]; + const capitalizedTo = + toToken.charAt(0).toUpperCase() + toToken.slice(1).toLowerCase(); + + if (colorPrefix && !fromToken.includes(' ')) { + return `${colorPrefix} ${capitalizedTo}`; + } + + return label.replace(new RegExp(fromToken, 'i'), capitalizedTo); + }); + + updated.mainFlowers = replaceInList(updated.mainFlowers); + updated.subFlowers = replaceInList(updated.subFlowers); + updated.greenery = replaceInList(updated.greenery); + + return updated; +} diff --git a/src/lib/server/gemini/text.js b/src/lib/server/gemini/text.js index ae30d54..56fa76c 100644 --- a/src/lib/server/gemini/text.js +++ b/src/lib/server/gemini/text.js @@ -3,6 +3,7 @@ /** @typedef {import('../flowerFlow/jobStore.js').UserInput} UserInput */ import { matchFlowersFromMood } from '../flowerFlow/flowerDB.js'; +import { BOUQUET_IMAGE_ASPECT_PROMPT } from '../../flowerFlow/bouquetImageFormat.js'; import { getTextModel, isGeminiConfigured, parseJsonFromText } from './client.js'; import { mockRecipe } from './mock.js'; @@ -54,6 +55,7 @@ Return JSON only: Rules: - Use ONLY exact candidate names from the lists above. Do not invent, rename, or substitute flowers. +- If userInput.notes contains a card message, choose mainFlowers whose wordOfFlower (on each candidate) best matches what the card message says. Flower language fit for the message is the top priority for mainFlowers when a card message exists. - mainFlowers must come from candidates.main only (1-2 items). - subFlowers must combine candidates.filler and/or candidates.line only (2-4 items total). - greenery must come from candidates.foliage only (1-2 items). @@ -90,7 +92,8 @@ Rules: - White background, soft natural lighting - Korean florist style - Describe bouquet composition only (flower types, colors, wrapping, mood) -- Do NOT specify alternate size variants — generate one final customer preview image +- ${BOUQUET_IMAGE_ASPECT_PROMPT} +- Do NOT specify alternate size variants; generate one final customer preview image - Return plain text only, no markdown`; const result = await model.generateContent(prompt); @@ -119,3 +122,46 @@ Return plain text only.`; const result = await model.generateContent(prompt); return result.response.text().trim(); } + +/** + * Update a bouquet recipe to reflect a customer edit request. + * @param {BouquetRecipe} recipe + * @param {string} editPrompt + * @returns {Promise} + */ +export async function applyRecipeEdit(recipe, editPrompt) { + if (!isGeminiConfigured()) { + const { mockApplyRecipeEdit } = await import('./mock.js'); + return mockApplyRecipeEdit(recipe, editPrompt); + } + + const model = getTextModel(); + const prompt = `You are a professional florist assistant. +Update this bouquet recipe so it matches the customer's edit request. + +Current recipe: +${JSON.stringify(recipe, null, 2)} + +Edit request: +${editPrompt} + +Return JSON only with the same schema: +{ + "concept": string, + "mainFlowers": string[], + "subFlowers": string[], + "greenery": string[], + "colors": string[], + "wrapping": string, + "shape": string, + "budget": string +} + +Rules: +- Change only what the edit request implies; keep unrelated fields the same. +- Use realistic florist flower names. +- mainFlowers, subFlowers, and greenery must stay consistent with the edit.`; + + const result = await model.generateContent(prompt); + return /** @type {BouquetRecipe} */ (parseJsonFromText(result.response.text())); +} diff --git a/src/lib/server/openai/image.js b/src/lib/server/openai/image.js index e15566b..0b10c2d 100644 --- a/src/lib/server/openai/image.js +++ b/src/lib/server/openai/image.js @@ -27,7 +27,7 @@ export async function generateOpenAIImage(prompt) { const response = await getOpenAIClient().images.generate({ model: env.OPENAI_IMAGE_MODEL || 'gpt-image-1', prompt, - size: env.OPENAI_IMAGE_SIZE || '1024x1024', + size: env.OPENAI_IMAGE_SIZE || '1024x1536', n: 1 }); diff --git a/src/routes/api/flower-flow/edit-images/+server.js b/src/routes/api/flower-flow/edit-images/+server.js index 3e1e403..c2c8341 100644 --- a/src/routes/api/flower-flow/edit-images/+server.js +++ b/src/routes/api/flower-flow/edit-images/+server.js @@ -5,7 +5,7 @@ import { getImageProvider, isImageGenerationConfigured } from '$lib/server/gemini/image.js'; -import { buildImagePrompt } from '$lib/server/gemini/text.js'; +import { buildImagePrompt, applyRecipeEdit } from '$lib/server/gemini/text.js'; import { json, readJsonBody, toErrorResponse } from '$lib/server/http.js'; /** @@ -74,7 +74,8 @@ export async function POST({ request }) { return json({ error: 'recipe is missing. Run recipe first.', code: 'bad_request' }, 400); } - const basePrompt = job.imagePrompt ?? (await buildImagePrompt(job.recipe)); + const updatedRecipe = await applyRecipeEdit(job.recipe, prompt); + const basePrompt = job.imagePrompt ?? (await buildImagePrompt(updatedRecipe)); const editPrompt = `${basePrompt}\n\n${describeEditInstruction({ mode, prompt, selection })}`; console.log( @@ -86,13 +87,19 @@ export async function POST({ request }) { generatedImage, `edit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` ); - await updateJob(jobId, { imagePrompt: editPrompt, images, floristNote: null }); + await updateJob(jobId, { + recipe: updatedRecipe, + imagePrompt: editPrompt, + images, + floristNote: null + }); console.log( `[flower-flow] edit-images job=${jobId.slice(0, 8)} OK (mock=${!isImageGenerationConfigured()})` ); return json({ jobId, + recipe: updatedRecipe, imagePrompt: editPrompt, images, mock: !isImageGenerationConfigured() diff --git a/src/routes/create/+page.svelte b/src/routes/create/+page.svelte index 7ed1805..76fb485 100644 --- a/src/routes/create/+page.svelte +++ b/src/routes/create/+page.svelte @@ -5,6 +5,9 @@ import Header from '$lib/components/ui/Header.svelte'; import Artwork from '$lib/components/ui/Artwork/Artwork.svelte'; import ContextForm from '$lib/components/ui/create/ContextForm.svelte'; + import FlowContinueBar, { + FLOW_CONTINUE_BUTTON + } from '$lib/components/ui/FlowContinueBar.svelte'; import { consumeDevCreateSnapshot, deleteFlowKey, @@ -31,7 +34,7 @@ const artworkDescription = $derived( hasAnySelection - ? `${style ?? '—'} style · ₩${budget.toLocaleString('ko-KR')} budget` + ? `${style ?? '...'} style · ₩${budget.toLocaleString('ko-KR')} budget` : 'Description Description Description' ); @@ -86,20 +89,16 @@
-
- - -
- +
+
+
+ + + +
diff --git a/src/routes/edit/+page.svelte b/src/routes/edit/+page.svelte index 2a35aea..a1f25c8 100644 --- a/src/routes/edit/+page.svelte +++ b/src/routes/edit/+page.svelte @@ -3,8 +3,12 @@ import { goto } from '$app/navigation'; import { resolve } from '$app/paths'; 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 { buildBriefBouquetTitle } from '$lib/flowerFlow/resolveRecipeFlowers.js'; import { getFlowString, saveFlow } from '$lib/flowerFlow/session.js'; const jobId = getFlowString('jobId'); @@ -23,7 +27,7 @@ let selectionPoints = $state([]); let initialImage = $state(null); let generatedImage = $state(null); - let recipe = $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 }>} */ @@ -33,7 +37,7 @@ const imageSrc = $derived(toDataUrl(generatedImage)); const hasAreaSelection = $derived(selectionPoints.length > 2); - const title = $derived(recipe?.concept ?? 'Generated bouquet'); + const title = $derived(buildBriefBouquetTitle(moodAnalysis)); const description = $derived.by(() => { if (hasAreaSelection) { return 'Your prompt will apply to the marked area only.'; @@ -234,7 +238,7 @@ initialImage = job.images.primary; generatedImage = job.images.primary; - recipe = job.recipe ?? null; + moodAnalysis = job.moodAnalysis ?? null; loading = false; } catch (err) { error = err instanceof Error ? err.message : 'Failed to load generated bouquet'; @@ -244,19 +248,19 @@ {#snippet editableImageFrame(image, editable = false)} -
+
{#if image} Generated bouquet {:else} -
+
{/if} {#if editable && image} @@ -336,13 +340,17 @@ class="flex min-h-0 w-full shrink-0 flex-col border-b border-line px-6 py-6 lg:w-[44%] lg:border-r lg:border-b-0 lg:px-10 lg:py-8" >
-
+
{#if loading} -
+
{:else if imageSrc} - Generated bouquet + Generated bouquet {:else} -
+
{/if}
@@ -350,9 +358,9 @@
-
+

Edit bouquet

@@ -415,16 +423,14 @@
-
+ {#if error}

{error}

{/if} -
+