feat: remove options page and add edit page

This commit is contained in:
Chaewon Lee
2026-06-12 17:24:26 +09:00
committed by GitHub
parent 5d65a5ffae
commit e7c690ac13
26 changed files with 622 additions and 351 deletions

19
src/lib/assets/flower.svg Normal file
View File

@@ -0,0 +1,19 @@
<svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<path fill="#000000" d="
M32 6
C36 6 38.5 10.5 38 15.5
C42.5 12 48 12.5 50.5 16
C53 19.5 50.5 24.5 46 26.5
C51 27.5 54.5 31.5 53.5 36
C52.5 40.5 47 42 42.5 40
C45.5 44 45 49.5 41 52
C37 54.5 32.5 51.5 31 47
C29.5 51.5 25 54.5 21 52
C17 49.5 16.5 44 19.5 40
C15 42 9.5 40.5 8.5 36
C7.5 31.5 11 27.5 16 26.5
C11.5 24.5 9 19.5 11.5 16
C14 12.5 19.5 12 24 15.5
C23.5 10.5 28 6 32 6
Z"/>
</svg>

After

Width:  |  Height:  |  Size: 540 B

View File

@@ -1,5 +1,5 @@
<script>
let { budget = $bindable(50_000) } = $props();
let { budget = $bindable(50000) } = $props();
const min = 10_000;
const max = 150_000;

View File

@@ -6,7 +6,7 @@
who = $bindable(null),
whatFor = $bindable(null),
style = $bindable(null),
budget = $bindable(50_000)
budget = $bindable(50000)
} = $props();
const hasAnySelection = $derived(who !== null || whatFor !== null || style !== null);

View File

@@ -1,40 +0,0 @@
<script>
import { toDataUrl } from '$lib/flowerFlow/api.js';
let {
size,
image = null,
selectedSize = $bindable(null)
} = $props();
const selected = $derived(selectedSize === size);
const dimmed = $derived(selectedSize !== null && selectedSize !== size);
function handleClick() {
selectedSize = size;
}
</script>
<!-- 세로(4:5) 꽃다발 미리보기 — 클릭 시 해당 사이즈 선택 -->
<button
type="button"
onclick={handleClick}
aria-pressed={selected}
aria-label="Select size {size}"
class={[
'relative min-h-0 w-full cursor-pointer overflow-hidden bg-track text-left transition-all duration-300',
selected ? 'scale-[1.02] opacity-100 ring-2 ring-pill ring-inset' : '',
dimmed ? 'opacity-25' : 'opacity-100',
!dimmed && !selected ? 'hover:opacity-90' : ''
]}
>
<div class="aspect-4/5 w-full">
{#if image}
<img
src={toDataUrl(image)}
alt="Bouquet option {size}"
class="pointer-events-none h-full w-full object-cover"
/>
{/if}
</div>
</button>

View File

@@ -1,49 +0,0 @@
<script>
import OptionGroup from '$lib/components/ui/create/OptionGroup.svelte';
import OptionCard from './OptionCard.svelte';
let { images = {}, loading = false, selectedSize = $bindable(null) } = $props();
const sizeOptions = ['S', 'M', 'L'];
</script>
<div class="flex min-h-0 flex-1 flex-col justify-center px-6 py-10 md:px-12 lg:px-16 lg:py-12">
{#if loading}
<div class="flex flex-1 items-center justify-center">
<p class="text-sm text-muted">Loading options...</p>
</div>
{:else}
<!-- options-reorganized.png: S/M/L 세로 이미지 가로 3열 -->
<div class="options-grid mb-10 min-h-0 w-full lg:mb-14">
{#each sizeOptions as size (size)}
<OptionCard {size} image={images[size]} bind:selectedSize />
{/each}
</div>
<OptionGroup
label="Choose the size of the bouquet"
options={sizeOptions}
selected={selectedSize}
onchange={(v) => (selectedSize = v)}
/>
{/if}
</div>
<style>
.options-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
flex: 1;
min-height: 0;
align-content: center;
max-height: min(70vh, 36rem);
}
@media (min-width: 1024px) {
.options-grid {
gap: 1rem;
max-height: min(65vh, 40rem);
}
}
</style>

View File

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

View File

@@ -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<Record<BouquetSize, GeneratedImage>>} */
export function loadDevBouquetImages() {
return {
S: loadFixtureImage('S'),
M: loadFixtureImage('M'),
L: loadFixtureImage('L')
};
return mockGeneratedImage();
}

View File

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

View File

@@ -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<GeneratedImage>}
*/
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<Record<BouquetSize, GeneratedImage>>} images
* @returns {Promise<Partial<Record<BouquetSize, GeneratedImage>>>}
* @param {GeneratedImage} image
* @param {string} [revision]
* @returns {Promise<{ primary: GeneratedImage }>}
*/
export async function uploadGeneratedImages(jobId, images) {
/** @type {Partial<Record<BouquetSize, GeneratedImage>>} */
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)
};
}

