fix: edit bouquet photos in-place with recipe sync and area masks
* fix: edit bouquet photos in-place with recipe sync and area masks * fix: silence MapPanel state_referenced_locally warning
This commit is contained in:
@@ -25,7 +25,7 @@
|
||||
mobile: row · desktop: 꽃 슬롯 높이 고정 → 설명 카드 길이와 무관하게 Y·크기 유지
|
||||
-->
|
||||
<div
|
||||
class="mx-auto flex min-h-0 w-full max-w-100 flex-1 flex-row items-start gap-8 px-6 py-6 lg:flex-col lg:items-center lg:justify-start lg:gap-4 lg:px-6 lg:pb-8 lg:pt-[calc(50%-5rem)]"
|
||||
class="mx-auto flex min-h-0 w-full max-w-100 flex-1 flex-row items-start gap-8 px-6 pt-6 pb-8 lg:flex-col lg:items-center lg:justify-start lg:gap-4 lg:px-6 lg:pb-12 lg:pt-[calc(50%-5rem)]"
|
||||
>
|
||||
<div
|
||||
class="flex h-[11rem] shrink-0 items-end justify-center sm:h-[13rem] lg:h-[min(24rem,36vh)] lg:w-full"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import FloristOrderMessage from './FloristOrderMessage.svelte';
|
||||
import KakaoMap from './KakaoMap.svelte';
|
||||
import ShopList from './ShopList.svelte';
|
||||
@@ -19,10 +20,15 @@
|
||||
onrefresh
|
||||
} = $props();
|
||||
|
||||
let mapCenterLat = $state(initialLat);
|
||||
let mapCenterLng = $state(initialLng);
|
||||
let mapCenterLat = $state(DEFAULT_MAP_CENTER.lat);
|
||||
let mapCenterLng = $state(DEFAULT_MAP_CENTER.lng);
|
||||
let panTarget = $state(null);
|
||||
|
||||
onMount(() => {
|
||||
mapCenterLat = initialLat;
|
||||
mapCenterLng = initialLng;
|
||||
});
|
||||
|
||||
function handleCenterChange(lat, lng) {
|
||||
mapCenterLat = lat;
|
||||
mapCenterLng = lng;
|
||||
|
||||
@@ -4,3 +4,90 @@ 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.';
|
||||
|
||||
/**
|
||||
* Deterministic image prompt — recipe is the sole source of truth for flower species.
|
||||
* @param {{ mainFlowers?: string[], subFlowers?: string[], greenery?: string[], colors?: string[], wrapping?: string, shape?: string }} recipe
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatStrictBouquetImagePrompt(recipe) {
|
||||
const mains = (recipe.mainFlowers ?? []).filter(Boolean);
|
||||
const subs = (recipe.subFlowers ?? []).filter(Boolean);
|
||||
const greenery = (recipe.greenery ?? []).filter(Boolean);
|
||||
const allFlowers = [...mains, ...subs, ...greenery];
|
||||
|
||||
return [
|
||||
'Generate a realistic Korean florist bouquet product photo.',
|
||||
'',
|
||||
'STRICT RECIPE — the bouquet must contain ONLY these flowers and NO other flower species:',
|
||||
allFlowers.length > 0 ? allFlowers.map((flower) => `- ${flower}`).join('\n') : '- (none listed)',
|
||||
'',
|
||||
`Main focal blooms (each must be clearly visible): ${mains.join(', ') || 'none'}`,
|
||||
`Supporting filler/line flowers (each must appear): ${subs.join(', ') || 'none'}`,
|
||||
`Greenery (must appear): ${greenery.join(', ') || 'none'}`,
|
||||
`Colors: ${(recipe.colors ?? []).join(', ') || 'natural harmony'}`,
|
||||
`Wrapping: ${recipe.wrapping || 'neutral florist wrap'}`,
|
||||
`Shape: ${recipe.shape || 'balanced hand-tied bouquet'}`,
|
||||
'',
|
||||
'Hard constraints:',
|
||||
'- Do NOT add any flower, filler, or foliage species not listed above',
|
||||
'- EVERY species listed above MUST appear in the final image',
|
||||
'- Real cut flowers only; no fantasy colors or impossible hybrids',
|
||||
`- ${BOUQUET_IMAGE_ASPECT_PROMPT}`,
|
||||
'- White background, soft natural lighting, front-facing, orderable from a real Korean florist'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt for editing an existing bouquet photo (reference image passed separately).
|
||||
* @param {{
|
||||
* userPrompt: string,
|
||||
* mode?: string,
|
||||
* selection?: Array<{ x: number, y: number }>,
|
||||
* recipe?: { mainFlowers?: string[], subFlowers?: string[], greenery?: string[] },
|
||||
* recipeChanged?: boolean
|
||||
* }} options
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatBouquetEditPrompt(options) {
|
||||
const { userPrompt, mode, selection, recipe, recipeChanged = false } = options;
|
||||
|
||||
const lines = [
|
||||
'You are editing the attached florist bouquet photograph.',
|
||||
'This is an image edit — modify the provided photo in place. Do not generate an unrelated new bouquet from scratch.',
|
||||
'',
|
||||
`Edit request: ${userPrompt}`,
|
||||
'',
|
||||
'Preserve unless the edit request explicitly requires a change:',
|
||||
'- Camera angle, white background, soft lighting, and overall framing',
|
||||
'- Wrapping paper, ribbon, and bouquet shape',
|
||||
'- Every flower species and greenery not involved in the edit request'
|
||||
];
|
||||
|
||||
if (recipeChanged && recipe) {
|
||||
lines.push(
|
||||
'',
|
||||
'This edit changes the flower list. Update only the affected blooms; keep the rest of the arrangement intact:',
|
||||
`Main blooms: ${(recipe.mainFlowers ?? []).join(', ') || 'none'}`,
|
||||
`Filler/line: ${(recipe.subFlowers ?? []).join(', ') || 'none'}`,
|
||||
`Greenery: ${(recipe.greenery ?? []).join(', ') || 'none'}`
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === 'area' && selection && selection.length >= 3) {
|
||||
lines.push(
|
||||
'',
|
||||
'Apply the edit ONLY inside the marked region shown in the attached mask image.',
|
||||
'White area in the mask = edit zone. Black area = do not change.',
|
||||
'Leave everything outside the marked region pixel-accurate to the original photo.'
|
||||
);
|
||||
}
|
||||
|
||||
lines.push(
|
||||
'',
|
||||
`Output exactly one edited bouquet photo. ${BOUQUET_IMAGE_ASPECT_PROMPT}`,
|
||||
'No side-by-side, before/after, or duplicate bouquets.'
|
||||
);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
@@ -41,12 +41,58 @@ function matchCatalogFlower(label) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cap recipe flower lists to florist-realistic counts and demote extra mains to sub.
|
||||
* @param {{ mainFlowers?: string[], subFlowers?: string[], greenery?: string[] }} recipe
|
||||
*/
|
||||
export function normalizeRecipeLists(recipe) {
|
||||
if (!recipe) return recipe;
|
||||
|
||||
/** @type {string[]} */
|
||||
const main = [...(recipe.mainFlowers ?? [])].filter(Boolean);
|
||||
/** @type {string[]} */
|
||||
let sub = [...(recipe.subFlowers ?? [])].filter(Boolean);
|
||||
/** @type {string[]} */
|
||||
const greenery = [...(recipe.greenery ?? [])].filter(Boolean);
|
||||
|
||||
/** @param {string} label */
|
||||
const catalogId = (label) => matchCatalogFlower(label)?.id ?? null;
|
||||
|
||||
/** @param {string} label @param {string[]} list */
|
||||
const listHasLabel = (label, list) => {
|
||||
const id = catalogId(label);
|
||||
if (id == null) {
|
||||
const normalized = normalizeName(label);
|
||||
return list.some((entry) => normalizeName(entry) === normalized);
|
||||
}
|
||||
|
||||
return list.some((entry) => catalogId(entry) === id);
|
||||
};
|
||||
|
||||
while (main.length > 2) {
|
||||
const extra = main.pop();
|
||||
if (!extra || listHasLabel(extra, sub) || listHasLabel(extra, greenery)) continue;
|
||||
|
||||
if (sub.length < 4) {
|
||||
sub.unshift(extra);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...recipe,
|
||||
mainFlowers: main,
|
||||
subFlowers: sub.slice(0, 4),
|
||||
greenery: greenery.slice(0, 2)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ mainFlowers?: string[], subFlowers?: string[], greenery?: string[] } | null | undefined} recipe
|
||||
* @param {(id: number) => string} getImageSrc
|
||||
* @returns {RecipeFlowerCard[]}
|
||||
*/
|
||||
export function resolveRecipeFlowers(recipe, getImageSrc) {
|
||||
const normalized = normalizeRecipeLists(recipe ?? {});
|
||||
if (!recipe) return [];
|
||||
|
||||
/** @type {RecipeFlowerCard[]} */
|
||||
@@ -77,9 +123,9 @@ export function resolveRecipeFlowers(recipe, getImageSrc) {
|
||||
}
|
||||
};
|
||||
|
||||
addFlowers(recipe.mainFlowers, 'main');
|
||||
addFlowers(recipe.subFlowers, 'sub');
|
||||
addFlowers(recipe.greenery, 'greenery');
|
||||
addFlowers(normalized.mainFlowers, 'main');
|
||||
addFlowers(normalized.subFlowers, 'sub');
|
||||
addFlowers(normalized.greenery, 'greenery');
|
||||
|
||||
return cards;
|
||||
}
|
||||
@@ -106,6 +152,17 @@ function pickKeywords(items, limit = 2) {
|
||||
return items.filter(Boolean).slice(0, limit).join(', ');
|
||||
}
|
||||
|
||||
/** @param {string} value */
|
||||
function isHexColor(value) {
|
||||
return /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(value.trim());
|
||||
}
|
||||
|
||||
/** @param {string[] | undefined} colorPalette @param {number} [limit=2] */
|
||||
function pickMoodColors(colorPalette, limit = 2) {
|
||||
const names = (colorPalette ?? []).filter((color) => color && !isHexColor(color));
|
||||
return pickKeywords(names, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Short mood-led title for the result description card.
|
||||
* @param {{ moodKeywords?: string[], styleImpression?: string[] } | null | undefined} moodAnalysis
|
||||
@@ -153,16 +210,17 @@ function getPrimaryFlowerFromRecipe(recipe) {
|
||||
* @param {{ mainFlowers?: string[] } | null | undefined} recipe
|
||||
*/
|
||||
export function buildBouquetRationale(moodAnalysis, userInput, recipe) {
|
||||
const normalized = normalizeRecipeLists(recipe ?? {});
|
||||
const recipient = userInput?.relationship?.trim();
|
||||
const subject = recipient ? `${recipient}'s` : 'The';
|
||||
const cardMessage = extractCardMessage(userInput);
|
||||
const mainFlower = getPrimaryFlowerFromRecipe(recipe);
|
||||
const mainFlower = getPrimaryFlowerFromRecipe(normalized);
|
||||
|
||||
const mood = pickKeywords(
|
||||
[...(moodAnalysis?.moodKeywords ?? []), ...(moodAnalysis?.styleImpression ?? [])],
|
||||
2
|
||||
);
|
||||
const colors = pickKeywords(moodAnalysis?.colorPalette, 2);
|
||||
const colors = pickMoodColors(moodAnalysis?.colorPalette, 2);
|
||||
|
||||
/** @type {string[]} */
|
||||
const parts = [];
|
||||
|
||||
29
src/lib/server/flowerFlow/loadGeneratedImage.js
Normal file
29
src/lib/server/flowerFlow/loadGeneratedImage.js
Normal file
@@ -0,0 +1,29 @@
|
||||
/** @typedef {import('./jobStore.js').GeneratedImage} GeneratedImage */
|
||||
|
||||
/**
|
||||
* @param {GeneratedImage} image
|
||||
* @returns {Promise<{ base64: string, mimeType: string }>}
|
||||
*/
|
||||
export async function loadGeneratedImageBytes(image) {
|
||||
if (image.base64) {
|
||||
return {
|
||||
base64: image.base64,
|
||||
mimeType: image.mimeType || 'image/png'
|
||||
};
|
||||
}
|
||||
|
||||
if (image.url) {
|
||||
const response = await fetch(image.url);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load bouquet image for editing');
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
return {
|
||||
base64: buffer.toString('base64'),
|
||||
mimeType: response.headers.get('content-type') || image.mimeType || 'image/png'
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('Bouquet image has no url or base64 data');
|
||||
}
|
||||
228
src/lib/server/flowerFlow/selectionMask.js
Normal file
228
src/lib/server/flowerFlow/selectionMask.js
Normal file
@@ -0,0 +1,228 @@
|
||||
import zlib from 'node:zlib';
|
||||
|
||||
/** @param {Array<{ x: number, y: number }>} polygon */
|
||||
function closePolygon(polygon) {
|
||||
if (polygon.length < 3) return polygon;
|
||||
const first = polygon[0];
|
||||
const last = polygon[polygon.length - 1];
|
||||
if (first.x === last.x && first.y === last.y) return polygon;
|
||||
return [...polygon, first];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {Array<{ x: number, y: number }>} polygon
|
||||
*/
|
||||
function pointInPolygon(x, y, polygon) {
|
||||
let inside = false;
|
||||
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
||||
const xi = polygon[i].x;
|
||||
const yi = polygon[i].y;
|
||||
const xj = polygon[j].x;
|
||||
const yj = polygon[j].y;
|
||||
const intersects = yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi + Number.EPSILON) + xi;
|
||||
if (intersects) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
/** @param {Buffer} buffer */
|
||||
function readPngDimensions(buffer) {
|
||||
if (buffer.length < 24 || buffer.toString('ascii', 1, 4) !== 'PNG') {
|
||||
throw new Error('Invalid PNG image');
|
||||
}
|
||||
|
||||
return {
|
||||
width: buffer.readUInt32BE(16),
|
||||
height: buffer.readUInt32BE(20)
|
||||
};
|
||||
}
|
||||
|
||||
/** @param {Buffer} buffer */
|
||||
function readJpegDimensions(buffer) {
|
||||
let offset = 2;
|
||||
while (offset < buffer.length) {
|
||||
if (buffer[offset] !== 0xff) {
|
||||
offset += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const marker = buffer[offset + 1];
|
||||
if (marker === 0xc0 || marker === 0xc2 || marker === 0xc1) {
|
||||
return {
|
||||
height: buffer.readUInt16BE(offset + 5),
|
||||
width: buffer.readUInt16BE(offset + 7)
|
||||
};
|
||||
}
|
||||
|
||||
const segmentLength = buffer.readUInt16BE(offset + 2);
|
||||
offset += 2 + segmentLength;
|
||||
}
|
||||
|
||||
throw new Error('Could not read JPEG dimensions');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Buffer} buffer
|
||||
* @param {string} mimeType
|
||||
*/
|
||||
export function readImageDimensions(buffer, mimeType) {
|
||||
if (mimeType.includes('png') || (buffer[0] === 0x89 && buffer.toString('ascii', 1, 4) === 'PNG')) {
|
||||
return readPngDimensions(buffer);
|
||||
}
|
||||
|
||||
if (mimeType.includes('jpeg') || mimeType.includes('jpg') || (buffer[0] === 0xff && buffer[1] === 0xd8)) {
|
||||
return readJpegDimensions(buffer);
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported image type for mask: ${mimeType}`);
|
||||
}
|
||||
|
||||
/** @param {number} width @param {number} height @param {Uint8Array} rgba */
|
||||
function encodePng(width, height, rgba) {
|
||||
/** @type {number[]} */
|
||||
const rows = [];
|
||||
let stride = 0;
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
rows.push(0);
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const index = stride + x * 4;
|
||||
rows.push(rgba[index], rgba[index + 1], rgba[index + 2], rgba[index + 3]);
|
||||
}
|
||||
stride += width * 4;
|
||||
}
|
||||
|
||||
const compressed = zlib.deflateSync(Buffer.from(rows));
|
||||
|
||||
/** @type {Buffer[]} */
|
||||
const chunks = [];
|
||||
const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
|
||||
/** @param {string} type @param {Buffer} data */
|
||||
const pushChunk = (type, data) => {
|
||||
const typeBuffer = Buffer.from(type, 'ascii');
|
||||
const length = Buffer.alloc(4);
|
||||
length.writeUInt32BE(data.length);
|
||||
const crcInput = Buffer.concat([typeBuffer, data]);
|
||||
const crc = Buffer.alloc(4);
|
||||
crc.writeUInt32BE(crc32(crcInput));
|
||||
chunks.push(length, typeBuffer, data, crc);
|
||||
};
|
||||
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(width, 0);
|
||||
ihdr.writeUInt32BE(height, 4);
|
||||
ihdr[8] = 8;
|
||||
ihdr[9] = 6;
|
||||
pushChunk('IHDR', ihdr);
|
||||
pushChunk('IDAT', compressed);
|
||||
pushChunk('IEND', Buffer.alloc(0));
|
||||
|
||||
return Buffer.concat([signature, ...chunks]);
|
||||
}
|
||||
|
||||
/** @param {Buffer} data */
|
||||
function crc32(data) {
|
||||
let crc = 0xffffffff;
|
||||
for (let i = 0; i < data.length; i += 1) {
|
||||
crc ^= data[i];
|
||||
for (let bit = 0; bit < 8; bit += 1) {
|
||||
crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
|
||||
}
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAI mask: transparent pixels = edit region, opaque = preserve.
|
||||
* @param {number} width
|
||||
* @param {number} height
|
||||
* @param {Array<{ x: number, y: number }>} selection
|
||||
*/
|
||||
export function buildOpenAIEditMask(width, height, selection) {
|
||||
const polygon = closePolygon(
|
||||
selection.map((point) => ({
|
||||
x: (point.x / 100) * width,
|
||||
y: (point.y / 100) * height
|
||||
}))
|
||||
);
|
||||
|
||||
const rgba = new Uint8Array(width * height * 4);
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const index = (y * width + x) * 4;
|
||||
const inside = pointInPolygon(x + 0.5, y + 0.5, polygon);
|
||||
if (inside) {
|
||||
rgba[index] = 0;
|
||||
rgba[index + 1] = 0;
|
||||
rgba[index + 2] = 0;
|
||||
rgba[index + 3] = 0;
|
||||
} else {
|
||||
rgba[index] = 255;
|
||||
rgba[index + 1] = 255;
|
||||
rgba[index + 2] = 255;
|
||||
rgba[index + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return encodePng(width, height, rgba);
|
||||
}
|
||||
|
||||
/**
|
||||
* Visual mask for Gemini: white polygon on black = edit region.
|
||||
* @param {number} width
|
||||
* @param {number} height
|
||||
* @param {Array<{ x: number, y: number }>} selection
|
||||
*/
|
||||
export function buildGeminiEditMask(width, height, selection) {
|
||||
const polygon = closePolygon(
|
||||
selection.map((point) => ({
|
||||
x: (point.x / 100) * width,
|
||||
y: (point.y / 100) * height
|
||||
}))
|
||||
);
|
||||
|
||||
const rgba = new Uint8Array(width * height * 4);
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const index = (y * width + x) * 4;
|
||||
const inside = pointInPolygon(x + 0.5, y + 0.5, polygon);
|
||||
if (inside) {
|
||||
rgba[index] = 255;
|
||||
rgba[index + 1] = 255;
|
||||
rgba[index + 2] = 255;
|
||||
rgba[index + 3] = 255;
|
||||
} else {
|
||||
rgba[index] = 0;
|
||||
rgba[index + 1] = 0;
|
||||
rgba[index + 2] = 0;
|
||||
rgba[index + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return encodePng(width, height, rgba);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ base64: string, mimeType: string }} sourceImage
|
||||
* @param {Array<{ x: number, y: number }>} selection
|
||||
* @param {'openai' | 'gemini'} provider
|
||||
*/
|
||||
export function buildAreaEditMask(sourceImage, selection, provider) {
|
||||
const buffer = Buffer.from(sourceImage.base64, 'base64');
|
||||
const { width, height } = readImageDimensions(buffer, sourceImage.mimeType);
|
||||
const maskBuffer =
|
||||
provider === 'gemini'
|
||||
? buildGeminiEditMask(width, height, selection)
|
||||
: buildOpenAIEditMask(width, height, selection);
|
||||
|
||||
return {
|
||||
base64: maskBuffer.toString('base64'),
|
||||
mimeType: 'image/png',
|
||||
width,
|
||||
height
|
||||
};
|
||||
}
|
||||
@@ -4,7 +4,7 @@ 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';
|
||||
import { generateOpenAIImage, editOpenAIImage, isOpenAIConfigured } from '../openai/image.js';
|
||||
|
||||
export function getImageProvider() {
|
||||
const configured = env.IMAGE_PROVIDER?.trim().toLowerCase();
|
||||
@@ -21,18 +21,34 @@ export function isImageGenerationConfigured() {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('@google/generative-ai').GenerateContentResult} result
|
||||
* @returns {GeneratedImage}
|
||||
*/
|
||||
function imageFromGeminiResult(result) {
|
||||
const parts = result.response.candidates?.[0]?.content?.parts ?? [];
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.inlineData?.data) {
|
||||
return {
|
||||
mimeType: part.inlineData.mimeType || 'image/png',
|
||||
base64: part.inlineData.data
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Gemini image model did not return image data');
|
||||
}
|
||||
|
||||
/**
|
||||
* Initial bouquet generation from a text prompt (generating flow).
|
||||
* @param {string} basePrompt
|
||||
* @param {{ edit?: boolean }} [options]
|
||||
* @returns {Promise<GeneratedImage>}
|
||||
*/
|
||||
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. ${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.`;
|
||||
export async function generateBouquetImage(basePrompt) {
|
||||
const suffix = `Generate one final bouquet image. ${BOUQUET_IMAGE_ASPECT_PROMPT} The STRICT RECIPE flower list above is mandatory: include every listed species and do not add any other flowers. Keep it realistic, orderable from a real florist, front-facing, and suitable for a customer preview.`;
|
||||
const prompt = `${basePrompt}\n\n${suffix}`;
|
||||
const provider = getImageProvider();
|
||||
|
||||
// Explicit mock mode: develop the full flow without spending any image quota.
|
||||
if (provider === 'mock') {
|
||||
return mockGeneratedImage();
|
||||
}
|
||||
@@ -50,18 +66,61 @@ export async function generateBouquetImage(basePrompt, options = {}) {
|
||||
}
|
||||
|
||||
const model = getImageModel();
|
||||
|
||||
const result = await model.generateContent(prompt);
|
||||
const parts = result.response.candidates?.[0]?.content?.parts ?? [];
|
||||
return imageFromGeminiResult(result);
|
||||
}
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.inlineData?.data) {
|
||||
return {
|
||||
mimeType: part.inlineData.mimeType || 'image/png',
|
||||
base64: part.inlineData.data
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Edit an existing bouquet photo using the source image as reference.
|
||||
* @param {{ base64: string, mimeType: string }} sourceImage
|
||||
* @param {string} editPrompt
|
||||
* @param {{ mask?: { base64: string, mimeType: string } | null }} [options]
|
||||
* @returns {Promise<GeneratedImage>}
|
||||
*/
|
||||
export async function editBouquetImage(sourceImage, editPrompt, options = {}) {
|
||||
const provider = getImageProvider();
|
||||
const mask = options.mask ?? null;
|
||||
|
||||
if (provider === 'mock' || sourceImage.mimeType === 'image/svg+xml') {
|
||||
return mockGeneratedImage('Edited bouquet');
|
||||
}
|
||||
|
||||
throw new Error('Gemini image model did not return image data');
|
||||
if (provider === 'openai') {
|
||||
if (!isOpenAIConfigured()) {
|
||||
return mockGeneratedImage('Edited bouquet');
|
||||
}
|
||||
|
||||
return editOpenAIImage(editPrompt, sourceImage, mask);
|
||||
}
|
||||
|
||||
if (!isGeminiConfigured()) {
|
||||
return mockGeneratedImage('Edited bouquet');
|
||||
}
|
||||
|
||||
const model = getImageModel();
|
||||
/** @type {import('@google/generative-ai').Part[]} */
|
||||
const parts = [{ text: editPrompt }, {
|
||||
inlineData: {
|
||||
data: sourceImage.base64,
|
||||
mimeType: sourceImage.mimeType
|
||||
}
|
||||
}];
|
||||
|
||||
if (mask) {
|
||||
parts.push(
|
||||
{
|
||||
text: 'This mask marks the edit region. Modify the bouquet photo only where the mask is white. Keep black areas unchanged.'
|
||||
},
|
||||
{
|
||||
inlineData: {
|
||||
data: mask.base64,
|
||||
mimeType: mask.mimeType
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const result = await model.generateContent(parts);
|
||||
|
||||
return imageFromGeminiResult(result);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/** @typedef {import('../flowerFlow/jobStore.js').MoodAnalysis} MoodAnalysis */
|
||||
/** @typedef {import('../flowerFlow/jobStore.js').BouquetRecipe} BouquetRecipe */
|
||||
|
||||
import { formatStrictBouquetImagePrompt } from '../../flowerFlow/bouquetImageFormat.js';
|
||||
|
||||
/** @returns {MoodAnalysis} */
|
||||
export function mockMoodAnalysis() {
|
||||
return {
|
||||
@@ -56,15 +58,7 @@ export function mockRecipe(userInput = {}) {
|
||||
|
||||
/** @param {BouquetRecipe} recipe */
|
||||
export function mockImagePrompt(recipe) {
|
||||
return [
|
||||
'Generate a realistic florist-style bouquet image.',
|
||||
'Use real flowers only.',
|
||||
`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.',
|
||||
'Vertical portrait composition with a 3:4 aspect ratio (width:height). Frame the full bouquet without cropping.'
|
||||
].join(' ');
|
||||
return formatStrictBouquetImagePrompt(recipe);
|
||||
}
|
||||
|
||||
/** @param {string} [label] */
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
/** @typedef {import('../flowerFlow/jobStore.js').UserInput} UserInput */
|
||||
|
||||
import { matchFlowersFromMood } from '../flowerFlow/flowerDB.js';
|
||||
import { BOUQUET_IMAGE_ASPECT_PROMPT } from '../../flowerFlow/bouquetImageFormat.js';
|
||||
import { formatStrictBouquetImagePrompt } from '../../flowerFlow/bouquetImageFormat.js';
|
||||
import { normalizeRecipeLists } from '../../flowerFlow/resolveRecipeFlowers.js';
|
||||
import { getTextModel, isGeminiConfigured, parseJsonFromText } from './client.js';
|
||||
import { mockRecipe } from './mock.js';
|
||||
import { mockApplyRecipeEdit, mockRecipe } from './mock.js';
|
||||
|
||||
/**
|
||||
* @param {MoodAnalysis} mood
|
||||
@@ -19,7 +20,7 @@ export async function buildBouquetRecipe(mood, userInput = {}) {
|
||||
: 'around ₩50,000';
|
||||
|
||||
if (!isGeminiConfigured()) {
|
||||
return mockRecipe(userInput);
|
||||
return normalizeRecipeLists(mockRecipe(userInput));
|
||||
}
|
||||
|
||||
const model = getTextModel();
|
||||
@@ -67,7 +68,7 @@ Rules:
|
||||
- Budget should be ${budget}.`;
|
||||
|
||||
const result = await model.generateContent(prompt);
|
||||
return /** @type {BouquetRecipe} */ (parseJsonFromText(result.response.text()));
|
||||
return normalizeRecipeLists(/** @type {BouquetRecipe} */ (parseJsonFromText(result.response.text())));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,29 +76,7 @@ Rules:
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
export async function buildImagePrompt(recipe) {
|
||||
if (!isGeminiConfigured()) {
|
||||
const { mockImagePrompt } = await import('./mock.js');
|
||||
return mockImagePrompt(recipe);
|
||||
}
|
||||
|
||||
const model = getTextModel();
|
||||
const prompt = `Write one detailed image generation prompt for a realistic florist bouquet.
|
||||
Use this recipe:
|
||||
${JSON.stringify(recipe, null, 2)}
|
||||
|
||||
Rules:
|
||||
- Real flowers only — use the exact flower names from the recipe
|
||||
- mainFlowers are the focal blooms; subFlowers add volume and line; greenery frames the bouquet
|
||||
- No fantasy colors or surreal shapes
|
||||
- White background, soft natural lighting
|
||||
- Korean florist style
|
||||
- Describe bouquet composition only (flower types, colors, wrapping, mood)
|
||||
- ${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);
|
||||
return result.response.text().trim();
|
||||
return formatStrictBouquetImagePrompt(recipe);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -131,8 +110,7 @@ Return plain text only.`;
|
||||
*/
|
||||
export async function applyRecipeEdit(recipe, editPrompt) {
|
||||
if (!isGeminiConfigured()) {
|
||||
const { mockApplyRecipeEdit } = await import('./mock.js');
|
||||
return mockApplyRecipeEdit(recipe, editPrompt);
|
||||
return normalizeRecipeLists(mockApplyRecipeEdit(recipe, editPrompt));
|
||||
}
|
||||
|
||||
const model = getTextModel();
|
||||
@@ -160,8 +138,11 @@ Return JSON only with the same schema:
|
||||
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.`;
|
||||
- If the edit changes flower types (swap, add, remove, or replace), update mainFlowers, subFlowers, and/or greenery so the recipe matches exactly.
|
||||
- Flower swaps (e.g. "change tulip to rose") must update the matching list entry; new flowers must use standard catalog names.
|
||||
- mainFlowers: 1-2 items. subFlowers: 1-4 items. greenery: 1-2 items.
|
||||
- The updated recipe is the sole source of truth for the next bouquet image — every listed flower must be included in the image prompt.`;
|
||||
|
||||
const result = await model.generateContent(prompt);
|
||||
return /** @type {BouquetRecipe} */ (parseJsonFromText(result.response.text()));
|
||||
return normalizeRecipeLists(/** @type {BouquetRecipe} */ (parseJsonFromText(result.response.text())));
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ Return JSON only with this shape:
|
||||
"energyLevel": "low" | "medium" | "high"
|
||||
}
|
||||
|
||||
Use plain color names only (e.g. ivory, navy, blush). Do not use hex codes or RGB values.
|
||||
|
||||
User context:
|
||||
- relationship: ${userInput.relationship ?? 'unknown'}
|
||||
- occasion: ${userInput.occasion ?? 'unknown'}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { env } from '$env/dynamic/private';
|
||||
import OpenAI from 'openai';
|
||||
import OpenAI, { toFile } from 'openai';
|
||||
|
||||
let client = null;
|
||||
|
||||
@@ -52,3 +52,53 @@ export async function generateOpenAIImage(prompt) {
|
||||
|
||||
throw new Error('OpenAI image model did not return image data');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} prompt
|
||||
* @param {{ base64: string, mimeType: string }} sourceImage
|
||||
* @param {{ base64: string, mimeType: string } | null} [mask]
|
||||
* @returns {Promise<import('../flowerFlow/jobStore.js').GeneratedImage>}
|
||||
*/
|
||||
export async function editOpenAIImage(prompt, sourceImage, mask = null) {
|
||||
const buffer = Buffer.from(sourceImage.base64, 'base64');
|
||||
const imageFile = await toFile(buffer, 'bouquet.png', { type: sourceImage.mimeType });
|
||||
|
||||
/** @type {import('openai').default.Images.ImageEditParams} */
|
||||
const params = {
|
||||
model: env.OPENAI_IMAGE_MODEL || 'gpt-image-1',
|
||||
image: imageFile,
|
||||
prompt,
|
||||
size: env.OPENAI_IMAGE_SIZE || '1024x1536',
|
||||
n: 1
|
||||
};
|
||||
|
||||
if (mask) {
|
||||
const maskFile = await toFile(Buffer.from(mask.base64, 'base64'), 'mask.png', {
|
||||
type: 'image/png'
|
||||
});
|
||||
params.mask = maskFile;
|
||||
}
|
||||
|
||||
const response = await getOpenAIClient().images.edit(params);
|
||||
|
||||
const image = response.data?.[0];
|
||||
|
||||
if (image?.b64_json) {
|
||||
return {
|
||||
mimeType: 'image/png',
|
||||
base64: image.b64_json
|
||||
};
|
||||
}
|
||||
|
||||
if (image?.url) {
|
||||
const imageResponse = await fetch(image.url);
|
||||
const bytes = new Uint8Array(await imageResponse.arrayBuffer());
|
||||
|
||||
return {
|
||||
mimeType: imageResponse.headers.get('content-type') || 'image/png',
|
||||
base64: Buffer.from(bytes).toString('base64')
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('OpenAI image edit did not return image data');
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { requireJob, updateJob } from '$lib/server/flowerFlow/jobStore.js';
|
||||
import { loadGeneratedImageBytes } from '$lib/server/flowerFlow/loadGeneratedImage.js';
|
||||
import { buildAreaEditMask } from '$lib/server/flowerFlow/selectionMask.js';
|
||||
import { uploadGeneratedImages } from '$lib/server/flowerFlow/imageStorage.js';
|
||||
import { formatBouquetEditPrompt } from '$lib/flowerFlow/bouquetImageFormat.js';
|
||||
import { normalizeRecipeLists } from '$lib/flowerFlow/resolveRecipeFlowers.js';
|
||||
import {
|
||||
generateBouquetImage,
|
||||
editBouquetImage,
|
||||
getImageProvider,
|
||||
isImageGenerationConfigured
|
||||
} from '$lib/server/gemini/image.js';
|
||||
import { buildImagePrompt, applyRecipeEdit } from '$lib/server/gemini/text.js';
|
||||
import { applyRecipeEdit } from '$lib/server/gemini/text.js';
|
||||
import { json, readJsonBody, toErrorResponse } from '$lib/server/http.js';
|
||||
|
||||
/**
|
||||
@@ -24,29 +28,6 @@ function isPointArray(value) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ mode: string, prompt: string, selection: unknown }} instruction
|
||||
*/
|
||||
function describeEditInstruction(instruction) {
|
||||
const lines = [
|
||||
'EDIT REQUEST:',
|
||||
instruction.prompt,
|
||||
'',
|
||||
'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') {
|
||||
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 {
|
||||
@@ -74,14 +55,37 @@ export async function POST({ request }) {
|
||||
return json({ error: 'recipe is missing. Run recipe first.', code: 'bad_request' }, 400);
|
||||
}
|
||||
|
||||
if (!job.images?.primary) {
|
||||
return json({ error: 'bouquet image is missing. Generate images first.', code: 'bad_request' }, 400);
|
||||
}
|
||||
|
||||
const priorRecipe = normalizeRecipeLists(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 })}`;
|
||||
const recipeChanged = JSON.stringify(updatedRecipe) !== JSON.stringify(priorRecipe);
|
||||
|
||||
const sourceImage = await loadGeneratedImageBytes(job.images.primary);
|
||||
const editPrompt = formatBouquetEditPrompt({
|
||||
userPrompt: prompt,
|
||||
mode,
|
||||
selection,
|
||||
recipe: updatedRecipe,
|
||||
recipeChanged
|
||||
});
|
||||
|
||||
const provider = getImageProvider();
|
||||
const mask =
|
||||
mode === 'area' && selection.length >= 3
|
||||
? buildAreaEditMask(
|
||||
sourceImage,
|
||||
selection,
|
||||
provider === 'gemini' ? 'gemini' : 'openai'
|
||||
)
|
||||
: null;
|
||||
|
||||
console.log(
|
||||
`[flower-flow] edit-images job=${jobId.slice(0, 8)} provider=${getImageProvider()} mode=${mode} → generating...`
|
||||
`[flower-flow] edit-images job=${jobId.slice(0, 8)} provider=${provider} mode=${mode}${mask ? ' (masked)' : ''} → editing...`
|
||||
);
|
||||
const generatedImage = await generateBouquetImage(editPrompt, { edit: true });
|
||||
const generatedImage = await editBouquetImage(sourceImage, editPrompt, { mask });
|
||||
const images = await uploadGeneratedImages(
|
||||
jobId,
|
||||
generatedImage,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { requireJob, updateJob } from '$lib/server/flowerFlow/jobStore.js';
|
||||
import { normalizeRecipeLists } from '$lib/flowerFlow/resolveRecipeFlowers.js';
|
||||
import { buildImagePrompt } from '$lib/server/gemini/text.js';
|
||||
import {
|
||||
generateBouquetImage,
|
||||
@@ -29,11 +30,12 @@ function generateForJob(jobId, recipe) {
|
||||
if (existing) return existing;
|
||||
|
||||
const task = (async () => {
|
||||
const imagePrompt = await buildImagePrompt(recipe);
|
||||
const normalizedRecipe = normalizeRecipeLists(recipe);
|
||||
const imagePrompt = await buildImagePrompt(normalizedRecipe);
|
||||
const generatedImage = await generateBouquetImage(imagePrompt);
|
||||
const images = await uploadGeneratedImages(jobId, generatedImage, `initial-${Date.now()}`);
|
||||
await updateJob(jobId, { imagePrompt, images });
|
||||
return { imagePrompt, images };
|
||||
await updateJob(jobId, { imagePrompt, images, recipe: normalizedRecipe });
|
||||
return { imagePrompt, images, recipe: normalizedRecipe };
|
||||
})().finally(() => {
|
||||
inFlight.delete(jobId);
|
||||
});
|
||||
@@ -73,7 +75,7 @@ export async function POST({ request }) {
|
||||
console.log(
|
||||
`[flower-flow] generate-images job=${jobId.slice(0, 8)} provider=${getImageProvider()} → generating...`
|
||||
);
|
||||
const { imagePrompt, images } = await generateForJob(jobId, job.recipe);
|
||||
const { imagePrompt, images, recipe: savedRecipe } = await generateForJob(jobId, job.recipe);
|
||||
console.log(
|
||||
`[flower-flow] generate-images job=${jobId.slice(0, 8)} OK (mock=${!isImageGenerationConfigured()})`
|
||||
);
|
||||
@@ -82,6 +84,7 @@ export async function POST({ request }) {
|
||||
jobId,
|
||||
imagePrompt,
|
||||
images,
|
||||
recipe: savedRecipe,
|
||||
mock: !isImageGenerationConfigured()
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { requireJob, updateJob } from '$lib/server/flowerFlow/jobStore.js';
|
||||
import { normalizeRecipeLists } from '$lib/flowerFlow/resolveRecipeFlowers.js';
|
||||
import { buildBouquetRecipe } from '$lib/server/gemini/text.js';
|
||||
import { isGeminiConfigured } from '$lib/server/gemini/client.js';
|
||||
import { json, readJsonBody, toErrorResponse } from '$lib/server/http.js';
|
||||
@@ -26,7 +27,9 @@ export async function POST({ request }) {
|
||||
}
|
||||
|
||||
const currentJob = await requireJob(jobId);
|
||||
const recipe = await buildBouquetRecipe(currentJob.moodAnalysis, currentJob.userInput);
|
||||
const recipe = normalizeRecipeLists(
|
||||
await buildBouquetRecipe(currentJob.moodAnalysis, currentJob.userInput)
|
||||
);
|
||||
await updateJob(jobId, { recipe });
|
||||
|
||||
return json({
|
||||
|
||||
@@ -337,7 +337,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"
|
||||
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 lg:pb-12"
|
||||
>
|
||||
<div class="mx-auto flex min-h-0 w-full max-w-100 flex-1 flex-col items-center justify-center gap-6">
|
||||
<div class="w-full max-w-24 overflow-hidden bg-track shadow-sm ring-1 ring-black/5 sm:max-w-28 lg:max-w-75">
|
||||
|
||||
Reference in New Issue
Block a user