From e7c690ac1371a5295567396f1581ed1924c0a779 Mon Sep 17 00:00:00 2001 From: Chaewon Lee Date: Fri, 12 Jun 2026 17:24:26 +0900 Subject: [PATCH] feat: remove options page and add edit page --- src/lib/assets/flower.svg | 19 + .../components/ui/create/BudgetSlider.svelte | 2 +- .../components/ui/create/ContextForm.svelte | 2 +- .../components/ui/options/OptionCard.svelte | 40 -- .../components/ui/options/OptionsPanel.svelte | 49 -- src/lib/flowerFlow/api.js | 19 +- src/lib/server/dev/loadFixtureImages.js | 36 +- src/lib/server/dev/seedJob.js | 7 +- src/lib/server/flowerFlow/imageStorage.js | 29 +- src/lib/server/flowerFlow/jobStore.js | 8 +- src/lib/server/gemini/image.js | 57 +-- src/lib/server/gemini/mock.js | 7 +- src/lib/server/gemini/text.js | 2 +- src/routes/api/dev/seed-flow/+server.js | 2 +- src/routes/api/dev/skip-images/+server.js | 8 +- .../api/flower-flow/edit-images/+server.js | 101 +++++ .../{select-option => finalize}/+server.js | 18 +- .../flower-flow/generate-images/+server.js | 10 +- src/routes/api/flower-flow/job/+server.js | 1 - src/routes/edit/+page.svelte | 426 ++++++++++++++++++ src/routes/generating/+page.svelte | 2 +- src/routes/map/+page.svelte | 8 +- src/routes/message/+page.svelte | 6 +- src/routes/options/+page.svelte | 104 ----- src/routes/result/+page.svelte | 9 +- src/routes/upload/+page.svelte | 1 - 26 files changed, 622 insertions(+), 351 deletions(-) create mode 100644 src/lib/assets/flower.svg delete mode 100644 src/lib/components/ui/options/OptionCard.svelte delete mode 100644 src/lib/components/ui/options/OptionsPanel.svelte create mode 100644 src/routes/api/flower-flow/edit-images/+server.js rename src/routes/api/flower-flow/{select-option => finalize}/+server.js (60%) create mode 100644 src/routes/edit/+page.svelte delete mode 100644 src/routes/options/+page.svelte diff --git a/src/lib/assets/flower.svg b/src/lib/assets/flower.svg new file mode 100644 index 0000000..101ed26 --- /dev/null +++ b/src/lib/assets/flower.svg @@ -0,0 +1,19 @@ + + + diff --git a/src/lib/components/ui/create/BudgetSlider.svelte b/src/lib/components/ui/create/BudgetSlider.svelte index 73c4a0d..8fb831a 100644 --- a/src/lib/components/ui/create/BudgetSlider.svelte +++ b/src/lib/components/ui/create/BudgetSlider.svelte @@ -1,5 +1,5 @@ - - - diff --git a/src/lib/components/ui/options/OptionsPanel.svelte b/src/lib/components/ui/options/OptionsPanel.svelte deleted file mode 100644 index 4f9626e..0000000 --- a/src/lib/components/ui/options/OptionsPanel.svelte +++ /dev/null @@ -1,49 +0,0 @@ - - -
- {#if loading} -
-

Loading options...

-
- {:else} - -
- {#each sizeOptions as size (size)} - - {/each} -
- - (selectedSize = v)} - /> - {/if} -
- - diff --git a/src/lib/flowerFlow/api.js b/src/lib/flowerFlow/api.js index bc60054..f4aecfe 100644 --- a/src/lib/flowerFlow/api.js +++ b/src/lib/flowerFlow/api.js @@ -88,13 +88,24 @@ export async function generateImages(jobId) { /** * @param {string} jobId - * @param {'S' | 'M' | 'L'} size + * @param {{ mode: string, prompt: string, selection: Array<{ x: number, y: number }> }} editInstruction */ -export async function selectOption(jobId, size) { - const response = await fetch('/api/flower-flow/select-option', { +export async function editImages(jobId, editInstruction) { + const response = await fetch('/api/flower-flow/edit-images', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ jobId, size }) + body: JSON.stringify({ jobId, ...editInstruction }) + }); + + return parseResponse(response); +} + +/** @param {string} jobId */ +export async function finalizeJob(jobId) { + const response = await fetch('/api/flower-flow/finalize', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jobId }) }); return parseResponse(response); diff --git a/src/lib/server/dev/loadFixtureImages.js b/src/lib/server/dev/loadFixtureImages.js index 1f288cc..d15b6bc 100644 --- a/src/lib/server/dev/loadFixtureImages.js +++ b/src/lib/server/dev/loadFixtureImages.js @@ -2,7 +2,6 @@ import { readFileSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { mockGeneratedImage } from '$lib/server/gemini/mock.js'; -/** @typedef {import('../flowerFlow/jobStore.js').BouquetSize} BouquetSize */ /** @typedef {import('../flowerFlow/jobStore.js').GeneratedImage} GeneratedImage */ const MIME_BY_EXT = { @@ -14,34 +13,27 @@ const MIME_BY_EXT = { }; /** - * static/dev/bouquet-{size}.{jpg|png|svg} 를 읽습니다. + * static/dev/bouquet.{jpg|png|svg} 또는 기존 bouquet-m 파일을 읽습니다. * 파일이 없으면 mock SVG로 대체합니다. - * @param {BouquetSize} size * @returns {GeneratedImage} */ -function loadFixtureImage(size) { +export function loadDevBouquetImage() { const baseDir = join(process.cwd(), 'static', 'dev'); const extensions = ['.jpg', '.jpeg', '.png', '.webp', '.svg']; + const names = ['bouquet', 'bouquet-m']; - for (const ext of extensions) { - const filePath = join(baseDir, `bouquet-${size.toLowerCase()}${ext}`); - if (!existsSync(filePath)) continue; + for (const name of names) { + for (const ext of extensions) { + const filePath = join(baseDir, `${name}${ext}`); + if (!existsSync(filePath)) continue; - const mimeType = MIME_BY_EXT[ext] ?? 'application/octet-stream'; - return { - mimeType, - base64: readFileSync(filePath).toString('base64') - }; + const mimeType = MIME_BY_EXT[ext] ?? 'application/octet-stream'; + return { + mimeType, + base64: readFileSync(filePath).toString('base64') + }; + } } - return mockGeneratedImage(size); -} - -/** @returns {Partial>} */ -export function loadDevBouquetImages() { - return { - S: loadFixtureImage('S'), - M: loadFixtureImage('M'), - L: loadFixtureImage('L') - }; + return mockGeneratedImage(); } diff --git a/src/lib/server/dev/seedJob.js b/src/lib/server/dev/seedJob.js index fd12314..36d33d2 100644 --- a/src/lib/server/dev/seedJob.js +++ b/src/lib/server/dev/seedJob.js @@ -6,7 +6,7 @@ import { mockRecipe } from '$lib/server/gemini/mock.js'; import { uploadGeneratedImages } from '$lib/server/flowerFlow/imageStorage.js'; -import { loadDevBouquetImages } from './loadFixtureImages.js'; +import { loadDevBouquetImage } from './loadFixtureImages.js'; /** @typedef {'options' | 'result'} DevSeedStage */ @@ -22,13 +22,13 @@ export async function seedDevJob(userInput, stage = 'result') { const floristNote = stage === 'result' ? mockFloristNote(recipe) : null; const job = await createJob(userInput); - const images = await uploadGeneratedImages(job.id, loadDevBouquetImages()); + const images = await uploadGeneratedImages(job.id, loadDevBouquetImage(), `dev-${Date.now()}`); await updateJob(job.id, { moodAnalysis, recipe, imagePrompt, images, - ...(stage === 'result' ? { selectedSize: 'M', floristNote } : {}) + ...(stage === 'result' ? { floristNote } : {}) }); return { @@ -37,7 +37,6 @@ export async function seedDevJob(userInput, stage = 'result') { recipe, imagePrompt, images, - selectedSize: stage === 'result' ? 'M' : null, floristNote, mock: true }; diff --git a/src/lib/server/flowerFlow/imageStorage.js b/src/lib/server/flowerFlow/imageStorage.js index f593112..774eb62 100644 --- a/src/lib/server/flowerFlow/imageStorage.js +++ b/src/lib/server/flowerFlow/imageStorage.js @@ -4,7 +4,6 @@ import { throwSupabaseError } from '$lib/server/supabase.js'; -/** @typedef {import('./jobStore.js').BouquetSize} BouquetSize */ /** @typedef {import('./jobStore.js').GeneratedImage} GeneratedImage */ const EXTENSION_BY_MIME = { @@ -21,11 +20,11 @@ function extensionForMime(mimeType) { /** * @param {string} jobId - * @param {BouquetSize} size * @param {GeneratedImage} image + * @param {string} [revision='primary'] * @returns {Promise} */ -export async function uploadGeneratedImage(jobId, size, image) { +export async function uploadGeneratedImage(jobId, image, revision = 'primary') { if (!image.base64) { return image; } @@ -33,7 +32,7 @@ export async function uploadGeneratedImage(jobId, size, image) { const supabase = getSupabaseClient(); const bucket = getSupabaseStorageBucket(); const mimeType = image.mimeType || 'image/png'; - const path = `${jobId}/${size.toLowerCase()}.${extensionForMime(mimeType)}`; + const path = `${jobId}/${revision}.${extensionForMime(mimeType)}`; const bytes = Buffer.from(image.base64, 'base64'); const { error } = await supabase.storage.from(bucket).upload(path, bytes, { @@ -56,20 +55,12 @@ export async function uploadGeneratedImage(jobId, size, image) { /** * @param {string} jobId - * @param {Partial>} images - * @returns {Promise>>} + * @param {GeneratedImage} image + * @param {string} [revision] + * @returns {Promise<{ primary: GeneratedImage }>} */ -export async function uploadGeneratedImages(jobId, images) { - /** @type {Partial>} */ - const uploaded = {}; - const sizes = /** @type {BouquetSize[]} */ (['S', 'M', 'L']); - - for (const size of sizes) { - const image = images[size]; - if (image) { - uploaded[size] = await uploadGeneratedImage(jobId, size, image); - } - } - - return uploaded; +export async function uploadGeneratedImages(jobId, image, revision) { + return { + primary: await uploadGeneratedImage(jobId, image, revision) + }; } diff --git a/src/lib/server/flowerFlow/jobStore.js b/src/lib/server/flowerFlow/jobStore.js index 731ff5f..0f4beb8 100644 --- a/src/lib/server/flowerFlow/jobStore.js +++ b/src/lib/server/flowerFlow/jobStore.js @@ -1,8 +1,6 @@ import { randomUUID } from 'node:crypto'; import { getSupabaseClient, throwSupabaseError } from '$lib/server/supabase.js'; -/** @typedef {'S' | 'M' | 'L'} BouquetSize */ - /** * @typedef {Object} UserInput * @property {string} [relationship] @@ -50,8 +48,7 @@ import { getSupabaseClient, throwSupabaseError } from '$lib/server/supabase.js'; * @property {MoodAnalysis | null} moodAnalysis * @property {BouquetRecipe | null} recipe * @property {string | null} imagePrompt - * @property {Partial>} images - * @property {BouquetSize | null} selectedSize + * @property {{ primary?: GeneratedImage }} images * @property {string | null} floristNote */ @@ -68,7 +65,6 @@ function fromRow(row) { recipe: row.recipe ?? null, imagePrompt: row.image_prompt ?? null, images: row.images ?? {}, - selectedSize: row.selected_size ?? null, floristNote: row.florist_note ?? null }; } @@ -85,7 +81,6 @@ function toRowPatch(patch) { if ('recipe' in patch) row.recipe = patch.recipe; if ('imagePrompt' in patch) row.image_prompt = patch.imagePrompt; if ('images' in patch) row.images = patch.images ?? {}; - if ('selectedSize' in patch) row.selected_size = patch.selectedSize; if ('floristNote' in patch) row.florist_note = patch.floristNote; return row; @@ -105,7 +100,6 @@ export async function createJob(userInput = {}) { recipe: null, imagePrompt: null, images: {}, - selectedSize: null, floristNote: null }; diff --git a/src/lib/server/gemini/image.js b/src/lib/server/gemini/image.js index 4335944..34f6e16 100644 --- a/src/lib/server/gemini/image.js +++ b/src/lib/server/gemini/image.js @@ -1,4 +1,3 @@ -/** @typedef {import('../flowerFlow/jobStore.js').BouquetSize} BouquetSize */ /** @typedef {import('../flowerFlow/jobStore.js').GeneratedImage} GeneratedImage */ import { env } from '$env/dynamic/private'; @@ -6,36 +5,6 @@ import { getImageModel, isGeminiConfigured } from './client.js'; import { mockGeneratedImage } from './mock.js'; import { generateOpenAIImage, isOpenAIConfigured } from '../openai/image.js'; -/** S/M/L 공통 — recipe 구성 유지, 볼륨만 변경 */ -const SIZE_CONSTRAINTS = `CRITICAL: This is a size variant of the SAME bouquet design. -- Use EXACTLY the same main flowers, sub flowers, greenery, colors, and wrapping from the recipe above. -- Do NOT add, remove, or substitute any flower types or colors. -- Do NOT change wrapping paper style, ribbon, or background. -- Only change the NUMBER OF STEMS and overall bouquet VOLUME/DENSITY. -- Same studio product photo style, front-facing bouquet, white/neutral background.`; - -/** @type {Record} */ -const SIZE_PROMPTS = { - S: `SIZE: SMALL (S) — budget / compact version. -- Slim, compact bouquet; smallest of the three size options. -- Fewer stems: roughly 40% of a standard bouquet (about 3-5 main flower blooms visible). -- Narrow silhouette, delicate and minimal volume. -- Wrapping paper proportionally smaller, hugging a small stem count.`, - M: `SIZE: MEDIUM (M) — standard version. -- Balanced, everyday florist bouquet; noticeably fuller than S. -- Moderate stems: roughly 70% of a premium bouquet (about 6-9 main flower blooms visible). -- Wider and rounder than S; filler and greenery scaled up proportionally. -- Standard wrapping volume, natural full-but-not-oversized shape.`, - L: `SIZE: LARGE (L) — premium / generous version. -- Most voluminous and dense; clearly larger than M. -- Abundant stems: full premium bouquet (about 10-15+ main flower blooms visible). -- Wide, lush, grand arrangement; maximum filler and greenery density. -- Largest wrapping spread, framing a generous bouquet.` -}; - -/** @type {BouquetSize[]} */ -const ALL_SIZES = ['S', 'M', 'L']; - export function getImageProvider() { const configured = env.IMAGE_PROVIDER?.trim().toLowerCase(); if (configured === 'mock' || configured === 'openai' || configured === 'gemini') { @@ -52,28 +21,27 @@ export function isImageGenerationConfigured() { /** * @param {string} basePrompt - * @param {BouquetSize} size * @returns {Promise} */ -export async function generateBouquetImage(basePrompt, size) { - const prompt = `${basePrompt}\n\n${SIZE_CONSTRAINTS}\n\n${SIZE_PROMPTS[size]}`; +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.`; const provider = getImageProvider(); // Explicit mock mode: develop the full flow without spending any image quota. if (provider === 'mock') { - return mockGeneratedImage(size); + return mockGeneratedImage(); } if (provider === 'openai') { if (!isOpenAIConfigured()) { - return mockGeneratedImage(size); + return mockGeneratedImage(); } return generateOpenAIImage(prompt); } if (!isGeminiConfigured()) { - return mockGeneratedImage(size); + return mockGeneratedImage(); } const model = getImageModel(); @@ -92,18 +60,3 @@ export async function generateBouquetImage(basePrompt, size) { throw new Error('Gemini image model did not return image data'); } - -/** - * @param {string} basePrompt - * @returns {Promise>>} - */ -export async function generateAllSizeImages(basePrompt) { - /** @type {Partial>} */ - const images = {}; - - for (const size of ALL_SIZES) { - images[size] = await generateBouquetImage(basePrompt, size); - } - - return images; -} diff --git a/src/lib/server/gemini/mock.js b/src/lib/server/gemini/mock.js index 43ee3d1..c79b43e 100644 --- a/src/lib/server/gemini/mock.js +++ b/src/lib/server/gemini/mock.js @@ -1,6 +1,5 @@ /** @typedef {import('../flowerFlow/jobStore.js').MoodAnalysis} MoodAnalysis */ /** @typedef {import('../flowerFlow/jobStore.js').BouquetRecipe} BouquetRecipe */ -/** @typedef {import('../flowerFlow/jobStore.js').BouquetSize} BouquetSize */ /** @returns {MoodAnalysis} */ export function mockMoodAnalysis() { @@ -46,11 +45,11 @@ export function mockImagePrompt(recipe) { ].join(' '); } -/** @param {BouquetSize} size */ -export function mockGeneratedImage(size) { +/** @param {string} [label] */ +export function mockGeneratedImage(label = 'Bouquet') { const svg = ` - Mock Bouquet ${size} + Mock ${label} Set GEMINI_API_KEY for real images `; diff --git a/src/lib/server/gemini/text.js b/src/lib/server/gemini/text.js index 87d1bc7..508fc54 100644 --- a/src/lib/server/gemini/text.js +++ b/src/lib/server/gemini/text.js @@ -76,7 +76,7 @@ Rules: - White background, soft natural lighting - Korean florist style - Describe bouquet composition only (flower types, colors, wrapping, mood) -- Do NOT specify bouquet size, stem count, or S/M/L — size variants are applied in a separate step +- Do NOT specify alternate size variants — generate one final customer preview image - Return plain text only, no markdown`; const result = await model.generateContent(prompt); diff --git a/src/routes/api/dev/seed-flow/+server.js b/src/routes/api/dev/seed-flow/+server.js index 99ab69f..ef44070 100644 --- a/src/routes/api/dev/seed-flow/+server.js +++ b/src/routes/api/dev/seed-flow/+server.js @@ -43,7 +43,7 @@ export async function POST({ request }) { moodboard: DEV_MOODBOARD_UPLOAD, sns: DEV_SNS_UPLOAD }, - ...(stage === 'result' ? { selectedSize: 'M', floristNote: seeded.floristNote } : {}) + ...(stage === 'result' ? { floristNote: seeded.floristNote } : {}) }, // create 폼 초기값 참고용 (relationship/occasion/style/budget만) formDefaults: DEV_USER_INPUT diff --git a/src/routes/api/dev/skip-images/+server.js b/src/routes/api/dev/skip-images/+server.js index b57af86..54b5df9 100644 --- a/src/routes/api/dev/skip-images/+server.js +++ b/src/routes/api/dev/skip-images/+server.js @@ -1,6 +1,6 @@ import { dev } from '$app/environment'; import { json } from '@sveltejs/kit'; -import { loadDevBouquetImages } from '$lib/server/dev/loadFixtureImages.js'; +import { loadDevBouquetImage } from '$lib/server/dev/loadFixtureImages.js'; import { requireJob, updateJob } from '$lib/server/flowerFlow/jobStore.js'; import { uploadGeneratedImages } from '$lib/server/flowerFlow/imageStorage.js'; import { mockImagePrompt, mockMoodAnalysis, mockRecipe } from '$lib/server/gemini/mock.js'; @@ -24,7 +24,11 @@ export async function POST({ request }) { const moodAnalysis = job.moodAnalysis ?? mockMoodAnalysis(); const recipe = job.recipe ?? mockRecipe(job.userInput); const imagePrompt = job.imagePrompt ?? mockImagePrompt(recipe); - const images = await uploadGeneratedImages(jobId, loadDevBouquetImages()); + const images = await uploadGeneratedImages( + jobId, + loadDevBouquetImage(), + `dev-skip-${Date.now()}` + ); await updateJob(jobId, { moodAnalysis, recipe, imagePrompt, images }); diff --git a/src/routes/api/flower-flow/edit-images/+server.js b/src/routes/api/flower-flow/edit-images/+server.js new file mode 100644 index 0000000..116b35f --- /dev/null +++ b/src/routes/api/flower-flow/edit-images/+server.js @@ -0,0 +1,101 @@ +import { requireJob, updateJob } from '$lib/server/flowerFlow/jobStore.js'; +import { uploadGeneratedImages } from '$lib/server/flowerFlow/imageStorage.js'; +import { + generateBouquetImage, + getImageProvider, + isImageGenerationConfigured +} from '$lib/server/gemini/image.js'; +import { buildImagePrompt } from '$lib/server/gemini/text.js'; +import { json, readJsonBody, toErrorResponse } from '$lib/server/http.js'; + +/** + * @param {unknown} value + */ +function isPointArray(value) { + return ( + Array.isArray(value) && + value.every( + (point) => + point && + typeof point === 'object' && + typeof point.x === 'number' && + typeof point.y === 'number' + ) + ); +} + +/** + * @param {{ mode: string, prompt: string, selection: unknown }} instruction + */ +function describeEditInstruction(instruction) { + const lines = [ + '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.' + ]; + + if (instruction.mode === 'area') { + lines.push( + 'The user drew a target area on the image. Apply the edit only to that visual region as much as possible, while keeping the rest of the bouquet unchanged.', + `Selection points are normalized image coordinates: ${JSON.stringify(instruction.selection)}` + ); + } + + return lines.join('\n'); +} + +/** @type {import('./$types').RequestHandler} */ +export async function POST({ request }) { + try { + const body = await readJsonBody(request); + const jobId = typeof body.jobId === 'string' ? body.jobId : ''; + const prompt = typeof body.prompt === 'string' ? body.prompt.trim() : ''; + const mode = body.mode === 'area' ? 'area' : 'whole'; + const selection = isPointArray(body.selection) ? body.selection : []; + + if (!jobId) { + return json({ error: 'jobId is required', code: 'bad_request' }, 400); + } + + if (!prompt) { + return json({ error: 'prompt is required', code: 'bad_request' }, 400); + } + + if (mode === 'area' && selection.length < 3) { + return json({ error: 'selection is required for area edits', code: 'bad_request' }, 400); + } + + const job = await requireJob(jobId); + + if (!job.recipe) { + return json({ error: 'recipe is missing. Run recipe first.', code: 'bad_request' }, 400); + } + + const basePrompt = job.imagePrompt ?? (await buildImagePrompt(job.recipe)); + const editPrompt = `${basePrompt}\n\n${describeEditInstruction({ mode, prompt, selection })}`; + + console.log( + `[flower-flow] edit-images job=${jobId.slice(0, 8)} provider=${getImageProvider()} mode=${mode} → generating...` + ); + const generatedImage = await generateBouquetImage(editPrompt); + const images = await uploadGeneratedImages( + jobId, + generatedImage, + `edit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + ); + await updateJob(jobId, { imagePrompt: editPrompt, images, floristNote: null }); + console.log( + `[flower-flow] edit-images job=${jobId.slice(0, 8)} OK (mock=${!isImageGenerationConfigured()})` + ); + + return json({ + jobId, + imagePrompt: editPrompt, + images, + mock: !isImageGenerationConfigured() + }); + } catch (error) { + return toErrorResponse(error); + } +} diff --git a/src/routes/api/flower-flow/select-option/+server.js b/src/routes/api/flower-flow/finalize/+server.js similarity index 60% rename from src/routes/api/flower-flow/select-option/+server.js rename to src/routes/api/flower-flow/finalize/+server.js index 5259c84..94904bc 100644 --- a/src/routes/api/flower-flow/select-option/+server.js +++ b/src/routes/api/flower-flow/finalize/+server.js @@ -3,41 +3,29 @@ import { buildFloristNote } from '$lib/server/gemini/text.js'; import { isGeminiConfigured } from '$lib/server/gemini/client.js'; import { json, readJsonBody, toErrorResponse } from '$lib/server/http.js'; -/** @type {import('$lib/server/flowerFlow/jobStore.js').BouquetSize[]} */ -const VALID_SIZES = ['S', 'M', 'L']; - /** @type {import('./$types').RequestHandler} */ export async function POST({ request }) { try { const body = await readJsonBody(request); const jobId = typeof body.jobId === 'string' ? body.jobId : ''; - const size = typeof body.size === 'string' ? body.size : ''; if (!jobId) { return json({ error: 'jobId is required' }, 400); } - if (!VALID_SIZES.includes(size)) { - return json({ error: 'size must be one of S, M, or L' }, 400); - } - const job = await requireJob(jobId); - const selectedImage = job.images?.[/** @type {'S'|'M'|'L'} */ (size)]; + const selectedImage = job.images?.primary; if (!selectedImage) { - return json({ error: 'selected size image is missing. Run generate-images first.' }, 400); + return json({ error: 'generated image is missing. Run generate-images first.' }, 400); } const floristNote = job.recipe ? await buildFloristNote(job.recipe) : null; - await updateJob(jobId, { - selectedSize: /** @type {'S'|'M'|'L'} */ (size), - floristNote - }); + await updateJob(jobId, { floristNote }); return json({ jobId, - selectedSize: size, selectedImage, floristNote, recipe: job.recipe, diff --git a/src/routes/api/flower-flow/generate-images/+server.js b/src/routes/api/flower-flow/generate-images/+server.js index 7726748..bb344ed 100644 --- a/src/routes/api/flower-flow/generate-images/+server.js +++ b/src/routes/api/flower-flow/generate-images/+server.js @@ -1,7 +1,7 @@ import { requireJob, updateJob } from '$lib/server/flowerFlow/jobStore.js'; import { buildImagePrompt } from '$lib/server/gemini/text.js'; import { - generateAllSizeImages, + generateBouquetImage, getImageProvider, isImageGenerationConfigured } from '$lib/server/gemini/image.js'; @@ -19,7 +19,7 @@ function isMockImage(image) { * Dedupe concurrent generation for the same job. Without this, a remount or * double-navigation can fire several generate-images requests at once, which is * a common way to *cause* the very rate limits this page then keeps retrying. - * @type {Map> }>>} + * @type {Map>} */ const inFlight = new Map(); @@ -30,8 +30,8 @@ function generateForJob(jobId, recipe) { const task = (async () => { const imagePrompt = await buildImagePrompt(recipe); - const generatedImages = await generateAllSizeImages(imagePrompt); - const images = await uploadGeneratedImages(jobId, generatedImages); + const generatedImage = await generateBouquetImage(imagePrompt); + const images = await uploadGeneratedImages(jobId, generatedImage, `initial-${Date.now()}`); await updateJob(jobId, { imagePrompt, images }); return { imagePrompt, images }; })().finally(() => { @@ -58,7 +58,7 @@ export async function POST({ request }) { return json({ error: 'recipe is missing. Run recipe first.', code: 'bad_request' }, 400); } - if (job.images?.M && !isMockImage(job.images.M)) { + if (job.images?.primary && !isMockImage(job.images.primary)) { console.log( `[flower-flow] generate-images job=${jobId.slice(0, 8)} cached (already generated)` ); diff --git a/src/routes/api/flower-flow/job/+server.js b/src/routes/api/flower-flow/job/+server.js index 6decd00..682b6ad 100644 --- a/src/routes/api/flower-flow/job/+server.js +++ b/src/routes/api/flower-flow/job/+server.js @@ -20,7 +20,6 @@ export async function GET({ url }) { recipe: job.recipe, imagePrompt: job.imagePrompt, images: job.images, - selectedSize: job.selectedSize, floristNote: job.floristNote, mock: !isGeminiConfigured() }); diff --git a/src/routes/edit/+page.svelte b/src/routes/edit/+page.svelte new file mode 100644 index 0000000..1a456ad --- /dev/null +++ b/src/routes/edit/+page.svelte @@ -0,0 +1,426 @@ + + +
+
+ +
+
+
+
+ {#if loading} +
+ {:else if imageSrc} + Generated bouquet + {:else} +
+ {/if} +
+ +
+

{title}

+

{description}

+
+
+
+ +
+
+
+

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} +
+
+ + {#each editHistory as edit (edit.id)} +
+
+
+

{edit.instruction.prompt}

+ {#if edit.instruction.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} +
+

Result

+
+
+ {/each} +
+ +
+ + +
+ +
+ {#each QUICK_PROMPTS as quickPrompt (quickPrompt)} + + {/each} +
+ +
+ + +
+

+ {#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} +
+
+ +
+ {#if error} +

+ {error} +

+ {:else if editing} +

+ Editing bouquet image... +

+ {/if} + +
+ + +
+
+
+
+
+
diff --git a/src/routes/generating/+page.svelte b/src/routes/generating/+page.svelte index 49d4ede..75790fc 100644 --- a/src/routes/generating/+page.svelte +++ b/src/routes/generating/+page.svelte @@ -109,7 +109,7 @@ mock: imageResult.mock }); - await goto(resolve('/options')); + await goto(resolve('/edit')); } catch (err) { if (!active) return; diff --git a/src/routes/map/+page.svelte b/src/routes/map/+page.svelte index 1bcae1b..eff4b5a 100644 --- a/src/routes/map/+page.svelte +++ b/src/routes/map/+page.svelte @@ -29,11 +29,8 @@ const artworkTitle = $derived(selectedShopId ? 'Ready to order' : 'Your bouquet'); - const artworkDescription = $derived( - floristNote || 'Your selected bouquet design.' - ); + const artworkDescription = $derived(floristNote || 'Your selected bouquet design.'); - // options Continue(최종 사이즈 확정) 이후 selectedSize가 있을 때만 이미지 표시 const bouquetImageSrc = $derived(selectedImage ? toDataUrl(selectedImage) : null); /** @@ -78,8 +75,7 @@ try { const job = await fetchJob(jobId); floristNote = job.floristNote ?? ''; - // options에서 S/M/L 선택 후 Continue 한 경우에만 job.selectedSize 존재 - selectedImage = job.selectedSize ? (job.images?.[job.selectedSize] ?? null) : null; + selectedImage = job.images?.primary ?? null; const order = buildFloristOrderMessage({ userInput: { ...sessionUserInput, ...job.userInput }, diff --git a/src/routes/message/+page.svelte b/src/routes/message/+page.svelte index 774607a..fb308c8 100644 --- a/src/routes/message/+page.svelte +++ b/src/routes/message/+page.svelte @@ -88,7 +88,7 @@ return; } - await goto(resolve('/options')); + await goto(resolve('/edit')); } @@ -121,9 +121,9 @@ disabled={skipping} onclick={skipWithDummyImages} class="w-full rounded border border-dashed border-subtle/60 px-4 py-2.5 text-xs text-muted hover:border-subtle hover:text-ink disabled:opacity-50" - title="AI 생성 없이 더미 이미지로 options로 이동 (개발용)" + title="AI 생성 없이 더미 이미지로 edit로 이동 (개발용)" > - {skipping ? 'Skipping…' : 'Dev: Skip to options (dummy images)'} + {skipping ? 'Skipping…' : 'Dev: Skip to edit (dummy images)'} {/if} - - - - diff --git a/src/routes/result/+page.svelte b/src/routes/result/+page.svelte index daaa933..46717a5 100644 --- a/src/routes/result/+page.svelte +++ b/src/routes/result/+page.svelte @@ -11,7 +11,6 @@ let selectedImage = $state(null); let floristNote = $state(''); let recipe = $state(null); - let selectedSize = $state(''); let mock = $state(false); onMount(async () => { @@ -24,10 +23,9 @@ try { const job = await fetchJob(jobId); - selectedImage = job.selectedSize ? job.images?.[job.selectedSize] : null; + selectedImage = job.images?.primary ?? null; floristNote = job.floristNote ?? ''; recipe = job.recipe ?? null; - selectedSize = job.selectedSize ?? ''; mock = Boolean(job.mock); loading = false; } catch (err) { @@ -65,11 +63,6 @@
-
-

Selected size

-

{selectedSize || 'Not selected'}

-
-

Florist note

{floristNote}

diff --git a/src/routes/upload/+page.svelte b/src/routes/upload/+page.svelte index 1469887..bbfd9ad 100644 --- a/src/routes/upload/+page.svelte +++ b/src/routes/upload/+page.svelte @@ -60,7 +60,6 @@ imagePrompt: null, images: null, imagesJobId: null, - selectedSize: null, floristNote: null, mock: result.mock });