View File

@@ -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<Record<BouquetSize, GeneratedImage>>} 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
};

View File

@@ -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<BouquetSize, string>} */
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<GeneratedImage>}
*/
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<Partial<Record<BouquetSize, GeneratedImage>>>}
*/
export async function generateAllSizeImages(basePrompt) {
/** @type {Partial<Record<BouquetSize, GeneratedImage>>} */
const images = {};
for (const size of ALL_SIZES) {
images[size] = await generateBouquetImage(basePrompt, size);
}
return images;
}

View File

@@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" width="768" height="1024" viewBox="0 0 768 1024">
<rect width="768" height="1024" fill="#f7f3ef"/>
<text x="50%" y="48%" text-anchor="middle" font-size="42" fill="#6b5b53" font-family="Arial">Mock Bouquet ${size}</text>
<text x="50%" y="48%" text-anchor="middle" font-size="42" fill="#6b5b53" font-family="Arial">Mock ${label}</text>
<text x="50%" y="54%" text-anchor="middle" font-size="22" fill="#9a8d84" font-family="Arial">Set GEMINI_API_KEY for real images</text>
</svg>`;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string, Promise<{ imagePrompt: string, images: Partial<Record<import('$lib/server/flowerFlow/jobStore.js').BouquetSize, import('$lib/server/flowerFlow/jobStore.js').GeneratedImage>> }>>}
* @type {Map<string, Promise<{ imagePrompt: string, images: { primary: import('$lib/server/flowerFlow/jobStore.js').GeneratedImage } }>>}
*/
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)`
);

View File

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

View File

