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·크기 유지
|
mobile: row · desktop: 꽃 슬롯 높이 고정 → 설명 카드 길이와 무관하게 Y·크기 유지
|
||||||
-->
|
-->
|
||||||
<div
|
<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
|
<div
|
||||||
class="flex h-[11rem] shrink-0 items-end justify-center sm:h-[13rem] lg:h-[min(24rem,36vh)] lg:w-full"
|
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>
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
import FloristOrderMessage from './FloristOrderMessage.svelte';
|
import FloristOrderMessage from './FloristOrderMessage.svelte';
|
||||||
import KakaoMap from './KakaoMap.svelte';
|
import KakaoMap from './KakaoMap.svelte';
|
||||||
import ShopList from './ShopList.svelte';
|
import ShopList from './ShopList.svelte';
|
||||||
@@ -19,10 +20,15 @@
|
|||||||
onrefresh
|
onrefresh
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let mapCenterLat = $state(initialLat);
|
let mapCenterLat = $state(DEFAULT_MAP_CENTER.lat);
|
||||||
let mapCenterLng = $state(initialLng);
|
let mapCenterLng = $state(DEFAULT_MAP_CENTER.lng);
|
||||||
let panTarget = $state(null);
|
let panTarget = $state(null);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
mapCenterLat = initialLat;
|
||||||
|
mapCenterLng = initialLng;
|
||||||
|
});
|
||||||
|
|
||||||
function handleCenterChange(lat, lng) {
|
function handleCenterChange(lat, lng) {
|
||||||
mapCenterLat = lat;
|
mapCenterLat = lat;
|
||||||
mapCenterLng = lng;
|
mapCenterLng = lng;
|
||||||
|
|||||||
@@ -4,3 +4,90 @@ export const BOUQUET_IMAGE_ASPECT = '3:4';
|
|||||||
|
|
||||||
export const BOUQUET_IMAGE_ASPECT_PROMPT =
|
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.';
|
'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;
|
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 {{ mainFlowers?: string[], subFlowers?: string[], greenery?: string[] } | null | undefined} recipe
|
||||||
* @param {(id: number) => string} getImageSrc
|
* @param {(id: number) => string} getImageSrc
|
||||||
* @returns {RecipeFlowerCard[]}
|
* @returns {RecipeFlowerCard[]}
|
||||||
*/
|
*/
|
||||||
export function resolveRecipeFlowers(recipe, getImageSrc) {
|
export function resolveRecipeFlowers(recipe, getImageSrc) {
|
||||||
|
const normalized = normalizeRecipeLists(recipe ?? {});
|
||||||
if (!recipe) return [];
|
if (!recipe) return [];
|
||||||
|
|
||||||
/** @type {RecipeFlowerCard[]} */
|
/** @type {RecipeFlowerCard[]} */
|
||||||
@@ -77,9 +123,9 @@ export function resolveRecipeFlowers(recipe, getImageSrc) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
addFlowers(recipe.mainFlowers, 'main');
|
addFlowers(normalized.mainFlowers, 'main');
|
||||||
addFlowers(recipe.subFlowers, 'sub');
|
addFlowers(normalized.subFlowers, 'sub');
|
||||||
addFlowers(recipe.greenery, 'greenery');
|
addFlowers(normalized.greenery, 'greenery');
|
||||||
|
|
||||||
return cards;
|
return cards;
|
||||||
}
|
}
|
||||||
@@ -106,6 +152,17 @@ function pickKeywords(items, limit = 2) {
|
|||||||
return items.filter(Boolean).slice(0, limit).join(', ');
|
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.
|
* Short mood-led title for the result description card.
|
||||||
* @param {{ moodKeywords?: string[], styleImpression?: string[] } | null | undefined} moodAnalysis
|
* @param {{ moodKeywords?: string[], styleImpression?: string[] } | null | undefined} moodAnalysis
|
||||||
@@ -153,16 +210,17 @@ function getPrimaryFlowerFromRecipe(recipe) {
|
|||||||
* @param {{ mainFlowers?: string[] } | null | undefined} recipe
|
* @param {{ mainFlowers?: string[] } | null | undefined} recipe
|
||||||
*/
|
*/
|
||||||
export function buildBouquetRationale(moodAnalysis, userInput, recipe) {
|
export function buildBouquetRationale(moodAnalysis, userInput, recipe) {
|
||||||
|
const normalized = normalizeRecipeLists(recipe ?? {});
|
||||||
const recipient = userInput?.relationship?.trim();
|
const recipient = userInput?.relationship?.trim();
|
||||||
const subject = recipient ? `${recipient}'s` : 'The';
|
const subject = recipient ? `${recipient}'s` : 'The';
|
||||||
const cardMessage = extractCardMessage(userInput);
|
const cardMessage = extractCardMessage(userInput);
|
||||||
const mainFlower = getPrimaryFlowerFromRecipe(recipe);
|
const mainFlower = getPrimaryFlowerFromRecipe(normalized);
|
||||||
|
|
||||||
const mood = pickKeywords(
|
const mood = pickKeywords(
|
||||||
[...(moodAnalysis?.moodKeywords ?? []), ...(moodAnalysis?.styleImpression ?? [])],
|
[...(moodAnalysis?.moodKeywords ?? []), ...(moodAnalysis?.styleImpression ?? [])],
|
||||||
2
|
2
|
||||||
);
|
);
|
||||||
const colors = pickKeywords(moodAnalysis?.colorPalette, 2);
|
const colors = pickMoodColors(moodAnalysis?.colorPalette, 2);
|
||||||
|
|
||||||
/** @type {string[]} */
|
/** @type {string[]} */
|
||||||
const parts = [];
|
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 { BOUQUET_IMAGE_ASPECT_PROMPT } from '../../flowerFlow/bouquetImageFormat.js';
|
||||||
import { getImageModel, isGeminiConfigured } from './client.js';
|
import { getImageModel, isGeminiConfigured } from './client.js';
|
||||||
import { mockGeneratedImage } from './mock.js';
|
import { mockGeneratedImage } from './mock.js';
|
||||||
import { generateOpenAIImage, isOpenAIConfigured } from '../openai/image.js';
|
import { generateOpenAIImage, editOpenAIImage, isOpenAIConfigured } from '../openai/image.js';
|
||||||
|
|
||||||
export function getImageProvider() {
|
export function getImageProvider() {
|
||||||
const configured = env.IMAGE_PROVIDER?.trim().toLowerCase();
|
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 {string} basePrompt
|
||||||
* @param {{ edit?: boolean }} [options]
|
|
||||||
* @returns {Promise<GeneratedImage>}
|
* @returns {Promise<GeneratedImage>}
|
||||||
*/
|
*/
|
||||||
export async function generateBouquetImage(basePrompt, options = {}) {
|
export async function generateBouquetImage(basePrompt) {
|
||||||
const suffix = options.edit
|
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.`;
|
||||||
? `Generate exactly one edited bouquet image. Show a single bouquet only, centered in frame. Do not show two bouquets, no side-by-side comparison, no before/after layout, and no duplicate arrangements. ${BOUQUET_IMAGE_ASPECT_PROMPT} Keep it realistic, orderable from a real florist, front-facing, and suitable for a customer preview.`
|
|
||||||
: `Generate one final bouquet image. ${BOUQUET_IMAGE_ASPECT_PROMPT} Keep it realistic, orderable from a real florist, front-facing, and suitable for a customer preview.`;
|
|
||||||
const prompt = `${basePrompt}\n\n${suffix}`;
|
const prompt = `${basePrompt}\n\n${suffix}`;
|
||||||
const provider = getImageProvider();
|
const provider = getImageProvider();
|
||||||
|
|
||||||
// Explicit mock mode: develop the full flow without spending any image quota.
|
|
||||||
if (provider === 'mock') {
|
if (provider === 'mock') {
|
||||||
return mockGeneratedImage();
|
return mockGeneratedImage();
|
||||||
}
|
}
|
||||||
@@ -50,18 +66,61 @@ export async function generateBouquetImage(basePrompt, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const model = getImageModel();
|
const model = getImageModel();
|
||||||
|
|
||||||
const result = await model.generateContent(prompt);
|
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) {
|
* Edit an existing bouquet photo using the source image as reference.
|
||||||
return {
|
* @param {{ base64: string, mimeType: string }} sourceImage
|
||||||
mimeType: part.inlineData.mimeType || 'image/png',
|
* @param {string} editPrompt
|
||||||
base64: part.inlineData.data
|
* @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').MoodAnalysis} MoodAnalysis */
|
||||||
/** @typedef {import('../flowerFlow/jobStore.js').BouquetRecipe} BouquetRecipe */
|
/** @typedef {import('../flowerFlow/jobStore.js').BouquetRecipe} BouquetRecipe */
|
||||||
|
|
||||||
|
import { formatStrictBouquetImagePrompt } from '../../flowerFlow/bouquetImageFormat.js';
|
||||||
|
|
||||||
/** @returns {MoodAnalysis} */
|
/** @returns {MoodAnalysis} */
|
||||||
export function mockMoodAnalysis() {
|
export function mockMoodAnalysis() {
|
||||||
return {
|
return {
|
||||||
@@ -56,15 +58,7 @@ export function mockRecipe(userInput = {}) {
|
|||||||
|
|
||||||
/** @param {BouquetRecipe} recipe */
|
/** @param {BouquetRecipe} recipe */
|
||||||
export function mockImagePrompt(recipe) {
|
export function mockImagePrompt(recipe) {
|
||||||
return [
|
return formatStrictBouquetImagePrompt(recipe);
|
||||||
'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(' ');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param {string} [label] */
|
/** @param {string} [label] */
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
/** @typedef {import('../flowerFlow/jobStore.js').UserInput} UserInput */
|
/** @typedef {import('../flowerFlow/jobStore.js').UserInput} UserInput */
|
||||||
|
|
||||||
import { matchFlowersFromMood } from '../flowerFlow/flowerDB.js';
|
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 { getTextModel, isGeminiConfigured, parseJsonFromText } from './client.js';
|
||||||
import { mockRecipe } from './mock.js';
|
import { mockApplyRecipeEdit, mockRecipe } from './mock.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {MoodAnalysis} mood
|
* @param {MoodAnalysis} mood
|
||||||
@@ -19,7 +20,7 @@ export async function buildBouquetRecipe(mood, userInput = {}) {
|
|||||||
: 'around ₩50,000';
|
: 'around ₩50,000';
|
||||||
|
|
||||||
if (!isGeminiConfigured()) {
|
if (!isGeminiConfigured()) {
|
||||||
return mockRecipe(userInput);
|
return normalizeRecipeLists(mockRecipe(userInput));
|
||||||
}
|
}
|
||||||
|
|
||||||
const model = getTextModel();
|
const model = getTextModel();
|
||||||
@@ -67,7 +68,7 @@ Rules:
|
|||||||
- Budget should be ${budget}.`;
|
- Budget should be ${budget}.`;
|
||||||
|
|
||||||
const result = await model.generateContent(prompt);
|
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>}
|
* @returns {Promise<string>}
|
||||||
*/
|
*/
|
||||||
export async function buildImagePrompt(recipe) {
|
export async function buildImagePrompt(recipe) {
|
||||||
if (!isGeminiConfigured()) {
|
return formatStrictBouquetImagePrompt(recipe);
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -131,8 +110,7 @@ Return plain text only.`;
|
|||||||
*/
|
*/
|
||||||
export async function applyRecipeEdit(recipe, editPrompt) {
|
export async function applyRecipeEdit(recipe, editPrompt) {
|
||||||
if (!isGeminiConfigured()) {
|
if (!isGeminiConfigured()) {
|
||||||
const { mockApplyRecipeEdit } = await import('./mock.js');
|
return normalizeRecipeLists(mockApplyRecipeEdit(recipe, editPrompt));
|
||||||
return mockApplyRecipeEdit(recipe, editPrompt);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const model = getTextModel();
|
const model = getTextModel();
|
||||||
@@ -160,8 +138,11 @@ Return JSON only with the same schema:
|
|||||||
Rules:
|
Rules:
|
||||||
- Change only what the edit request implies; keep unrelated fields the same.
|
- Change only what the edit request implies; keep unrelated fields the same.
|
||||||
- Use realistic florist flower names.
|
- 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);
|
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"
|
"energyLevel": "low" | "medium" | "high"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Use plain color names only (e.g. ivory, navy, blush). Do not use hex codes or RGB values.
|
||||||
|
|
||||||
User context:
|
User context:
|
||||||
- relationship: ${userInput.relationship ?? 'unknown'}
|
- relationship: ${userInput.relationship ?? 'unknown'}
|
||||||
- occasion: ${userInput.occasion ?? 'unknown'}
|
- occasion: ${userInput.occasion ?? 'unknown'}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { env } from '$env/dynamic/private';
|
import { env } from '$env/dynamic/private';
|
||||||
import OpenAI from 'openai';
|
import OpenAI, { toFile } from 'openai';
|
||||||
|
|
||||||
let client = null;
|
let client = null;
|
||||||
|
|
||||||
@@ -52,3 +52,53 @@ export async function generateOpenAIImage(prompt) {
|
|||||||
|
|
||||||
throw new Error('OpenAI image model did not return image data');
|
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 { 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 { uploadGeneratedImages } from '$lib/server/flowerFlow/imageStorage.js';
|
||||||
|
import { formatBouquetEditPrompt } from '$lib/flowerFlow/bouquetImageFormat.js';
|
||||||
|
import { normalizeRecipeLists } from '$lib/flowerFlow/resolveRecipeFlowers.js';
|
||||||
import {
|
import {
|
||||||
generateBouquetImage,
|
editBouquetImage,
|
||||||
getImageProvider,
|
getImageProvider,
|
||||||
isImageGenerationConfigured
|
isImageGenerationConfigured
|
||||||
} from '$lib/server/gemini/image.js';
|
} 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';
|
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} */
|
/** @type {import('./$types').RequestHandler} */
|
||||||
export async function POST({ request }) {
|
export async function POST({ request }) {
|
||||||
try {
|
try {
|
||||||
@@ -74,14 +55,37 @@ export async function POST({ request }) {
|
|||||||
return json({ error: 'recipe is missing. Run recipe first.', code: 'bad_request' }, 400);
|
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 updatedRecipe = await applyRecipeEdit(job.recipe, prompt);
|
||||||
const basePrompt = job.imagePrompt ?? (await buildImagePrompt(updatedRecipe));
|
const recipeChanged = JSON.stringify(updatedRecipe) !== JSON.stringify(priorRecipe);
|
||||||
const editPrompt = `${basePrompt}\n\n${describeEditInstruction({ mode, prompt, selection })}`;
|
|
||||||
|
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(
|
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(
|
const images = await uploadGeneratedImages(
|
||||||
jobId,
|
jobId,
|
||||||
generatedImage,
|
generatedImage,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { requireJob, updateJob } from '$lib/server/flowerFlow/jobStore.js';
|
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 { buildImagePrompt } from '$lib/server/gemini/text.js';
|
||||||
import {
|
import {
|
||||||
generateBouquetImage,
|
generateBouquetImage,
|
||||||
@@ -29,11 +30,12 @@ function generateForJob(jobId, recipe) {
|
|||||||
if (existing) return existing;
|
if (existing) return existing;
|
||||||
|
|
||||||
const task = (async () => {
|
const task = (async () => {
|
||||||
const imagePrompt = await buildImagePrompt(recipe);
|
const normalizedRecipe = normalizeRecipeLists(recipe);
|
||||||
|
const imagePrompt = await buildImagePrompt(normalizedRecipe);
|
||||||
const generatedImage = await generateBouquetImage(imagePrompt);
|
const generatedImage = await generateBouquetImage(imagePrompt);
|
||||||
const images = await uploadGeneratedImages(jobId, generatedImage, `initial-${Date.now()}`);
|
const images = await uploadGeneratedImages(jobId, generatedImage, `initial-${Date.now()}`);
|
||||||
await updateJob(jobId, { imagePrompt, images });
|
await updateJob(jobId, { imagePrompt, images, recipe: normalizedRecipe });
|
||||||
return { imagePrompt, images };
|
return { imagePrompt, images, recipe: normalizedRecipe };
|
||||||
})().finally(() => {
|
})().finally(() => {
|
||||||
inFlight.delete(jobId);
|
inFlight.delete(jobId);
|
||||||
});
|
});
|
||||||
@@ -73,7 +75,7 @@ export async function POST({ request }) {
|
|||||||
console.log(
|
console.log(
|
||||||
`[flower-flow] generate-images job=${jobId.slice(0, 8)} provider=${getImageProvider()} → generating...`
|
`[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(
|
console.log(
|
||||||
`[flower-flow] generate-images job=${jobId.slice(0, 8)} OK (mock=${!isImageGenerationConfigured()})`
|
`[flower-flow] generate-images job=${jobId.slice(0, 8)} OK (mock=${!isImageGenerationConfigured()})`
|
||||||
);
|
);
|
||||||
@@ -82,6 +84,7 @@ export async function POST({ request }) {
|
|||||||
jobId,
|
jobId,
|
||||||
imagePrompt,
|
imagePrompt,
|
||||||
images,
|
images,
|
||||||
|
recipe: savedRecipe,
|
||||||
mock: !isImageGenerationConfigured()
|
mock: !isImageGenerationConfigured()
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { requireJob, updateJob } from '$lib/server/flowerFlow/jobStore.js';
|
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 { buildBouquetRecipe } from '$lib/server/gemini/text.js';
|
||||||
import { isGeminiConfigured } from '$lib/server/gemini/client.js';
|
import { isGeminiConfigured } from '$lib/server/gemini/client.js';
|
||||||
import { json, readJsonBody, toErrorResponse } from '$lib/server/http.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 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 });
|
await updateJob(jobId, { recipe });
|
||||||
|
|
||||||
return json({
|
return json({
|
||||||
|
|||||||
@@ -337,7 +337,7 @@
|
|||||||
|
|
||||||
<main class="flex min-h-0 flex-1 flex-col lg:flex-row">
|
<main class="flex min-h-0 flex-1 flex-col lg:flex-row">
|
||||||
<section
|
<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="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">
|
<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