diff --git a/src/lib/components/ui/upload/MoodboardGrid.svelte b/src/lib/components/ui/upload/MoodboardGrid.svelte index a1f2653..1e43c3d 100644 --- a/src/lib/components/ui/upload/MoodboardGrid.svelte +++ b/src/lib/components/ui/upload/MoodboardGrid.svelte @@ -4,7 +4,11 @@ import { hydrateDevUpload } from '$lib/dev/hydrateUpload.js'; import { getFlowObject, isDevSeeded } from '$lib/flowerFlow/session.js'; - let { primaryFile = $bindable(null), caption = 'build their moodboard!', filledCount = $bindable(0), allFilled = $bindable(false) } = $props(); + let { + primaryFile = $bindable(null), + uploadedTiles = $bindable(), + caption = 'build their moodboard!' + } = $props(); let colorFile = $state(null); let seasonFile = $state(null); @@ -17,9 +21,21 @@ }); $effect(() => { - const count = [colorFile, seasonFile, characterFile, locationFile].filter(Boolean).length; - filledCount = count; - allFilled = count === 4; + const next = { + color: !!colorFile, + season: !!seasonFile, + character: !!characterFile, + location: !!locationFile + }; + + if ( + uploadedTiles?.color !== next.color || + uploadedTiles?.season !== next.season || + uploadedTiles?.character !== next.character || + uploadedTiles?.location !== next.location + ) { + uploadedTiles = next; + } }); onMount(async () => { diff --git a/src/lib/components/ui/upload/SnsFeedUpload.svelte b/src/lib/components/ui/upload/SnsFeedUpload.svelte index 5f73edc..9d311a5 100644 --- a/src/lib/components/ui/upload/SnsFeedUpload.svelte +++ b/src/lib/components/ui/upload/SnsFeedUpload.svelte @@ -4,7 +4,8 @@ import { hydrateDevUpload } from '$lib/dev/hydrateUpload.js'; import { getFlowObject, isDevSeeded } from '$lib/flowerFlow/session.js'; - let { primaryFile = $bindable(null), caption = 'upload their feed!', filledCount = $bindable(0), allFilled = $bindable(false) } = $props(); + let { primaryFile = $bindable(null), hasImage = $bindable(), caption = 'upload their feed!' } = + $props(); let firstFile = $state(null); @@ -14,8 +15,8 @@ }); $effect(() => { - filledCount = firstFile ? 1 : 0; - allFilled = Boolean(firstFile); + const next = !!firstFile; + if (hasImage !== next) hasImage = next; }); onMount(async () => { diff --git a/src/lib/server/gemini/image.js b/src/lib/server/gemini/image.js index 34f6e16..a9402a2 100644 --- a/src/lib/server/gemini/image.js +++ b/src/lib/server/gemini/image.js @@ -21,10 +21,14 @@ export function isImageGenerationConfigured() { /** * @param {string} basePrompt + * @param {{ edit?: boolean }} [options] * @returns {Promise} */ -export async function generateBouquetImage(basePrompt) { - const prompt = `${basePrompt}\n\nGenerate one final bouquet image. Keep it realistic, orderable from a real florist, front-facing, and suitable for a customer preview.`; +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.'; + const prompt = `${basePrompt}\n\n${suffix}`; const provider = getImageProvider(); // Explicit mock mode: develop the full flow without spending any image quota. diff --git a/src/routes/api/flower-flow/edit-images/+server.js b/src/routes/api/flower-flow/edit-images/+server.js index 116b35f..3e1e403 100644 --- a/src/routes/api/flower-flow/edit-images/+server.js +++ b/src/routes/api/flower-flow/edit-images/+server.js @@ -32,7 +32,9 @@ function describeEditInstruction(instruction) { 'EDIT REQUEST:', instruction.prompt, '', - 'Preserve the same bouquet concept, camera angle, background, wrapping style, and realistic florist photography unless the edit request explicitly says otherwise.' + 'This is a refinement of one existing bouquet photo, not a new collage.', + 'Preserve the same bouquet concept, camera angle, background, wrapping style, and realistic florist photography unless the edit request explicitly says otherwise.', + 'Output exactly one bouquet in a single composition. Never show two bouquets, side-by-side views, comparison panels, or duplicated arrangements.' ]; if (instruction.mode === 'area') { @@ -78,7 +80,7 @@ export async function POST({ request }) { console.log( `[flower-flow] edit-images job=${jobId.slice(0, 8)} provider=${getImageProvider()} mode=${mode} → generating...` ); - const generatedImage = await generateBouquetImage(editPrompt); + const generatedImage = await generateBouquetImage(editPrompt, { edit: true }); const images = await uploadGeneratedImages( jobId, generatedImage, diff --git a/src/routes/edit/+page.svelte b/src/routes/edit/+page.svelte index ec519ef..2a35aea 100644 --- a/src/routes/edit/+page.svelte +++ b/src/routes/edit/+page.svelte @@ -1,5 +1,5 @@ +{#snippet editableImageFrame(image, editable = false)} +
+ {#if image} + Generated bouquet + {:else} +
+ {/if} + + {#if editable && image} + + + {#if areaSelectionActive} + + {#if selectionPoints.length > 1} + + {/if} + + {/if} + {/if} +
+{/snippet} +
@@ -199,156 +351,57 @@
-
+

Edit bouquet

Tell us how you want to refine it.

-
+

Generated image

-
- {#if initialImage} - Generated bouquet - {:else if imageSrc} - Generated bouquet - {:else} -
- {/if} - - {#if mode === 'area' && editHistory.length === 0} - - {#if selectionPoints.length > 1} - - {#each selectionPoints.filter((_, index) => index % 8 === 0) as point, index (index)} - - {/each} - {/if} - - {/if} -
+ {@render editableImageFrame(initialImage ?? generatedImage, chatMessages.length === 0)}
- {#each editHistory as edit (edit.id)} -
+ {#each chatMessages as message (message.id)} + {#if message.role === 'user'}
-

{edit.instruction.prompt}

- {#if edit.instruction.mode === 'area'} +

{message.prompt}

+ {#if message.mode === 'area'}

Selected area only

{/if}
- -
-
- {#if edit.afterImage} - Edited bouquet result - {/if} - - {#if mode === 'area' && edit.id === latestEditId} - - {#if selectionPoints.length > 1} - - {#each selectionPoints.filter((_, index) => index % 8 === 0) as point, index (index)} - - {/each} - {/if} - - {/if} + {:else if message.status === 'pending'} +
+
+ Editing bouquet image...
+
+ {:else if message.status === 'error'} +
+
+ {message.error} +
+
+ {:else} +
+ {@render editableImageFrame(message.afterImage, message.id === latestAssistantId)}

Result

-
+ {/if} {/each}
-
- - -
-
{#each QUICK_PROMPTS as quickPrompt (quickPrompt)}
+
-
+
+ {#if error} +

+ {error} +

+ {/if} + +
-
-

- {#if mode === 'area'} - Draw over the bouquet, then describe only that selected area. - {:else} - Prompt applies to the whole generated bouquet. - {/if} -

- - {#if selectionPoints.length > 0} - +
+
-
- {#if error} -

- {error} -

- {:else if editing} -

- Editing bouquet image... -

- {/if} - -
- - -
+
+
diff --git a/src/routes/upload/+page.svelte b/src/routes/upload/+page.svelte index 25dfee3..43f2acc 100644 --- a/src/routes/upload/+page.svelte +++ b/src/routes/upload/+page.svelte @@ -24,11 +24,21 @@ : 'moodboard' ); let primaryFile = $state(null); - let filledCount = $state(0); - let allFilled = $state(false); + let moodboardTiles = $state({ + color: false, + season: false, + character: false, + location: false + }); + let snsHasImage = $state(false); let loading = $state(false); let error = $state(''); + const recipientLabel = $derived.by(() => { + const who = typeof userInput.relationship === 'string' ? userInput.relationship : ''; + return who ? who.toLowerCase() : 'them'; + }); + const recipientPronoun = $derived.by(() => { const style = typeof userInput.style === 'string' ? userInput.style.toLowerCase() : ''; if (style === 'masculine') return 'his'; @@ -36,35 +46,97 @@ return 'their'; }); - const hasUserContext = $derived( - Boolean(userInput.relationship || userInput.occasion || userInput.style) - ); + const MOODBOARD_TILE_COPY = { + color: { + title: 'A hint of color', + description: + 'The first thread pulled. Warm or cool, bold or shy. Their palette begins to speak.' + }, + season: { + title: 'Season in the air', + description: 'Spring lightness or winter hush. Time of year will breathe through the bouquet.' + }, + character: { + title: 'Their character', + description: 'A face, a gesture, a presence. Something in them is starting to take floral form.' + }, + location: { + title: 'A sense of place', + description: + 'City grit or quiet coast. Where they belong roots the arrangement in memory.' + } + }; - const artworkTitle = $derived.by(() => { - const who = userInput.relationship; - const whatFor = userInput.occasion; - if (!hasUserContext) return 'Title'; - const occasion = whatFor ? `A ${whatFor} bouquet for` : 'A bouquet for'; - return `${occasion} ${who ?? '...'}`; + const artworkCopy = $derived.by(() => { + if (mode === 'sns') { + if (snsHasImage) { + return { + title: 'Feed captured', + description: + 'We will look at the photos and colors in their feed to sense what kind of bouquet fits them.' + }; + } + + return { + title: 'Their social world', + description: `Upload a screenshot of ${recipientPronoun} feed. One glance is often enough to sense the mood.` + }; + } + + const uploaded = /** @type {const} */ (['color', 'season', 'character', 'location']).filter( + (key) => moodboardTiles[key] + ); + const count = uploaded.length; + + if (count === 0) { + return { + title: 'Gather their mood', + description: `Four small glimpses of color, season, character, and place. Together they become the palette for a bouquet made for ${recipientLabel}.` + }; + } + + if (count === 1) { + return MOODBOARD_TILE_COPY[uploaded[0]]; + } + + if (count === 4) { + return { + title: 'A moodboard whole', + description: + 'Color, season, character, and place. The collage is complete, and their bouquet is ready to take shape.' + }; + } + + if (count === 2) { + return { + title: 'Taking shape', + description: + 'The moodboard is finding its rhythm. Keep adding. Each image is another note in their story.' + }; + } + + return { + title: 'Almost there', + description: 'One last glimpse and their world will be fully gathered on the page.' + }; }); - const artworkDescription = $derived( - hasUserContext - ? `${userInput.style ?? '—'} style · ₩${Number(userInput.budget ?? 50_000).toLocaleString('ko-KR')} budget` - : 'Description Description Description' - ); + const artworkTitle = $derived(artworkCopy.title); + const artworkDescription = $derived(artworkCopy.description); /** create2(시작) → upload1(1장+) → upload2(전체 채움) */ const artworkVariant = $derived.by(() => { - if (allFilled) return 'upload2'; - if (filledCount > 0) return 'upload1'; - return 'create2'; - }); + if (mode === 'sns') { + if (snsHasImage) return 'upload2'; + return 'create2'; + } - $effect(() => { - void mode; - filledCount = 0; - allFilled = false; + const count = ['color', 'season', 'character', 'location'].filter( + (key) => moodboardTiles[key] + ).length; + if (count === 4) return 'upload2'; + if (count > 0) return 'upload1'; + return 'create2'; }); async function continueToMessage() { @@ -126,15 +198,13 @@ {#if mode === 'moodboard'} {:else} {/if}