@@ -0,0 +1,426 @@
<script>
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import Header from '$lib/components/ui/Header.svelte';
import { editImages, fetchJob, finalizeJob, toDataUrl } from '$lib/flowerFlow/api.js';
import { getFlowString, saveFlow } from '$lib/flowerFlow/session.js';
const jobId = getFlowString('jobId');
const QUICK_PROMPTS = [
'Make it more romantic',
'Use warmer colors',
'Add more volume',
'Keep the same flowers'
];
let loading = $state(true);
let error = $state('');
let prompt = $state('');
let mode = $state('whole');
let drawing = $state(false);
let selectionPoints = $state([]);
let initialImage = $state(null);
let generatedImage = $state(null);
let recipe = $state(null);
let editing = $state(false);
let continuing = $state(false);
let editHistory = $state([]);
const imageSrc = $derived(toDataUrl(generatedImage));
const title = $derived(recipe?.concept ?? 'Generated bouquet');
const description = $derived(
recipe?.mainFlowers?.length
? `${recipe.mainFlowers.join(', ')} · ${recipe.wrapping ?? 'Custom wrap'}`
: 'Review and refine your bouquet before choosing a size.'
);
const selectionPolyline = $derived(
selectionPoints.map((point) => `${point.x},${point.y}`).join(' ')
);
const canSaveAreaPrompt = $derived(mode !== 'area' || selectionPoints.length > 2);
const latestEditId = $derived(editHistory[editHistory.length - 1]?.id ?? '');
/**
* @param {PointerEvent} event
*/
function getPoint(event) {
const rect = /** @type {SVGElement} */ (event.currentTarget).getBoundingClientRect();
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))
};
}
/** @param {PointerEvent} event */
function startDrawing(event) {
if (mode !== 'area') return;
event.preventDefault();
/** @type {SVGElement} */ (event.currentTarget).setPointerCapture(event.pointerId);
drawing = true;
selectionPoints = [getPoint(event)];
}
/** @param {PointerEvent} event */
function draw(event) {
if (!drawing || mode !== 'area') return;
event.preventDefault();
selectionPoints = [...selectionPoints, getPoint(event)];
}
function stopDrawing() {
drawing = false;
}
function clearSelection() {
selectionPoints = [];
}
/** @param {string} text */
function addQuickPrompt(text) {
prompt = prompt ? `${prompt}, ${text.toLowerCase()}` : text;
}
function getEditInstruction() {
if (!prompt.trim()) {
error = 'Tell us what to change first.';
return null;
}
if (!canSaveAreaPrompt) {
error = 'Draw the area you want to change first.';
return null;
}
return {
mode,
prompt: prompt.trim(),
selection: mode === 'area' ? selectionPoints : []
};
}
async function applyEdit() {
const instruction = getEditInstruction();
if (!instruction || !jobId) return;
editing = true;
error = '';
try {
const result = await editImages(jobId, instruction);
const afterImage = result.images?.primary ?? null;
generatedImage = afterImage;
editHistory = [
...editHistory,
{
id: `${Date.now()}-${editHistory.length}`,
instruction,
afterImage
}
];
prompt = '';
selectionPoints = [];
saveFlow({
editInstruction: instruction,
imagePrompt: result.imagePrompt,
mock: result.mock
});
} catch (err) {
error = err instanceof Error ? err.message : 'Edit failed';
} finally {
editing = false;
}
}
async function continueToResult() {
if (!jobId) {
await goto(resolve('/create'));
return;
}
continuing = true;
error = '';
try {
await finalizeJob(jobId);
await goto(resolve('/result'));
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to continue to result';
continuing = false;
}
}
onMount(async () => {
if (!jobId) {
await goto(resolve('/create'));
return;
}
try {
const job = await fetchJob(jobId);
if (!job.images?.primary) {
await goto(resolve('/generating'));
return;
}
initialImage = job.images.primary;
generatedImage = job.images.primary;
recipe = job.recipe ?? null;
loading = false;
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load generated bouquet';
loading = false;
}
});
</script>
<div
class="flex h-dvh flex-col overflow-x-hidden bg-surface text-ink lg:h-screen lg:overflow-hidden"
>
<Header step={5} total={7} />
<main class="flex min-h-0 flex-1 flex-col lg:flex-row">
<section
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"
>
<div class="mx-auto flex min-h-0 w-full max-w-100 flex-1 flex-col justify-center gap-6">
<div class="overflow-hidden bg-track shadow-sm ring-1 ring-black/5">
{#if loading}
<div class="aspect-[4/5] w-full animate-pulse bg-placeholder"></div>
{:else if imageSrc}
<img src={imageSrc} alt="Generated bouquet" class="aspect-[4/5] w-full object-cover" />
{:else}
<div class="aspect-[4/5] w-full bg-placeholder"></div>
{/if}
</div>
<div class="border border-line-strong bg-surface px-5 py-4">
<h1 class="text-sm">{title}</h1>
<p class="mt-2 text-xs leading-relaxed text-muted">{description}</p>
</div>
</div>
</section>
<section class="relative flex min-h-0 flex-1 flex-col overflow-hidden">
<div class="mx-auto flex min-h-0 w-full max-w-2xl flex-1 flex-col gap-4 px-6 py-5 lg:py-6">
<div class="shrink-0">
<p class="text-xs tracking-[0.2em] text-muted uppercase">Edit bouquet</p>
<h2 class="mt-1 text-lg">Tell us how you want to refine it.</h2>
</div>
<div class="min-h-0 flex-1 space-y-4 overflow-y-auto pr-1">
<div class="space-y-2">
<p class="text-xs text-muted">Generated image</p>
<div class="relative w-[42%] overflow-hidden bg-track ring-1 ring-black/5">
{#if initialImage}
<img
src={toDataUrl(initialImage)}
alt="Generated bouquet"
class={[
'aspect-[4/5] w-full object-contain',
mode === 'area' && editHistory.length === 0 ? 'opacity-75' : ''
]}
draggable="false"
/>
{:else if imageSrc}
<img
src={imageSrc}
alt="Generated bouquet"
class={[
'aspect-[4/5] w-full object-contain',
mode === 'area' && editHistory.length === 0 ? 'opacity-75' : ''
]}
draggable="false"
/>
{:else}
<div class="aspect-[4/5] w-full bg-placeholder"></div>
{/if}
{#if mode === 'area' && editHistory.length === 0}
<svg
class="absolute inset-0 h-full w-full touch-none"
viewBox="0 0 100 100"
preserveAspectRatio="none"
role="application"
aria-label="Draw an area to edit"
onpointerdown={startDrawing}
onpointermove={draw}
onpointerup={stopDrawing}
onpointercancel={stopDrawing}
onpointerleave={stopDrawing}
>
{#if selectionPoints.length > 1}
<polyline
points={selectionPolyline}
fill="rgba(255,255,255,0.18)"
stroke="white"
stroke-width="0.8"
stroke-dasharray="1.4 1.2"
vector-effect="non-scaling-stroke"
/>
{#each selectionPoints.filter((_, index) => index % 8 === 0) as point, index (index)}
<circle cx={point.x} cy={point.y} r="0.8" fill="white" />
{/each}
{/if}
</svg>
{/if}
</div>
</div>
{#each editHistory as edit (edit.id)}
<div class="space-y-4">
<div class="flex justify-end">
<div
class="max-w-[46%] rounded-3xl bg-pill px-4 py-3 text-sm leading-relaxed text-surface"
>
<p>{edit.instruction.prompt}</p>
{#if edit.instruction.mode === 'area'}
<p class="mt-2 text-xs opacity-70">Selected area only</p>
{/if}
</div>
</div>
<div class="space-y-2">
<div class="relative w-[42%] overflow-hidden bg-track ring-1 ring-black/5">
{#if edit.afterImage}
<img
src={toDataUrl(edit.afterImage)}
alt="Edited bouquet result"
class={[
'aspect-[4/5] w-full object-contain',
mode === 'area' && edit.id === latestEditId ? 'opacity-75' : ''
]}
draggable="false"
/>
{/if}
{#if mode === 'area' && edit.id === latestEditId}
<svg
class="absolute inset-0 h-full w-full touch-none"
viewBox="0 0 100 100"
preserveAspectRatio="none"
role="application"
aria-label="Draw an area to edit"
onpointerdown={startDrawing}
onpointermove={draw}
onpointerup={stopDrawing}
onpointercancel={stopDrawing}
onpointerleave={stopDrawing}
>
{#if selectionPoints.length > 1}
<polyline
points={selectionPolyline}
fill="rgba(255,255,255,0.18)"
stroke="white"
stroke-width="0.8"
stroke-dasharray="1.4 1.2"
vector-effect="non-scaling-stroke"
/>
{#each selectionPoints.filter((_, index) => index % 8 === 0) as point, index (index)}
<circle cx={point.x} cy={point.y} r="0.8" fill="white" />
{/each}
{/if}
</svg>
{/if}
</div>
<p class="text-xs text-muted">Result</p>
</div>
</div>
{/each}
</div>
<div class="flex shrink-0 rounded-full bg-track p-1 ring-1 ring-black/5">
<button
type="button"
onclick={() => (mode = 'whole')}
class={[
'flex-1 rounded-full px-4 py-2 text-sm transition-colors',
mode === 'whole' ? 'bg-pill text-surface' : 'text-muted hover:text-ink'
]}
>
Whole image
</button>
<button
type="button"
onclick={() => (mode = 'area')}
class={[
'flex-1 rounded-full px-4 py-2 text-sm transition-colors',
mode === 'area' ? 'bg-pill text-surface' : 'text-muted hover:text-ink'
]}
>
Select area
</button>
</div>
<div class="flex shrink-0 flex-wrap gap-2">
{#each QUICK_PROMPTS as quickPrompt (quickPrompt)}
<button
type="button"
onclick={() => addQuickPrompt(quickPrompt)}
class="rounded-full bg-placeholder px-3 py-1 text-xs text-ink hover:bg-line-strong"
>
{quickPrompt}
</button>
{/each}
</div>
<div class="shrink-0 space-y-2">
<textarea
bind:value={prompt}
rows="2"
placeholder={mode === 'area'
? 'Tell me how to change the selected area...'
: 'Tell me how you would like to change your bouquet...'}
class="w-full resize-none rounded-[2rem] border border-pill bg-surface px-6 py-3 text-sm outline-none placeholder:text-muted"
></textarea>
<div class="flex flex-wrap items-center justify-between gap-3 text-xs text-muted">
<p>
{#if mode === 'area'}
Draw over the bouquet, then describe only that selected area.
{:else}
Prompt applies to the whole generated bouquet.
{/if}
</p>
{#if selectionPoints.length > 0}
<button type="button" class="underline hover:text-ink" onclick={clearSelection}>
Clear selection
</button>
{/if}
</div>
</div>
<div class="shrink-0 space-y-2">
{#if error}
<p class="rounded bg-surface/95 px-3 py-2 text-sm text-red-600 ring-1 ring-black/5">
{error}
</p>
{:else if editing}
<p class="rounded bg-surface/95 px-3 py-2 text-sm text-muted ring-1 ring-black/5">
Editing bouquet image...
</p>
{/if}
<div class="grid grid-cols-2 gap-2">
<button
type="button"
class="border border-pill px-4 py-3 text-sm text-ink disabled:opacity-50"
disabled={!prompt.trim() || editing || continuing}
onclick={applyEdit}
>
{editing ? 'Applying...' : 'Apply edit'}
</button>
<button
type="button"
class="bg-pill px-4 py-3 text-sm text-surface"
disabled={editing || continuing}
onclick={continueToResult}
>
{continuing ? 'Preparing result...' : 'Continue to result'}
</button>
</div>
</div>
</div>
</section>
</main>
</div>

View File

@@ -109,7 +109,7 @@
mock: imageResult.mock
});
await goto(resolve('/options'));
await goto(resolve('/edit'));
} catch (err) {
if (!active) return;

View File

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

View File

@@ -88,7 +88,7 @@
return;
}
await goto(resolve('/options'));
await goto(resolve('/edit'));
}
</script>
@@ -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)'}
</button>
{/if}
<button

View File

@@ -1,104 +0,0 @@
<script>
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import Header from '$lib/components/ui/Header.svelte';
import Artwork from '$lib/components/ui/Artwork/Artwork.svelte';
import OptionsPanel from '$lib/components/ui/options/OptionsPanel.svelte';
import { fetchJob, selectOption } from '$lib/flowerFlow/api.js';
import { getFlowString } from '$lib/flowerFlow/session.js';
const jobId = getFlowString('jobId');
let images = $state({});
let recipe = $state(null);
let loading = $state(true);
let loadingSize = $state(null);
let error = $state('');
let selectedSize = $state(null);
const artworkTitle = $derived(recipe?.concept ?? 'Title');
const artworkDescription = $derived(
recipe?.mainFlowers?.length
? `${recipe.mainFlowers.join(', ')} · ${recipe.wrapping ?? 'Custom wrap'}`
: 'Description Description Description'
);
onMount(async () => {
if (!jobId) {
// 와이어프레임 확인용: job 없어도 레이아웃은 보여 줌
loading = false;
error = 'Start from /create and complete the flow to see generated bouquets.';
return;
}
try {
const job = await fetchJob(jobId);
if (!job.images?.M) {
await goto(resolve('/generating'));
return;
}
images = job.images;
recipe = job.recipe ?? null;
loading = false;
} catch {
await goto(resolve('/generating'));
}
});
async function handleContinue() {
if (!selectedSize) {
error = 'Choose a bouquet size to continue.';
return;
}
if (!jobId) {
error = 'Complete create → upload → message → generating first.';
return;
}
loadingSize = selectedSize;
error = '';
try {
await selectOption(jobId, /** @type {'S'|'M'|'L'} */ (selectedSize));
await goto(resolve('/result'));
} catch (err) {
error = err instanceof Error ? err.message : 'Selection failed';
loadingSize = null;
}
}
</script>
<div
class="flex h-dvh flex-col overflow-x-hidden bg-surface text-ink lg:h-screen lg:overflow-hidden"
>
<Header step={5} total={7} />
<main class="flex min-h-0 flex-1 flex-col lg:flex-row">
<Artwork title={artworkTitle} description={artworkDescription} />
<section class="relative flex min-h-0 flex-1 flex-col pb-[4.75rem] lg:overflow-y-auto lg:pb-0">
<OptionsPanel {images} {loading} bind:selectedSize />
<div
class="fixed right-0 bottom-0 left-0 z-20 space-y-2 px-4 pb-5 lg:absolute lg:right-8 lg:bottom-8 lg:left-auto lg:w-72 lg:px-0"
>
{#if error}
<p class="rounded bg-surface/95 px-3 py-2 text-sm text-red-600 ring-1 ring-black/5">
{error}
</p>
{/if}
<button
type="button"
disabled={!selectedSize || Boolean(loadingSize)}
onclick={handleContinue}
class="w-full bg-pill px-4 py-3 text-sm text-surface disabled:opacity-50"
>
{loadingSize ? 'Selecting...' : 'Continue to result'}
</button>
</div>
</section>
</main>
</div>

View File

@@ -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 @@
</div>
<div class="space-y-6">
<div>
<h2 class="mb-2 text-lg">Selected size</h2>
<p class="text-sm text-muted">{selectedSize || 'Not selected'}</p>
</div>
<div>
<h2 class="mb-2 text-lg">Florist note</h2>
<p class="text-sm leading-relaxed text-muted">{floristNote}</p>

View File

@@ -60,7 +60,6 @@
imagePrompt: null,
images: null,
imagesJobId: null,
selectedSize: null,
floristNote: null,
mock: result.mock
});