refine: ui, prompt, and desc card
* ui improved * prompt:realisic+noHuman * prompt:editRefinement * fix: map DescriptionCard truncation and truncateAt typo Prevent result/map card overflow with character limits and line-clamp; fix buildMapOrderDescription calling undefined truncateAt. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: 이지은 <ijieun@ijieun-ui-MacBookPro.local> Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
25
src/lib/artwork/artworkSlotLayout.js
Normal file
25
src/lib/artwork/artworkSlotLayout.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* 2분할 좌측 액자 슬롯 — 페이지 전환 시 동일 위치·크기 유지.
|
||||||
|
* DescriptionCard가 커져도 액자 슬롯(flex-none)은 위치·크기 고정, 카드만 아래로 늘어남.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 액자 전용 슬롯 — 프레임 높이만큼만 차지 (데스크톱에서 불필요한 빈 높이 제거)
|
||||||
|
* 모바일 row 레이아웃용 최소 높이만 유지
|
||||||
|
*/
|
||||||
|
export const ARTWORK_SLOT_FLOWER =
|
||||||
|
'flex h-[11rem] w-full shrink-0 flex-none items-start justify-center sm:h-[13rem] lg:h-auto lg:min-h-0';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 액자 + DescriptionCard 래퍼
|
||||||
|
* lg:pt-16 — 우측 ContextForm 헤더(lg:py-16)와 상단 정렬 (레퍼런스 기준)
|
||||||
|
* 세로 중앙 배치(calc(50%...)) 없음 → 카드 길이와 무관하게 액자 Y 고정
|
||||||
|
*/
|
||||||
|
export const ARTWORK_SLOT_WRAPPER =
|
||||||
|
'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-5 lg:px-10 lg:pt-20 lg:pb-10';
|
||||||
|
|
||||||
|
/** 액자 외곽 최대 너비 */
|
||||||
|
export const ARTWORK_FRAME_MAX_W = 'w-full max-w-24 sm:max-w-28 lg:max-w-75';
|
||||||
|
|
||||||
|
/** DescriptionCard — 액자 바로 아래, flex-none으로 카드만 세로 확장 */
|
||||||
|
export const ARTWORK_SLOT_CARD = 'w-full min-w-0 shrink-0 flex-none lg:flex lg:justify-center';
|
||||||
86
src/lib/artwork/drawMuseumFrame.js
Normal file
86
src/lib/artwork/drawMuseumFrame.js
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import {
|
||||||
|
FRAME_FILL,
|
||||||
|
FRAME_STROKE,
|
||||||
|
INNER_H,
|
||||||
|
INNER_W,
|
||||||
|
MAT_X,
|
||||||
|
MAT_Y,
|
||||||
|
OUTER_H,
|
||||||
|
OUTER_W
|
||||||
|
} from './museumFrameGeometry.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* p5 캔버스에 미술관 화이트 액자 링을 그립니다.
|
||||||
|
* 중앙 개구부는 투명 — 아래 HTML 슬롯이 보입니다.
|
||||||
|
* @param {import('p5')} p
|
||||||
|
* @param {number} canvasW
|
||||||
|
* @param {number} canvasH
|
||||||
|
*/
|
||||||
|
export function drawMuseumFrame(p, canvasW, canvasH) {
|
||||||
|
p.clear();
|
||||||
|
|
||||||
|
const scale = Math.min(canvasW / OUTER_W, canvasH / OUTER_H);
|
||||||
|
const drawW = OUTER_W * scale;
|
||||||
|
const drawH = OUTER_H * scale;
|
||||||
|
const offsetX = (canvasW - drawW) / 2;
|
||||||
|
const offsetY = (canvasH - drawH) / 2;
|
||||||
|
|
||||||
|
const matX = MAT_X * scale;
|
||||||
|
const matY = MAT_Y * scale;
|
||||||
|
const innerW = INNER_W * scale;
|
||||||
|
const innerH = INNER_H * scale;
|
||||||
|
const innerX = offsetX + matX;
|
||||||
|
const innerY = offsetY + matY;
|
||||||
|
const strokeW = Math.max(1, scale);
|
||||||
|
|
||||||
|
p.push();
|
||||||
|
p.fill(FRAME_FILL);
|
||||||
|
p.noStroke();
|
||||||
|
|
||||||
|
// mat 링 (상·하·좌·우 4조각)
|
||||||
|
p.rect(offsetX, offsetY, drawW, matY);
|
||||||
|
p.rect(offsetX, offsetY + matY + innerH, drawW, matY);
|
||||||
|
p.rect(offsetX, offsetY + matY, matX, innerH);
|
||||||
|
p.rect(offsetX + matX + innerW, offsetY + matY, matX, innerH);
|
||||||
|
|
||||||
|
// 외곽·내곽 테두리
|
||||||
|
p.noFill();
|
||||||
|
p.stroke(FRAME_STROKE);
|
||||||
|
p.strokeWeight(strokeW);
|
||||||
|
p.rect(offsetX, offsetY, drawW, drawH);
|
||||||
|
p.rect(innerX, innerY, innerW, innerH);
|
||||||
|
|
||||||
|
p.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* p5 instance mode 스케치 팩토리.
|
||||||
|
* @param {HTMLElement} container
|
||||||
|
* @returns {Promise<import('p5')>}
|
||||||
|
*/
|
||||||
|
export function createMuseumFrameSketch(container) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
import('p5').then(({ default: p5 }) => {
|
||||||
|
const sketch = (/** @type {import('p5')} */ p) => {
|
||||||
|
p.setup = () => {
|
||||||
|
const w = container.clientWidth || 1;
|
||||||
|
const h = container.clientHeight || 1;
|
||||||
|
const canvas = p.createCanvas(w, h);
|
||||||
|
canvas.parent(container);
|
||||||
|
canvas.elt.style.pointerEvents = 'none';
|
||||||
|
p.pixelDensity(Math.min(window.devicePixelRatio || 1, 2));
|
||||||
|
drawMuseumFrame(p, w, h);
|
||||||
|
};
|
||||||
|
|
||||||
|
p.windowResized = () => {
|
||||||
|
const w = container.clientWidth || 1;
|
||||||
|
const h = container.clientHeight || 1;
|
||||||
|
p.resizeCanvas(w, h);
|
||||||
|
drawMuseumFrame(p, w, h);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
resolve(new p5(sketch, container));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
29
src/lib/artwork/museumFrameGeometry.js
Normal file
29
src/lib/artwork/museumFrameGeometry.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* white frame.svg 비율을 768×1024 내부 개구부에 맞춘 논리 좌표.
|
||||||
|
* SVG 외곽 206×280, 내부 179.29×253.17, mat ~13.3px.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 꽃다발 이미지 출력 크기 (3:4) */
|
||||||
|
export const INNER_W = 768;
|
||||||
|
export const INNER_H = 1024;
|
||||||
|
|
||||||
|
/** inner 대비 mat 두께 (SVG 비율 환산) */
|
||||||
|
export const MAT_X = Math.round((13.3574 / 179.2866) * INNER_W);
|
||||||
|
export const MAT_Y = Math.round((13.2979 / 253.1691) * INNER_H);
|
||||||
|
|
||||||
|
export const OUTER_W = INNER_W + MAT_X * 2;
|
||||||
|
export const OUTER_H = INNER_H + MAT_Y * 2;
|
||||||
|
|
||||||
|
export const FRAME_STROKE = '#D5D5D5';
|
||||||
|
export const FRAME_FILL = '#FFFFFF';
|
||||||
|
|
||||||
|
/** ref/frame ref.png 시각 기준 — 개구부 안 아트워크 너비 비율 (액자 크기는 그대로) */
|
||||||
|
export const ARTWORK_INNER_SCALE = 0.85;
|
||||||
|
|
||||||
|
/** CSS % 배치용 (0–100) */
|
||||||
|
export const APERTURE_LEFT_PCT = (MAT_X / OUTER_W) * 100;
|
||||||
|
export const APERTURE_TOP_PCT = (MAT_Y / OUTER_H) * 100;
|
||||||
|
export const APERTURE_WIDTH_PCT = (INNER_W / OUTER_W) * 100;
|
||||||
|
export const APERTURE_HEIGHT_PCT = (INNER_H / OUTER_H) * 100;
|
||||||
|
|
||||||
|
export const OUTER_ASPECT_RATIO = `${OUTER_W} / ${OUTER_H}`;
|
||||||
@@ -7,16 +7,13 @@
|
|||||||
<path d="M168.14 433L120 424.25L140.632 405L178.8 394L218 423.5L168.14 433Z" fill="#7D7D7D"/>
|
<path d="M168.14 433L120 424.25L140.632 405L178.8 394L218 423.5L168.14 433Z" fill="#7D7D7D"/>
|
||||||
<path d="M158 316.5C176.167 299.333 208 252.7 190 203.5C167.5 142 206.5 90 221.5 88" stroke="#7D7D7D" stroke-width="6"/>
|
<path d="M158 316.5C176.167 299.333 208 252.7 190 203.5C167.5 142 206.5 90 221.5 88" stroke="#7D7D7D" stroke-width="6"/>
|
||||||
<path d="M158 316.5C176.167 299.333 208 252.7 190 203.5C167.5 142 206.5 90 221.5 88" stroke="url(#paint0_linear_391_391)" stroke-width="6"/>
|
<path d="M158 316.5C176.167 299.333 208 252.7 190 203.5C167.5 142 206.5 90 221.5 88" stroke="url(#paint0_linear_391_391)" stroke-width="6"/>
|
||||||
<foreignObject x="137" y="68" width="111" height="91"><div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur(5px);clip-path:url(#bgblur_0_391_391_clip_path);height:100%;width:100%"></div></foreignObject><rect data-figma-bg-blur-radius="10" x="147" y="78" width="91" height="71" fill="#F1C130"/>
|
<rect x="147" y="78" width="91" height="71" fill="#F1C130"/>
|
||||||
<foreignObject x="116.507" y="33.507" width="60.9719" height="49.9859"><div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur(2.75px);clip-path:url(#bgblur_1_391_391_clip_path);height:100%;width:100%"></div></foreignObject><rect data-figma-bg-blur-radius="5.49296" x="122" y="39" width="49.9859" height="39" fill="#EDBA54"/>
|
<rect x="122" y="39" width="49.9859" height="39" fill="#EDBA54"/>
|
||||||
<foreignObject x="128.304" y="59.3043" width="80.3913" height="80.3913"><div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur(9.35px);clip-path:url(#bgblur_2_391_391_clip_path);height:100%;width:100%"></div></foreignObject><rect data-figma-bg-blur-radius="18.6957" x="147" y="78" width="43" height="43" fill="#F19430"/>
|
<rect x="147" y="78" width="43" height="43" fill="#F19430"/>
|
||||||
<path d="M197 288.566L76 271L133.932 360.865V372.576L120.295 424L175.523 402.87V372.576L197 288.566Z" fill="#BBBBBB"/>
|
<path d="M197 288.566L76 271L133.932 360.865V372.576L120.295 424L175.523 402.87V372.576L197 288.566Z" fill="#BBBBBB"/>
|
||||||
<path d="M136 307.86L265.5 264L202.383 360.612V371.263L217.362 423L179.915 412.095L136 307.86Z" fill="#D9D9D9"/>
|
<path d="M136 307.86L265.5 264L202.383 360.612V371.263L217.362 423L179.915 412.095L136 307.86Z" fill="#D9D9D9"/>
|
||||||
<defs>
|
<defs>
|
||||||
<clipPath id="bgblur_0_391_391_clip_path" transform="translate(-137 -68)"><rect x="147" y="78" width="91" height="71"/>
|
<linearGradient id="paint0_linear_391_391" x1="234.304" y1="63" x2="234.304" y2="316.5" gradientUnits="userSpaceOnUse">
|
||||||
</clipPath><clipPath id="bgblur_1_391_391_clip_path" transform="translate(-116.507 -33.507)"><rect x="122" y="39" width="49.9859" height="39"/>
|
|
||||||
</clipPath><clipPath id="bgblur_2_391_391_clip_path" transform="translate(-128.304 -59.3043)"><rect x="147" y="78" width="43" height="43"/>
|
|
||||||
</clipPath><linearGradient id="paint0_linear_391_391" x1="234.304" y1="63" x2="234.304" y2="316.5" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop offset="0.355769" stop-color="#523E03"/>
|
<stop offset="0.355769" stop-color="#523E03"/>
|
||||||
<stop offset="1" stop-color="#08B816"/>
|
<stop offset="1" stop-color="#08B816"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 1.6 KiB |
@@ -1,5 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { dev } from '$app/environment';
|
import { dev } from '$app/environment';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
import { seedDevFlow } from '$lib/flowerFlow/devSeed.js';
|
import { seedDevFlow } from '$lib/flowerFlow/devSeed.js';
|
||||||
|
|
||||||
/** 나중에 mute 하려면 true 로 변경 */
|
/** 나중에 mute 하려면 true 로 변경 */
|
||||||
@@ -25,10 +27,27 @@
|
|||||||
// 페이지 상단 const jobId = getFlowString(...) 갱신을 위해 새로고침
|
// 페이지 상단 const jobId = getFlowString(...) 갱신을 위해 새로고침
|
||||||
location.reload();
|
location.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openResultDev() {
|
||||||
|
loading = true;
|
||||||
|
message = '';
|
||||||
|
|
||||||
|
const result = await seedDevFlow('result');
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
message = result.error;
|
||||||
|
loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = false;
|
||||||
|
await goto(resolve('/result'));
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if dev && !DEV_SEED_MUTED}
|
{#if dev && !DEV_SEED_MUTED}
|
||||||
<div class="dev-seed fixed bottom-4 left-4 z-50 flex flex-col items-start gap-1">
|
<div class="dev-seed fixed bottom-4 left-4 z-50 flex flex-col items-start gap-1">
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
@@ -38,6 +57,16 @@
|
|||||||
>
|
>
|
||||||
{loading ? 'Seeding…' : 'Dev: Fill data'}
|
{loading ? 'Seeding…' : 'Dev: Fill data'}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={loading}
|
||||||
|
onclick={openResultDev}
|
||||||
|
class="rounded border border-dashed border-subtle/60 bg-surface/95 px-3 py-1.5 text-xs text-muted shadow-sm backdrop-blur hover:border-subtle hover:text-ink disabled:opacity-50"
|
||||||
|
title="더미 job 생성 후 /result 로 이동 (개발용)"
|
||||||
|
>
|
||||||
|
{loading ? 'Seeding…' : 'Dev: → Result'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{#if message && message !== 'Filled'}
|
{#if message && message !== 'Filled'}
|
||||||
<p class="max-w-48 rounded bg-surface/95 px-2 py-1 text-xs text-red-600 shadow-sm">
|
<p class="max-w-48 rounded bg-surface/95 px-2 py-1 text-xs text-red-600 shadow-sm">
|
||||||
{message}
|
{message}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
<script>
|
<script>
|
||||||
// The exhibited artwork — always shown on the left, acting like a step indicator.
|
// The exhibited artwork — always shown on the left, acting like a step indicator.
|
||||||
import Vase from './Vase.svelte';
|
|
||||||
import DescriptionCard from './DescriptionCard.svelte';
|
import DescriptionCard from './DescriptionCard.svelte';
|
||||||
import ComingSoonTape from './ComingSoonTape.svelte';
|
import ComingSoonTape from './ComingSoonTape.svelte';
|
||||||
|
import MuseumFrame from './MuseumFrame.svelte';
|
||||||
import { downloadGeneratedImage } from '$lib/flowerFlow/downloadGeneratedImage.js';
|
import { downloadGeneratedImage } from '$lib/flowerFlow/downloadGeneratedImage.js';
|
||||||
|
import {
|
||||||
|
ARTWORK_SLOT_FLOWER,
|
||||||
|
ARTWORK_SLOT_WRAPPER,
|
||||||
|
ARTWORK_SLOT_CARD
|
||||||
|
} from '$lib/artwork/artworkSlotLayout.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
title = 'Title',
|
title = 'Title',
|
||||||
@@ -23,6 +28,8 @@
|
|||||||
let downloading = $state(false);
|
let downloading = $state(false);
|
||||||
let downloadError = $state('');
|
let downloadError = $state('');
|
||||||
|
|
||||||
|
const frameMode = $derived(imageSrc ? 'bouquet' : 'artwork');
|
||||||
|
|
||||||
async function handleDownload() {
|
async function handleDownload() {
|
||||||
if (!downloadImage || downloading) return;
|
if (!downloadImage || downloading) return;
|
||||||
|
|
||||||
@@ -45,22 +52,11 @@
|
|||||||
<!--
|
<!--
|
||||||
mobile: row · desktop: 꽃 슬롯 높이 고정 → 설명 카드 길이와 무관하게 Y·크기 유지
|
mobile: row · desktop: 꽃 슬롯 높이 고정 → 설명 카드 길이와 무관하게 Y·크기 유지
|
||||||
-->
|
-->
|
||||||
<div
|
<div class={ARTWORK_SLOT_WRAPPER}>
|
||||||
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:pt-[calc(50%-5rem)] lg:pb-12"
|
<div class={ARTWORK_SLOT_FLOWER}>
|
||||||
>
|
<div class="mx-auto flex w-full flex-col items-center">
|
||||||
<div
|
<MuseumFrame mode={frameMode} {variant} {imageSrc} imageAlt="Selected bouquet" />
|
||||||
class="flex h-[11rem] shrink-0 items-end justify-center sm:h-[13rem] lg:h-[min(24rem,36vh)] lg:w-full"
|
{#if imageSrc && downloadImage}
|
||||||
>
|
|
||||||
{#if imageSrc}
|
|
||||||
<div class="mx-auto flex w-full max-w-24 shrink-0 flex-col items-center sm:max-w-28 lg:max-w-75">
|
|
||||||
<div class="w-full overflow-hidden">
|
|
||||||
<img
|
|
||||||
src={imageSrc}
|
|
||||||
alt="Selected bouquet"
|
|
||||||
class="aspect-[3/4] h-auto w-full object-contain object-center"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{#if downloadImage}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={downloading}
|
disabled={downloading}
|
||||||
@@ -74,12 +70,9 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
|
||||||
<Vase {variant} />
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="min-w-0 shrink-0 lg:flex lg:w-full lg:justify-center">
|
<div class={ARTWORK_SLOT_CARD}>
|
||||||
<DescriptionCard {title} {description} mode={cardMode} />
|
<DescriptionCard {title} {description} mode={cardMode} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
</h3>
|
</h3>
|
||||||
<p
|
<p
|
||||||
class={[
|
class={[
|
||||||
'mt-2 text-xs',
|
'mt-2 line-clamp-4 text-xs',
|
||||||
mode === 'instruction' ? 'leading-snug text-muted' : 'leading-relaxed text-ink'
|
mode === 'instruction' ? 'leading-snug text-muted' : 'leading-relaxed text-ink'
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
|
|||||||
94
src/lib/components/ui/Artwork/MuseumFrame.svelte
Normal file
94
src/lib/components/ui/Artwork/MuseumFrame.svelte
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import { createMuseumFrameSketch } from '$lib/artwork/drawMuseumFrame.js';
|
||||||
|
import {
|
||||||
|
APERTURE_HEIGHT_PCT,
|
||||||
|
APERTURE_LEFT_PCT,
|
||||||
|
APERTURE_TOP_PCT,
|
||||||
|
APERTURE_WIDTH_PCT,
|
||||||
|
ARTWORK_INNER_SCALE,
|
||||||
|
OUTER_ASPECT_RATIO
|
||||||
|
} from '$lib/artwork/museumFrameGeometry.js';
|
||||||
|
import { ARTWORK_FRAME_MAX_W } from '$lib/artwork/artworkSlotLayout.js';
|
||||||
|
import { getArtworkSrc } from './artworkVariants.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
/** @type {'bouquet' | 'artwork'} */
|
||||||
|
mode = 'artwork',
|
||||||
|
/** @type {import('./artworkVariants.js').ArtworkVariant} */
|
||||||
|
variant = 'create1',
|
||||||
|
imageSrc = null,
|
||||||
|
imageAlt = 'Selected bouquet',
|
||||||
|
/** bouquet 로딩 플레이스홀더 */
|
||||||
|
loading = false
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const artworkSrc = $derived(getArtworkSrc(variant));
|
||||||
|
|
||||||
|
/** @type {HTMLElement | null} */
|
||||||
|
let frameHost = $state(null);
|
||||||
|
/** @type {import('p5') | null} */
|
||||||
|
let p5Instance = $state(null);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!browser || !frameHost) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
createMuseumFrameSketch(frameHost).then((instance) => {
|
||||||
|
if (cancelled) {
|
||||||
|
instance.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
p5Instance = instance;
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
p5Instance?.remove();
|
||||||
|
p5Instance = null;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mx-auto shrink-0 {ARTWORK_FRAME_MAX_W}">
|
||||||
|
<div class="relative w-full" style:aspect-ratio={OUTER_ASPECT_RATIO}>
|
||||||
|
<!-- 개구부: 꽃다발 또는 아트워크 -->
|
||||||
|
<div
|
||||||
|
class="absolute z-0 flex items-center justify-center overflow-hidden bg-surface"
|
||||||
|
style:left="{APERTURE_LEFT_PCT}%"
|
||||||
|
style:top="{APERTURE_TOP_PCT}%"
|
||||||
|
style:width="{APERTURE_WIDTH_PCT}%"
|
||||||
|
style:height="{APERTURE_HEIGHT_PCT}%"
|
||||||
|
>
|
||||||
|
{#if mode === 'bouquet'}
|
||||||
|
{#if loading}
|
||||||
|
<div class="h-full w-full animate-pulse bg-placeholder"></div>
|
||||||
|
{:else if imageSrc}
|
||||||
|
<img
|
||||||
|
src={imageSrc}
|
||||||
|
alt={imageAlt}
|
||||||
|
class="h-full w-full object-cover object-center"
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="h-full w-full bg-placeholder"></div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
{#key variant}
|
||||||
|
<img
|
||||||
|
src={artworkSrc}
|
||||||
|
alt=""
|
||||||
|
class="h-auto max-h-full w-auto object-contain object-center"
|
||||||
|
style:width="{ARTWORK_INNER_SCALE * 100}%"
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
|
{/key}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- p5 액자 링 (고정, 내용과 무관) -->
|
||||||
|
<div bind:this={frameHost} class="absolute inset-0 z-10" aria-hidden="true"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -7,10 +7,11 @@
|
|||||||
const src = $derived(getArtworkSrc(variant));
|
const src = $derived(getArtworkSrc(variant));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- MuseumFrame artwork 모드에서 크기·위치 제어. 단독 사용 시 fallback. -->
|
||||||
<img
|
<img
|
||||||
{src}
|
{src}
|
||||||
alt=""
|
alt=""
|
||||||
class="mx-auto h-auto w-full max-w-24 shrink-0 sm:max-w-28 lg:max-w-75"
|
class="mx-auto h-auto w-full object-contain object-center"
|
||||||
width="328"
|
width="328"
|
||||||
height="443"
|
height="443"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
onclick={() => onchange(option)}
|
onclick={() => onchange(option)}
|
||||||
class={[
|
class={[
|
||||||
'text-xl tracking-wide transition-colors',
|
'text-lg tracking-wide transition-colors',
|
||||||
selected === option ? 'text-ink' : 'text-muted hover:text-ink'
|
selected === option ? 'text-ink' : 'text-muted hover:text-ink'
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import FlowNav from '$lib/components/ui/FlowNav.svelte';
|
import Button from '$lib/components/ui/Button.svelte';
|
||||||
import GrowthMetaphorIllustration from '$lib/components/ui/landing/GrowthMetaphorIllustration.svelte';
|
import GrowthMetaphorIllustration from '$lib/components/ui/landing/GrowthMetaphorIllustration.svelte';
|
||||||
|
|
||||||
function handleStart() {
|
function handleStart() {
|
||||||
@@ -10,13 +10,10 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
class="relative flex min-h-dvh flex-col bg-surface font-sans text-ink"
|
class="relative flex min-h-dvh flex-col bg-surface px-6 py-8 font-sans text-ink sm:px-10 sm:py-10 lg:px-14"
|
||||||
aria-label="Every bouquet starts with a muse — seed to bouquet growth metaphor"
|
aria-label="Every bouquet starts with a muse — seed to bouquet growth metaphor"
|
||||||
>
|
>
|
||||||
<FlowNav showBack={false} continueLabel="Start Creating ->" onContinue={handleStart} />
|
<div class="mx-auto flex w-full max-w-6xl min-h-0 flex-1 flex-col justify-center">
|
||||||
|
|
||||||
<div class="flex flex-1 flex-col px-6 pt-8 pb-8 sm:px-10 sm:pt-10 sm:pb-10 lg:px-14">
|
|
||||||
<div class="mx-auto flex min-h-0 w-full max-w-6xl flex-1 flex-col justify-center">
|
|
||||||
<GrowthMetaphorIllustration />
|
<GrowthMetaphorIllustration />
|
||||||
|
|
||||||
<p class="mt-3 text-left text-sm tracking-[0.18em] text-muted sm:mt-4 sm:text-base">
|
<p class="mt-3 text-left text-sm tracking-[0.18em] text-muted sm:mt-4 sm:text-base">
|
||||||
@@ -24,11 +21,17 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mx-auto w-full max-w-6xl pt-10 pb-2">
|
<!--
|
||||||
<p class="text-lg leading-none tracking-wide text-ink">AI Florist</p>
|
아트워크 하단 선과 동일한 max-w-6xl 기준:
|
||||||
<h1 class="mt-2 text-4xl leading-none font-bold tracking-wide sm:text-5xl lg:text-6xl">
|
Fleumuse — 선 왼쪽 끝 / start creating — 선 오른쪽 끝, y축 가운데 정렬
|
||||||
|
-->
|
||||||
|
<div class="mx-auto flex w-full max-w-6xl items-center justify-between gap-6 pt-10 pb-2">
|
||||||
|
<h1 class="text-4xl leading-none font-bold tracking-wide sm:text-5xl lg:text-6xl">
|
||||||
Fleumuse
|
Fleumuse
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
|
<div class="shrink-0">
|
||||||
|
<Button onclick={handleStart}>start creating</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -52,12 +52,12 @@
|
|||||||
? `<p style="margin:4px 0 0;font-size:12px;color:#666">${escapeHtml(shop.distance)}</p>`
|
? `<p style="margin:4px 0 0;font-size:12px;color:#666">${escapeHtml(shop.distance)}</p>`
|
||||||
: '';
|
: '';
|
||||||
const phone = shop.phone
|
const phone = shop.phone
|
||||||
? `<p style="margin:4px 0 0;font-size:12px;color:#666">${escapeHtml(shop.phone)}</p>`
|
? `<p style="display:block;margin:4px 0 0;font-size:12px;color:#666;word-break:break-all;overflow-wrap:anywhere">${escapeHtml(shop.phone)}</p>`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
return `<div style="padding:15px 12px;min-width:200px;max-width:240px;font-family:system-ui,sans-serif;line-height:1.4">
|
return `<div style="display:block;padding:12px;min-width:0;width:min(260px,72vw);max-width:72vw;box-sizing:border-box;font-family:system-ui,sans-serif;line-height:1.45;overflow:visible;word-wrap:break-word;overflow-wrap:anywhere;white-space:normal">
|
||||||
<p style="margin:0;font-size:15px;font-weight:600;color:#1a1a1a">${escapeHtml(shop.name)}</p>
|
<p style="display:block;margin:0;font-size:15px;font-weight:600;color:#38322f;word-break:break-word;overflow-wrap:anywhere">${escapeHtml(shop.name)}</p>
|
||||||
<p style="margin:4px 0 0;font-size:13px;color:#555">${escapeHtml(shop.address)}</p>
|
<p style="display:block;margin:4px 0 0;font-size:13px;color:#555;word-break:break-word;overflow-wrap:anywhere">${escapeHtml(shop.address)}</p>
|
||||||
${distance}
|
${distance}
|
||||||
${phone}
|
${phone}
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|||||||
@@ -48,8 +48,8 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex min-h-0 flex-1 flex-col">
|
<div class="flex min-h-0 flex-1 flex-col">
|
||||||
<header class="shrink-0 px-6 py-8 md:px-10 lg:px-12 lg:py-10">
|
<header class="shrink-0 px-6 pt-8 pb-3 md:px-10 lg:px-12 lg:pt-10 lg:pb-4">
|
||||||
<h1 class="text-2xl leading-relaxed font-light text-muted md:text-3xl lg:text-[2rem]">
|
<h1 class="text-2xl leading-relaxed font-light text-ink md:text-3xl lg:text-[2rem]">
|
||||||
Find a nearby florist
|
Find a nearby florist
|
||||||
</h1>
|
</h1>
|
||||||
<p class="mt-3 text-sm text-muted">Move the map, then refresh to search this area.</p>
|
<p class="mt-3 text-sm text-muted">Move the map, then refresh to search this area.</p>
|
||||||
@@ -59,7 +59,6 @@
|
|||||||
{#if mock}
|
{#if mock}
|
||||||
<p class="mt-2 text-xs text-muted">Showing sample shops (no Kakao API key).</p>
|
<p class="mt-2 text-xs text-muted">Showing sample shops (no Kakao API key).</p>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="mt-6 border-b border-pill lg:mt-8"></div>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="shrink-0 px-6 pb-4 md:px-10 lg:px-12">
|
<div class="shrink-0 px-6 pb-4 md:px-10 lg:px-12">
|
||||||
@@ -94,7 +93,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-full shrink-0 lg:w-72 lg:overflow-y-auto">
|
<div class="w-full min-w-0 shrink-0 lg:w-72 lg:overflow-y-auto">
|
||||||
{#if loading && shops.length === 0}
|
{#if loading && shops.length === 0}
|
||||||
<p class="text-sm text-muted">Searching for flower shops...</p>
|
<p class="text-sm text-muted">Searching for flower shops...</p>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -2,25 +2,29 @@
|
|||||||
let { shops = [], selectedId = $bindable(null), onselect } = $props();
|
let { shops = [], selectedId = $bindable(null), onselect } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex min-w-0 flex-col gap-2">
|
||||||
{#each shops as shop (shop.id)}
|
{#each shops as shop (shop.id)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => onselect(shop.id)}
|
onclick={() => onselect(shop.id)}
|
||||||
class={[
|
class={[
|
||||||
'rounded border px-4 py-3 text-left transition-colors',
|
'box-border h-auto w-full min-w-0 overflow-hidden rounded border px-4 py-3 text-left transition-colors',
|
||||||
selectedId === shop.id
|
selectedId === shop.id
|
||||||
? 'border-pill bg-pill text-surface'
|
? 'border-pill bg-pill text-surface'
|
||||||
: 'border-line-strong bg-surface text-ink hover:border-ink/30'
|
: 'border-line-strong bg-surface text-ink hover:border-ink/30'
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<p class="text-sm font-medium">{shop.name}</p>
|
<p class="text-sm leading-snug font-medium [overflow-wrap:anywhere] break-words">
|
||||||
<p class="mt-1 text-xs opacity-80">{shop.address}</p>
|
{shop.name}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs leading-snug [overflow-wrap:anywhere] break-words opacity-80">
|
||||||
|
{shop.address}
|
||||||
|
</p>
|
||||||
{#if shop.distance}
|
{#if shop.distance}
|
||||||
<p class="mt-1 text-xs opacity-70">{shop.distance}</p>
|
<p class="mt-1 text-xs leading-snug break-words opacity-70">{shop.distance}</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if selectedId === shop.id && shop.phone}
|
{#if selectedId === shop.id && shop.phone}
|
||||||
<p class="mt-1 text-xs opacity-70">{shop.phone}</p>
|
<p class="mt-1 text-xs leading-snug break-all opacity-70">{shop.phone}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -65,10 +65,30 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="moodboard min-h-0 w-full flex-1">
|
<div class="moodboard min-h-0 w-full flex-1">
|
||||||
<UploadTile label="Color" bind:file={colorFile} class="tile tile-color" />
|
<UploadTile
|
||||||
<UploadTile label="Season" bind:file={seasonFile} class="tile tile-season" />
|
label="Fashion"
|
||||||
<UploadTile label="Character" bind:file={characterFile} class="tile tile-character" />
|
prompt="What outfit reminds you of them?"
|
||||||
<UploadTile label="Location" bind:file={locationFile} class="tile tile-location" />
|
bind:file={colorFile}
|
||||||
|
class="tile tile-color"
|
||||||
|
/>
|
||||||
|
<UploadTile
|
||||||
|
label="Season"
|
||||||
|
prompt="What season reminds you of them?"
|
||||||
|
bind:file={seasonFile}
|
||||||
|
class="tile tile-season"
|
||||||
|
/>
|
||||||
|
<UploadTile
|
||||||
|
label="Location"
|
||||||
|
prompt="What place matches their vibe?"
|
||||||
|
bind:file={characterFile}
|
||||||
|
class="tile tile-character"
|
||||||
|
/>
|
||||||
|
<UploadTile
|
||||||
|
label="Cafe"
|
||||||
|
prompt="What kind of cafe feels like them?"
|
||||||
|
bind:file={locationFile}
|
||||||
|
class="tile tile-location"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -40,7 +40,11 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="feed min-h-0 w-full flex-1">
|
<div class="feed min-h-0 w-full flex-1">
|
||||||
<UploadTile bind:file={firstFile} class="sns-tile" />
|
<UploadTile
|
||||||
|
prompt="Upload screenshots of their social feed"
|
||||||
|
bind:file={firstFile}
|
||||||
|
class="sns-tile"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
// both the moodboard and the SNS feed.
|
// both the moodboard and the SNS feed.
|
||||||
let {
|
let {
|
||||||
label = null,
|
label = null,
|
||||||
|
/** 빈 타일 중앙에 표시할 안내 문장 */
|
||||||
|
prompt = null,
|
||||||
showLabel = true,
|
showLabel = true,
|
||||||
class: klass = '',
|
class: klass = '',
|
||||||
style = '',
|
style = '',
|
||||||
@@ -44,7 +46,7 @@
|
|||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
class="sr-only"
|
class="sr-only"
|
||||||
aria-label={label ? `Add a ${label} image` : 'Add an image'}
|
aria-label={prompt ?? (label ? `Add a ${label} image` : 'Add an image')}
|
||||||
onchange={pick}
|
onchange={pick}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -63,13 +65,15 @@
|
|||||||
</span>
|
</span>
|
||||||
{:else}
|
{:else}
|
||||||
<div
|
<div
|
||||||
class="flex flex-col items-center gap-3 text-subtle transition-transform group-hover:scale-105"
|
class="flex flex-col items-center gap-3 px-4 text-center text-subtle transition-transform group-hover:scale-105"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="flex size-10 items-center justify-center rounded-full border border-current text-xl leading-none"
|
class="flex size-10 shrink-0 items-center justify-center rounded-full border border-current text-xl leading-none"
|
||||||
aria-hidden="true">+</span
|
aria-hidden="true">+</span
|
||||||
>
|
>
|
||||||
{#if label && showLabel}
|
{#if prompt}
|
||||||
|
<span class="max-w-[14rem] text-sm leading-snug text-muted">{prompt}</span>
|
||||||
|
{:else if label && showLabel}
|
||||||
<span class="text-sm tracking-[0.15em] uppercase">{label}</span>
|
<span class="text-sm tracking-[0.15em] uppercase">{label}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
178
src/lib/flowerFlow/areaEditIntent.js
Normal file
178
src/lib/flowerFlow/areaEditIntent.js
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
/** @typedef {'ribbon/bow' | 'wrapping paper' | 'flower' | 'leaf' | 'stem' | 'selected object'} AreaEditTarget */
|
||||||
|
|
||||||
|
/** @typedef {{ x: number, y: number }} PercentPoint */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} AreaEditIntent
|
||||||
|
* @property {AreaEditTarget} targetObject
|
||||||
|
* @property {string} normalizedPrompt
|
||||||
|
* @property {boolean} isColorChange
|
||||||
|
* @property {PercentPoint} selectionCentroid
|
||||||
|
* @property {{ minX: number, maxX: number, minY: number, maxY: number }} selectionBounds
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {PercentPoint[]} selection
|
||||||
|
* @returns {{ minX: number, maxX: number, minY: number, maxY: number, centroid: PercentPoint }}
|
||||||
|
*/
|
||||||
|
export function analyzeSelectionGeometry(selection) {
|
||||||
|
let minX = 100;
|
||||||
|
let maxX = 0;
|
||||||
|
let minY = 100;
|
||||||
|
let maxY = 0;
|
||||||
|
let sumX = 0;
|
||||||
|
let sumY = 0;
|
||||||
|
|
||||||
|
for (const point of selection) {
|
||||||
|
minX = Math.min(minX, point.x);
|
||||||
|
maxX = Math.max(maxX, point.x);
|
||||||
|
minY = Math.min(minY, point.y);
|
||||||
|
maxY = Math.max(maxY, point.y);
|
||||||
|
sumX += point.x;
|
||||||
|
sumY += point.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = Math.max(selection.length, 1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
minX,
|
||||||
|
maxX,
|
||||||
|
minY,
|
||||||
|
maxY,
|
||||||
|
centroid: { x: sumX / count, y: sumY / count }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} userPrompt
|
||||||
|
*/
|
||||||
|
export function isVagueColorChangePrompt(userPrompt) {
|
||||||
|
const lower = userPrompt.trim().toLowerCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
/color|colour|색|톤|tone|hue|shade|tint|darken|lighten|black|white|red|blue|green|pink|gold|silver|ivory|navy|beige|brown|gray|grey|다른|변경|바꿔|바꾸|칠|색상/.test(
|
||||||
|
lower
|
||||||
|
) &&
|
||||||
|
!/replace|swap|add|remove|delete|more|less|volume|romantic|warm|bigger|smaller|꽃|flower|rose|tulip|ribbon|bow|wrapping|paper|leaf|stem|리본|포장|줄기|잎/.test(
|
||||||
|
lower
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} userPrompt
|
||||||
|
*/
|
||||||
|
export function isColorChangePrompt(userPrompt) {
|
||||||
|
const lower = userPrompt.trim().toLowerCase();
|
||||||
|
return /color|colour|색|톤|tone|hue|shade|tint|darken|lighten|black|white|red|blue|green|pink|gold|silver|ivory|navy|beige|brown|gray|grey|다른|변경|바꿔|바꾸|칠|색상/.test(
|
||||||
|
lower
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} userPrompt
|
||||||
|
* @param {PercentPoint} centroid
|
||||||
|
* @returns {AreaEditTarget | null}
|
||||||
|
*/
|
||||||
|
function inferTargetFromPromptAndPosition(userPrompt, centroid) {
|
||||||
|
const lower = userPrompt.trim().toLowerCase();
|
||||||
|
|
||||||
|
if (/ribbon|bow|리본|보우/.test(lower)) return 'ribbon/bow';
|
||||||
|
if (/wrapping|wrap paper|paper wrap|포장|싸개|포장지/.test(lower)) return 'wrapping paper';
|
||||||
|
if (/flower|bloom|petal|rose|tulip|꽃|장미|튤립/.test(lower)) return 'flower';
|
||||||
|
if (/leaf|foliage|greenery|잎|잎사귀|그린/.test(lower)) return 'leaf';
|
||||||
|
if (/stem|줄기|가지/.test(lower)) return 'stem';
|
||||||
|
|
||||||
|
if (centroid.y >= 58 && centroid.x >= 30 && centroid.x <= 70 && isColorChangePrompt(userPrompt)) {
|
||||||
|
return 'ribbon/bow';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (centroid.y <= 45) return 'flower';
|
||||||
|
if (centroid.y >= 70 && centroid.x >= 25 && centroid.x <= 75) return 'wrapping paper';
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {AreaEditTarget} targetObject
|
||||||
|
*/
|
||||||
|
function materialPreserveSuffix(targetObject) {
|
||||||
|
if (targetObject === 'ribbon/bow') {
|
||||||
|
return ' Preserve the ribbon folds, highlights, shadows, and fabric texture.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetObject === 'wrapping paper') {
|
||||||
|
return ' Preserve paper creases, shadows, and wrapping shape.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetObject === 'flower' || targetObject === 'leaf' || targetObject === 'stem') {
|
||||||
|
return ' Preserve natural petal or plant texture, shadows, and edges.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return ' Preserve the object shape, texture, shadows, and highlights.';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} userPrompt
|
||||||
|
* @param {AreaEditTarget} targetObject
|
||||||
|
*/
|
||||||
|
export function normalizeAreaEditPrompt(userPrompt, targetObject) {
|
||||||
|
const trimmed = userPrompt.trim();
|
||||||
|
const lower = trimmed.toLowerCase();
|
||||||
|
const label = targetObject === 'selected object' ? 'selected object' : `the ${targetObject}`;
|
||||||
|
|
||||||
|
const blackMatch = lower.match(
|
||||||
|
/(?:change|make|turn|set|to|into|색.*?)?\s*(?:color|colour|색)?\s*(?:to|into|as|를|을)?\s*black|검정|검은/
|
||||||
|
);
|
||||||
|
if (blackMatch) {
|
||||||
|
return `Change only ${label} color to black.${materialPreserveSuffix(targetObject)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/use other color|different color|another color|other colour|different colour|다른 색|다른색|색 바꿔|색상 변경/.test(lower)) {
|
||||||
|
return `Change only ${label} to a different harmonious color.${materialPreserveSuffix(targetObject)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^change color$|^change colour$|^change the color$|^change the colour$|^색 변경$|^색 바꿔$/.test(lower)) {
|
||||||
|
return `Change only ${label} color.${materialPreserveSuffix(targetObject)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isVagueColorChangePrompt(trimmed)) {
|
||||||
|
return `Change only ${label} color.${materialPreserveSuffix(targetObject)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetObject === 'ribbon/bow' && isColorChangePrompt(trimmed)) {
|
||||||
|
return `Change only ${label} color as requested: ${trimmed}.${materialPreserveSuffix(targetObject)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{
|
||||||
|
* userPrompt: string,
|
||||||
|
* selection: PercentPoint[],
|
||||||
|
* imageWidth?: number,
|
||||||
|
* imageHeight?: number
|
||||||
|
* }} options
|
||||||
|
* @returns {AreaEditIntent}
|
||||||
|
*/
|
||||||
|
export function inferAreaEditTarget(options) {
|
||||||
|
const { userPrompt, selection } = options;
|
||||||
|
const geometry = analyzeSelectionGeometry(selection);
|
||||||
|
const inferred = inferTargetFromPromptAndPosition(userPrompt, geometry.centroid);
|
||||||
|
const targetObject = inferred ?? 'selected object';
|
||||||
|
const normalizedPrompt = normalizeAreaEditPrompt(userPrompt, targetObject);
|
||||||
|
|
||||||
|
return {
|
||||||
|
targetObject,
|
||||||
|
normalizedPrompt,
|
||||||
|
isColorChange: isColorChangePrompt(userPrompt),
|
||||||
|
selectionCentroid: geometry.centroid,
|
||||||
|
selectionBounds: {
|
||||||
|
minX: geometry.minX,
|
||||||
|
maxX: geometry.maxX,
|
||||||
|
minY: geometry.minY,
|
||||||
|
maxY: geometry.maxY
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,6 +5,14 @@ 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.';
|
||||||
|
|
||||||
|
/** 최초 generate prompt opening — catalog 톤·장면 설정 */
|
||||||
|
export const BOUQUET_CATALOG_SCENE_PROMPT =
|
||||||
|
'A professional florist product photograph of a handcrafted bouquet, photographed for a premium flower shop catalog.';
|
||||||
|
|
||||||
|
/** generate + whole edit 공통 — 인물/손 노출 방지 */
|
||||||
|
export const BOUQUET_NO_PERSON_CONSTRAINT =
|
||||||
|
'Bouquet only. No person. No hands. No body parts visible.';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {{ mainFlowers?: string[], subFlowers?: string[], greenery?: string[] }} recipe
|
* @param {{ mainFlowers?: string[], subFlowers?: string[], greenery?: string[] }} recipe
|
||||||
*/
|
*/
|
||||||
@@ -47,6 +55,7 @@ export function formatStrictRecipeConstraints(recipe) {
|
|||||||
'- Include EVERY listed flower without omission — each must be clearly visible; none may be missing, hidden, or left out',
|
'- Include EVERY listed flower without omission — each must be clearly visible; none may be missing, hidden, or left out',
|
||||||
'- Do not swap or substitute any listed species unless the edit request explicitly requires that change',
|
'- Do not swap or substitute any listed species unless the edit request explicitly requires that change',
|
||||||
'- Real cut flowers only; no fantasy colors or impossible hybrids',
|
'- Real cut flowers only; no fantasy colors or impossible hybrids',
|
||||||
|
`- ${BOUQUET_NO_PERSON_CONSTRAINT}`,
|
||||||
`- ${BOUQUET_IMAGE_ASPECT_PROMPT}`,
|
`- ${BOUQUET_IMAGE_ASPECT_PROMPT}`,
|
||||||
'- White background, soft natural lighting, front-facing, orderable from a real Korean florist'
|
'- White background, soft natural lighting, front-facing, orderable from a real Korean florist'
|
||||||
].join('\n');
|
].join('\n');
|
||||||
@@ -60,6 +69,7 @@ export function formatStrictRecipeConstraints(recipe) {
|
|||||||
export function formatStrictBouquetImagePrompt(recipe) {
|
export function formatStrictBouquetImagePrompt(recipe) {
|
||||||
return [
|
return [
|
||||||
'Generate a realistic Korean florist bouquet product photo.',
|
'Generate a realistic Korean florist bouquet product photo.',
|
||||||
|
BOUQUET_CATALOG_SCENE_PROMPT,
|
||||||
'',
|
'',
|
||||||
formatStrictRecipeConstraints(recipe)
|
formatStrictRecipeConstraints(recipe)
|
||||||
].join('\n');
|
].join('\n');
|
||||||
@@ -72,37 +82,40 @@ export function formatStrictBouquetImagePrompt(recipe) {
|
|||||||
* mode?: string,
|
* mode?: string,
|
||||||
* selection?: Array<{ x: number, y: number }>,
|
* selection?: Array<{ x: number, y: number }>,
|
||||||
* recipe?: { mainFlowers?: string[], subFlowers?: string[], greenery?: string[] },
|
* recipe?: { mainFlowers?: string[], subFlowers?: string[], greenery?: string[] },
|
||||||
* recipeChanged?: boolean
|
* recipeChanged?: boolean,
|
||||||
|
* targetObject?: string,
|
||||||
|
* normalizedPrompt?: string
|
||||||
* }} options
|
* }} options
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
export function formatBouquetEditPrompt(options) {
|
export function formatBouquetEditPrompt(options) {
|
||||||
const { userPrompt, mode, selection, recipe, recipeChanged = false } = options;
|
const {
|
||||||
|
userPrompt,
|
||||||
|
mode,
|
||||||
|
selection,
|
||||||
|
recipe,
|
||||||
|
recipeChanged = false,
|
||||||
|
targetObject = 'selected object',
|
||||||
|
normalizedPrompt = userPrompt
|
||||||
|
} = options;
|
||||||
const isAreaEdit = mode === 'area' && selection && selection.length >= 3;
|
const isAreaEdit = mode === 'area' && selection && selection.length >= 3;
|
||||||
|
|
||||||
if (isAreaEdit) {
|
if (isAreaEdit) {
|
||||||
return [
|
return [
|
||||||
'You are editing the attached florist bouquet photograph with a binary mask.',
|
'You are editing a realistic bouquet product photo.',
|
||||||
'This is a localized inpainting edit — NOT a full bouquet redesign or re-render.',
|
'The transparent mask is only a rough localization guide.',
|
||||||
|
'Do NOT fill the entire masked shape.',
|
||||||
|
'Identify the actual object inside the selected area that matches the user request.',
|
||||||
|
"Edit only that object's visible surface.",
|
||||||
|
"Preserve the object's original shape, folds, shadows, highlights, texture, and boundaries.",
|
||||||
|
'Preserve all unselected objects exactly: flowers, leaves, stems, wrapping paper, background, lighting, and composition.',
|
||||||
|
'If the request is a color change, recolor only the target object material while keeping realistic shading.',
|
||||||
|
'Do not add new flowers, new ribbon, new objects, text, hands, or people.',
|
||||||
|
'Do not paint a flat solid color block inside the mask.',
|
||||||
'',
|
'',
|
||||||
`Edit request (masked region only): ${userPrompt}`,
|
`User request: ${userPrompt}`,
|
||||||
'',
|
`Inferred target object: ${targetObject}`,
|
||||||
'How to edit inside the mask:',
|
`Normalized edit instruction: ${normalizedPrompt}`,
|
||||||
'- Apply the edit request only to whatever is inside the transparent mask region (flowers, ribbon, wrapping, foliage, etc.)',
|
|
||||||
'- The request may be a color/style tweak OR a content swap — e.g. replace blooms in this area with roses, change ribbon color, adjust wrapping',
|
|
||||||
'- When swapping flowers inside the mask, render the requested species naturally in that region; blend stems, lighting, and edges with the surrounding bouquet',
|
|
||||||
'- Keep realistic material detail — petal texture, fabric folds, paper creases, shadows, and lighting — seamless with the rest of the photo',
|
|
||||||
'- Do not paste a flat color block; the edited area should look naturally photographed',
|
|
||||||
'- Do not use solid black unless the user explicitly asked for black',
|
|
||||||
'',
|
|
||||||
'Mask rules (mandatory):',
|
|
||||||
'- Transparent pixels in the attached mask = the ONLY area you may change',
|
|
||||||
'- Opaque pixels in the mask = leave completely unchanged',
|
|
||||||
'- Do NOT recolor, restyle, brighten, blur, regenerate, or swap species outside the mask',
|
|
||||||
'- Do NOT apply the edit request to the whole image',
|
|
||||||
'',
|
|
||||||
'Preserve everywhere outside the mask:',
|
|
||||||
'- All flowers, foliage, wrapping, ribbon, background, lighting, and framing exactly as in the input photo',
|
|
||||||
'',
|
'',
|
||||||
'Output exactly one edited photo. No before/after collage.'
|
'Output exactly one edited photo. No before/after collage.'
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|||||||
@@ -10,12 +10,254 @@
|
|||||||
|
|
||||||
const EMPTY_LOCALE = /** @type {OrderMessageLocale} */ ({ plainText: '', segments: [] });
|
const EMPTY_LOCALE = /** @type {OrderMessageLocale} */ ({ plainText: '', segments: [] });
|
||||||
|
|
||||||
|
/** @type {Record<string, string>} */
|
||||||
|
const MOOD_EN_TO_KO_STEM = {
|
||||||
|
warm: '따뜻',
|
||||||
|
romantic: '로맨틱',
|
||||||
|
gentle: '부드러',
|
||||||
|
soft: '부드러',
|
||||||
|
elegant: '우아',
|
||||||
|
vibrant: '생기 있는',
|
||||||
|
sentimental: '감성적',
|
||||||
|
cozy: '아늑',
|
||||||
|
fresh: '싱그러',
|
||||||
|
natural: '자연스러',
|
||||||
|
cheerful: '밝',
|
||||||
|
calm: '차분',
|
||||||
|
dreamy: '몽환적',
|
||||||
|
bold: '선명',
|
||||||
|
delicate: '섬세',
|
||||||
|
minimal: '미니멀',
|
||||||
|
classic: '클래식',
|
||||||
|
playful: '발랄',
|
||||||
|
modern: '모던',
|
||||||
|
organic: '내추럴',
|
||||||
|
pastel: '파스텔톤',
|
||||||
|
muted: '차분',
|
||||||
|
lively: '활기찬'
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @type {Record<string, string>} */
|
||||||
|
const MOOD_KO_STEM_TO_EN = {
|
||||||
|
따뜻: 'warm',
|
||||||
|
로맨틱: 'romantic',
|
||||||
|
부드러: 'gentle',
|
||||||
|
우아: 'elegant',
|
||||||
|
'생기 있는': 'vibrant',
|
||||||
|
감성적: 'sentimental',
|
||||||
|
아늑: 'cozy',
|
||||||
|
싱그러: 'fresh',
|
||||||
|
자연스러: 'natural',
|
||||||
|
밝: 'cheerful',
|
||||||
|
차분: 'calm',
|
||||||
|
몽환적: 'dreamy',
|
||||||
|
선명: 'bold',
|
||||||
|
섬세: 'delicate',
|
||||||
|
미니멀: 'minimal',
|
||||||
|
클래식: 'classic',
|
||||||
|
발랄: 'playful',
|
||||||
|
모던: 'modern',
|
||||||
|
내추럴: 'natural',
|
||||||
|
파스텔톤: 'pastel',
|
||||||
|
활기찬: 'lively'
|
||||||
|
};
|
||||||
|
|
||||||
|
const HANGUL_RE = /[\u3131-\u318E\uAC00-\uD7A3]/;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string[]} [items]
|
* @param {string} value
|
||||||
* @param {string} fallback
|
|
||||||
*/
|
*/
|
||||||
function joinKeywords(items, fallback) {
|
function isHangul(value) {
|
||||||
return items?.length ? items.slice(0, 4).join(', ') : fallback;
|
return HANGUL_RE.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} relationship
|
||||||
|
* @param {string | undefined} style
|
||||||
|
*/
|
||||||
|
function relationshipPhraseEn(relationship, style) {
|
||||||
|
const who = relationship?.trim() || 'Someone special';
|
||||||
|
const styleLower = style?.toLowerCase();
|
||||||
|
|
||||||
|
switch (who) {
|
||||||
|
case 'Friend':
|
||||||
|
return 'my friend';
|
||||||
|
case 'Family':
|
||||||
|
return 'my family';
|
||||||
|
case 'Partner':
|
||||||
|
if (styleLower === 'feminine') return 'my girlfriend';
|
||||||
|
if (styleLower === 'masculine') return 'my boyfriend';
|
||||||
|
return 'my partner';
|
||||||
|
case 'Teacher':
|
||||||
|
return 'my teacher';
|
||||||
|
case 'Others':
|
||||||
|
return 'someone special';
|
||||||
|
default:
|
||||||
|
return isHangul(who) ? who : `my ${who.toLowerCase()}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} relationship
|
||||||
|
* @param {string | undefined} style
|
||||||
|
*/
|
||||||
|
function relationshipPhraseKo(relationship, style) {
|
||||||
|
const who = relationship?.trim() || '소중한 사람';
|
||||||
|
const styleLower = style?.toLowerCase();
|
||||||
|
|
||||||
|
if (isHangul(who)) return who;
|
||||||
|
|
||||||
|
switch (who) {
|
||||||
|
case 'Friend':
|
||||||
|
return '친구';
|
||||||
|
case 'Family':
|
||||||
|
return '가족';
|
||||||
|
case 'Partner':
|
||||||
|
if (styleLower === 'feminine') return '여자친구';
|
||||||
|
if (styleLower === 'masculine') return '남자친구';
|
||||||
|
return '연인';
|
||||||
|
case 'Teacher':
|
||||||
|
return '선생님';
|
||||||
|
case 'Others':
|
||||||
|
return '소중한 사람';
|
||||||
|
default:
|
||||||
|
return who;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number | undefined} won
|
||||||
|
*/
|
||||||
|
function formatBudgetKo(won) {
|
||||||
|
if (!won || !Number.isFinite(won)) return '유연한 예산';
|
||||||
|
if (won >= 10_000 && won % 10_000 === 0) return `${won / 10_000}만원`;
|
||||||
|
return `₩${won.toLocaleString('ko-KR')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number | undefined} won
|
||||||
|
*/
|
||||||
|
function formatBudgetEn(won) {
|
||||||
|
if (!won || !Number.isFinite(won)) return 'a flexible budget';
|
||||||
|
const dollars = Math.max(1, Math.round(won / 1_400));
|
||||||
|
return `$${dollars}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} keyword
|
||||||
|
*/
|
||||||
|
function toKoMoodStem(keyword) {
|
||||||
|
const trimmed = keyword.trim();
|
||||||
|
if (!trimmed) return '';
|
||||||
|
|
||||||
|
if (isHangul(trimmed)) {
|
||||||
|
return trimmed.replace(/적인$/, '적').replace(/한$/, '').replace(/운$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
return MOOD_EN_TO_KO_STEM[trimmed.toLowerCase()] ?? trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} stem
|
||||||
|
*/
|
||||||
|
function finalizeKoMoodStem(stem) {
|
||||||
|
if (!stem) return '';
|
||||||
|
if (stem.endsWith('적')) return `${stem}인`;
|
||||||
|
if (stem.endsWith(' 있는')) return `${stem}`;
|
||||||
|
return `${stem}한`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} keyword
|
||||||
|
*/
|
||||||
|
function toEnMoodKeyword(keyword) {
|
||||||
|
const trimmed = keyword.trim();
|
||||||
|
if (!trimmed) return '';
|
||||||
|
|
||||||
|
if (isHangul(trimmed)) {
|
||||||
|
const stem = trimmed.replace(/적인$/, '적').replace(/한$/, '').replace(/운$/, '');
|
||||||
|
return MOOD_KO_STEM_TO_EN[stem] ?? trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string[]} keywords
|
||||||
|
* @param {'en' | 'ko'} lang
|
||||||
|
*/
|
||||||
|
function buildMoodPhrase(keywords, lang) {
|
||||||
|
const picked = keywords.map((item) => item.trim()).filter(Boolean).slice(0, 2);
|
||||||
|
|
||||||
|
if (!picked.length) {
|
||||||
|
return lang === 'ko' ? '따뜻하고 감성적인' : 'warm and romantic';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lang === 'ko') {
|
||||||
|
const stems = picked.map((keyword) => toKoMoodStem(keyword)).filter(Boolean);
|
||||||
|
if (!stems.length) return '따뜻하고 감성적인';
|
||||||
|
if (stems.length === 1) return finalizeKoMoodStem(stems[0]);
|
||||||
|
return `${stems[0]}하고 ${finalizeKoMoodStem(stems[1])}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const translated = picked.map((keyword) => toEnMoodKeyword(keyword)).filter(Boolean);
|
||||||
|
if (!translated.length) return 'warm and romantic';
|
||||||
|
return translated.length === 1 ? translated[0] : `${translated[0]} and ${translated[1]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {MoodAnalysis | null | undefined} moodAnalysis
|
||||||
|
* @param {BouquetRecipe | null | undefined} recipe
|
||||||
|
*/
|
||||||
|
function collectMoodKeywords(moodAnalysis, recipe) {
|
||||||
|
return [
|
||||||
|
...(moodAnalysis?.moodKeywords ?? []),
|
||||||
|
...(moodAnalysis?.styleImpression ?? []),
|
||||||
|
...(moodAnalysis?.textureKeywords ?? []),
|
||||||
|
...(recipe?.colors?.slice(0, 1) ?? [])
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} plainText
|
||||||
|
* @param {string[]} highlights
|
||||||
|
* @returns {OrderMessageSegment[]}
|
||||||
|
*/
|
||||||
|
function buildSegments(plainText, highlights) {
|
||||||
|
const unique = [...new Set(highlights.filter(Boolean))].sort((a, b) => b.length - a.length);
|
||||||
|
if (!unique.length) return [{ text: plainText, highlight: false }];
|
||||||
|
|
||||||
|
/** @type {OrderMessageSegment[]} */
|
||||||
|
const segments = [];
|
||||||
|
let cursor = 0;
|
||||||
|
|
||||||
|
while (cursor < plainText.length) {
|
||||||
|
let nextIndex = -1;
|
||||||
|
let matched = '';
|
||||||
|
|
||||||
|
for (const term of unique) {
|
||||||
|
const index = plainText.indexOf(term, cursor);
|
||||||
|
if (index === -1) continue;
|
||||||
|
if (nextIndex === -1 || index < nextIndex) {
|
||||||
|
nextIndex = index;
|
||||||
|
matched = term;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextIndex === -1 || !matched) {
|
||||||
|
segments.push({ text: plainText.slice(cursor), highlight: false });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextIndex > cursor) {
|
||||||
|
segments.push({ text: plainText.slice(cursor, nextIndex), highlight: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
segments.push({ text: matched, highlight: true });
|
||||||
|
cursor = nextIndex + matched.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments.length ? segments : [{ text: plainText, highlight: false }];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,72 +275,35 @@ export function buildFloristOrderMessage(input) {
|
|||||||
return { ...EMPTY_LOCALE, ko: { ...EMPTY_LOCALE } };
|
return { ...EMPTY_LOCALE, ko: { ...EMPTY_LOCALE } };
|
||||||
}
|
}
|
||||||
|
|
||||||
const relationship = userInput?.relationship ?? 'someone special';
|
const relationship = userInput?.relationship ?? 'Someone special';
|
||||||
const occasion = userInput?.occasion ?? 'a special occasion';
|
const style = userInput?.style;
|
||||||
const budget = userInput?.budget
|
const budget = userInput?.budget;
|
||||||
? `₩${Number(userInput.budget).toLocaleString('en-US')}`
|
|
||||||
: 'a flexible range';
|
|
||||||
|
|
||||||
const moodFeel = joinKeywords(
|
const moodKeywords = collectMoodKeywords(moodAnalysis, recipe);
|
||||||
[
|
const moodEn = buildMoodPhrase(moodKeywords, 'en');
|
||||||
...(moodAnalysis?.moodKeywords ?? []),
|
const moodKo = buildMoodPhrase(moodKeywords, 'ko');
|
||||||
...(moodAnalysis?.styleImpression ?? []),
|
|
||||||
...(moodAnalysis?.textureKeywords ?? [])
|
|
||||||
],
|
|
||||||
'gentle and warm'
|
|
||||||
);
|
|
||||||
|
|
||||||
const colorTone = joinKeywords(
|
const relEn = relationshipPhraseEn(relationship, style);
|
||||||
[...(moodAnalysis?.colorPalette ?? []), ...(recipe?.colors ?? [])],
|
const relKo = relationshipPhraseKo(relationship, style);
|
||||||
'soft natural'
|
const budgetEn = formatBudgetEn(budget);
|
||||||
);
|
const budgetKo = formatBudgetKo(budget);
|
||||||
|
|
||||||
const plainText =
|
const plainText =
|
||||||
`Hello, I'd like to inquire about a flower order. ` +
|
`Hi! I'd like to order a bouquet for ${relEn}. ` +
|
||||||
`It's a bouquet for ${relationship} for ${occasion}, with a budget around ${budget}. ` +
|
`My budget is around ${budgetEn}, and I'm looking for something ${moodEn}. ` +
|
||||||
`I'd like to gift something with a ${moodFeel} feel, using ${colorTone} tones. ` +
|
`Would it be possible to create a bouquet inspired by the reference images below?`;
|
||||||
`Would a reservation be possible?`;
|
|
||||||
|
|
||||||
const segments = [
|
|
||||||
{
|
|
||||||
text: "Hello, I'd like to inquire about a flower order. It's a bouquet for ",
|
|
||||||
highlight: false
|
|
||||||
},
|
|
||||||
{ text: relationship, highlight: true },
|
|
||||||
{ text: ' for ', highlight: false },
|
|
||||||
{ text: occasion, highlight: true },
|
|
||||||
{ text: ', with a budget around ', highlight: false },
|
|
||||||
{ text: budget, highlight: true },
|
|
||||||
{ text: ". I'd like to gift something with a ", highlight: false },
|
|
||||||
{ text: moodFeel, highlight: true },
|
|
||||||
{ text: ' feel, using ', highlight: false },
|
|
||||||
{ text: colorTone, highlight: true },
|
|
||||||
{ text: ' tones. Would a reservation be possible?', highlight: false }
|
|
||||||
];
|
|
||||||
|
|
||||||
const koPlainText =
|
const koPlainText =
|
||||||
`안녕하세요, 꽃 주문 문의드립니다. ` +
|
`안녕하세요! ${relKo}에게 선물할 꽃다발 주문하려고 합니다. ` +
|
||||||
`${relationship}에게 ${occasion} 꽃다발을 준비하고 싶습니다. 예산은 약 ${budget}입니다. ` +
|
`예산은 ${budgetKo} 정도이고, ${moodKo} 무드로 제작 부탁드립니다. ` +
|
||||||
`${moodFeel}한 분위기로, ${colorTone} 톤으로 선물하고 싶습니다. ` +
|
`아래 레퍼런스 이미지와 비슷한 느낌으로 가능할까요?`;
|
||||||
`예약 가능할까요?`;
|
|
||||||
|
|
||||||
const koSegments = [
|
|
||||||
{ text: '안녕하세요, 꽃 주문 문의드립니다. ', highlight: false },
|
|
||||||
{ text: relationship, highlight: true },
|
|
||||||
{ text: '에게 ', highlight: false },
|
|
||||||
{ text: occasion, highlight: true },
|
|
||||||
{ text: ' 꽃다발을 준비하고 싶습니다. 예산은 약 ', highlight: false },
|
|
||||||
{ text: budget, highlight: true },
|
|
||||||
{ text: '입니다. ', highlight: false },
|
|
||||||
{ text: moodFeel, highlight: true },
|
|
||||||
{ text: '한 분위기로, ', highlight: false },
|
|
||||||
{ text: colorTone, highlight: true },
|
|
||||||
{ text: ' 톤으로 선물하고 싶습니다. 예약 가능할까요?', highlight: false }
|
|
||||||
];
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
plainText,
|
plainText,
|
||||||
segments,
|
segments: buildSegments(plainText, [relEn, budgetEn, moodEn]),
|
||||||
ko: { plainText: koPlainText, segments: koSegments }
|
ko: {
|
||||||
|
plainText: koPlainText,
|
||||||
|
segments: buildSegments(koPlainText, [relKo, budgetKo, moodKo])
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -170,42 +170,26 @@ export function truncateDescription(text, maxLength = 140) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One-line context for the map order card (mood, recipient, or recipe concept).
|
* Map DescriptionCard — 1~2줄 작품 설명 (꽃 종류 + 넓은 꽃말 테마).
|
||||||
* @param {{ moodKeywords?: string[], styleImpression?: string[] } | null | undefined} moodAnalysis
|
* @param {string | null | undefined} word
|
||||||
* @param {{ relationship?: string, notes?: string } | null | undefined} userInput
|
* @returns {string | null}
|
||||||
* @param {{ concept?: string } | null | undefined} recipe
|
|
||||||
*/
|
*/
|
||||||
function buildMapOrderIntro(moodAnalysis, userInput, recipe) {
|
function broadFlowerTheme(word) {
|
||||||
const recipient = userInput?.relationship?.trim();
|
if (!word?.trim()) return null;
|
||||||
const mood = pickKeywords(
|
const w = word.toLowerCase();
|
||||||
[...(moodAnalysis?.moodKeywords ?? []), ...(moodAnalysis?.styleImpression ?? [])],
|
if (/love|romance|passion|devotion|사랑|연애|로맨/.test(w)) return 'love';
|
||||||
2
|
if (/friend|companionship|우정|친구/.test(w)) return 'friendship';
|
||||||
);
|
if (/health|healing|vitality|건강|회복/.test(w)) return 'health';
|
||||||
const hasCardMessage = Boolean(extractCardMessage(userInput));
|
if (/thank|grateful|감사/.test(w)) return 'gratitude';
|
||||||
|
if (/joy|happy|celebrat|cheer|기쁨|축하/.test(w)) return 'joy';
|
||||||
if (hasCardMessage && recipient) {
|
if (/hope|faith|희망/.test(w)) return 'hope';
|
||||||
return `A bouquet for ${recipient}, shaped around your card message`;
|
if (/warm|tender|gentle|따뜻|부드러/.test(w)) return 'warmth';
|
||||||
}
|
if (/peace|calm|평화|차분/.test(w)) return 'peace';
|
||||||
if (hasCardMessage) {
|
return null;
|
||||||
return 'A bouquet shaped around your card message';
|
|
||||||
}
|
|
||||||
if (mood && recipient) {
|
|
||||||
return `A ${mood} bouquet for ${recipient}`;
|
|
||||||
}
|
|
||||||
if (mood) {
|
|
||||||
return `A ${mood} bouquet from your moodboard`;
|
|
||||||
}
|
|
||||||
if (recipe?.concept?.trim()) {
|
|
||||||
return recipe.concept.trim();
|
|
||||||
}
|
|
||||||
if (recipient) {
|
|
||||||
return `A custom bouquet for ${recipient}`;
|
|
||||||
}
|
|
||||||
return 'Your custom bouquet design';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map order card — short intro plus flower species (main → sub → greenery, capped).
|
* Map order card — brief artwork-style blurb (flowers + broad themes, max ~2 lines).
|
||||||
* @param {{ mainFlowers?: string[], subFlowers?: string[], greenery?: string[], concept?: string } | null | undefined} recipe
|
* @param {{ mainFlowers?: string[], subFlowers?: string[], greenery?: string[], concept?: string } | null | undefined} recipe
|
||||||
* @param {{
|
* @param {{
|
||||||
* moodAnalysis?: { moodKeywords?: string[], styleImpression?: string[] } | null,
|
* moodAnalysis?: { moodKeywords?: string[], styleImpression?: string[] } | null,
|
||||||
@@ -214,16 +198,30 @@ function buildMapOrderIntro(moodAnalysis, userInput, recipe) {
|
|||||||
* }} [options]
|
* }} [options]
|
||||||
*/
|
*/
|
||||||
export function buildMapOrderDescription(recipe, options = {}) {
|
export function buildMapOrderDescription(recipe, options = {}) {
|
||||||
const { moodAnalysis = null, userInput = null, maxFlowers = 4 } = options;
|
const { maxFlowers = 3 } = options;
|
||||||
const flowers = resolveRecipeFlowers(recipe, () => '').slice(0, maxFlowers);
|
const flowers = resolveRecipeFlowers(recipe, () => '').slice(0, maxFlowers);
|
||||||
|
|
||||||
if (flowers.length === 0) {
|
if (flowers.length === 0) {
|
||||||
return 'Your selected bouquet design.';
|
return 'A hand-tied bouquet shaped from your moodboard.';
|
||||||
}
|
}
|
||||||
|
|
||||||
const intro = buildMapOrderIntro(moodAnalysis, userInput, recipe);
|
const names = flowers.map((flower) => flower.name).join(', ');
|
||||||
const flowerList = flowers.map((flower) => flower.name).join(', ');
|
const themes = [
|
||||||
|
...new Set(
|
||||||
|
flowers
|
||||||
|
.flatMap((flower) => [
|
||||||
|
broadFlowerTheme(flower.wordOfFlower),
|
||||||
|
broadFlowerTheme(flower.wordOfFlowerKo)
|
||||||
|
])
|
||||||
|
.filter(Boolean)
|
||||||
|
)
|
||||||
|
].slice(0, 2);
|
||||||
|
|
||||||
return `${intro}: ${flowerList}.`;
|
if (themes.length === 0) {
|
||||||
|
return truncateDescription(`Featuring ${names}.`, 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
return truncateDescription(`Featuring ${names} — notes of ${themes.join(' and ')}.`, 140);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -286,13 +284,22 @@ function getPrimaryFlowerFromRecipe(recipe) {
|
|||||||
return matchCatalogFlower(label);
|
return matchCatalogFlower(label);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** result/map DescriptionCard 본문 최대 글자수 */
|
||||||
|
export const ARTWORK_DESCRIPTION_MAX_LENGTH = 120;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Why this bouquet fits — mood from images, message, and main flower language.
|
* Why this bouquet fits — mood from images, message, and main flower language.
|
||||||
* @param {{ moodKeywords?: string[], styleImpression?: string[], colorPalette?: string[] } | null | undefined} moodAnalysis
|
* @param {{ moodKeywords?: string[], styleImpression?: string[], colorPalette?: string[] } | null | undefined} moodAnalysis
|
||||||
* @param {{ relationship?: string, notes?: string } | null | undefined} userInput
|
* @param {{ relationship?: string, notes?: string } | null | undefined} userInput
|
||||||
* @param {{ mainFlowers?: string[] } | null | undefined} recipe
|
* @param {{ mainFlowers?: string[] } | null | undefined} recipe
|
||||||
|
* @param {number} [maxLength=ARTWORK_DESCRIPTION_MAX_LENGTH]
|
||||||
*/
|
*/
|
||||||
export function buildBouquetRationale(moodAnalysis, userInput, recipe) {
|
export function buildBouquetRationale(
|
||||||
|
moodAnalysis,
|
||||||
|
userInput,
|
||||||
|
recipe,
|
||||||
|
maxLength = ARTWORK_DESCRIPTION_MAX_LENGTH
|
||||||
|
) {
|
||||||
const normalized = normalizeRecipeLists(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';
|
||||||
@@ -330,12 +337,15 @@ export function buildBouquetRationale(moodAnalysis, userInput, recipe) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (parts.length === 0) {
|
if (parts.length === 0) {
|
||||||
return recipient
|
return truncateDescription(
|
||||||
|
recipient
|
||||||
? `This bouquet reflects the feeling in ${recipient}'s images.`
|
? `This bouquet reflects the feeling in ${recipient}'s images.`
|
||||||
: 'This bouquet reflects the feeling in the images.';
|
: 'This bouquet reflects the feeling in the images.',
|
||||||
|
maxLength
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return parts.join(' ');
|
return truncateDescription(parts.join(' '), maxLength);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
195
src/lib/server/flowerFlow/refinedAreaMask.js
Normal file
195
src/lib/server/flowerFlow/refinedAreaMask.js
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import sharp from 'sharp';
|
||||||
|
import {
|
||||||
|
readImageDimensions,
|
||||||
|
buildEditMaskRgba,
|
||||||
|
countEditablePixels,
|
||||||
|
encodeOpenAIMaskPng,
|
||||||
|
erodeEditMask
|
||||||
|
} from './selectionMask.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} r
|
||||||
|
* @param {number} g
|
||||||
|
* @param {number} b
|
||||||
|
*/
|
||||||
|
function isBackgroundPixel(r, g, b) {
|
||||||
|
return r > 240 && g > 240 && b > 240;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} r
|
||||||
|
* @param {number} g
|
||||||
|
* @param {number} b
|
||||||
|
*/
|
||||||
|
function isStrongFoliagePixel(r, g, b) {
|
||||||
|
return g > r + 18 && g > b + 18 && g > 70;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} ar
|
||||||
|
* @param {number} ag
|
||||||
|
* @param {number} ab
|
||||||
|
* @param {number} br
|
||||||
|
* @param {number} bg
|
||||||
|
* @param {number} bb
|
||||||
|
*/
|
||||||
|
function colorDistance(ar, ag, ab, br, bg, bb) {
|
||||||
|
return Math.sqrt((ar - br) ** 2 + (ag - bg) ** 2 + (ab - bb) ** 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Uint8Array} sourceRgba
|
||||||
|
* @param {Uint8Array} editMaskRgba
|
||||||
|
* @param {number} width
|
||||||
|
* @param {number} height
|
||||||
|
* @param {number} cx
|
||||||
|
* @param {number} cy
|
||||||
|
*/
|
||||||
|
function medianSeedColor(sourceRgba, editMaskRgba, width, height, cx, cy) {
|
||||||
|
/** @type {number[]} */
|
||||||
|
const reds = [];
|
||||||
|
/** @type {number[]} */
|
||||||
|
const greens = [];
|
||||||
|
/** @type {number[]} */
|
||||||
|
const blues = [];
|
||||||
|
|
||||||
|
const radius = Math.max(4, Math.round(Math.min(width, height) * 0.02));
|
||||||
|
|
||||||
|
for (let y = Math.max(0, cy - radius); y <= Math.min(height - 1, cy + radius); y += 1) {
|
||||||
|
for (let x = Math.max(0, cx - radius); x <= Math.min(width - 1, cx + radius); x += 1) {
|
||||||
|
const index = (y * width + x) * 4;
|
||||||
|
if (editMaskRgba[index + 3] !== 0) continue;
|
||||||
|
|
||||||
|
const r = sourceRgba[index];
|
||||||
|
const g = sourceRgba[index + 1];
|
||||||
|
const b = sourceRgba[index + 2];
|
||||||
|
if (isBackgroundPixel(r, g, b)) continue;
|
||||||
|
|
||||||
|
reds.push(r);
|
||||||
|
greens.push(g);
|
||||||
|
blues.push(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reds.length === 0) return null;
|
||||||
|
|
||||||
|
const mid = Math.floor(reds.length / 2);
|
||||||
|
reds.sort((a, b) => a - b);
|
||||||
|
greens.sort((a, b) => a - b);
|
||||||
|
blues.sort((a, b) => a - b);
|
||||||
|
|
||||||
|
return { r: reds[mid], g: greens[mid], b: blues[mid] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Uint8Array} sourceRgba
|
||||||
|
* @param {Uint8Array} editMaskRgba
|
||||||
|
* @param {number} width
|
||||||
|
* @param {number} height
|
||||||
|
* @param {{ r: number, g: number, b: number }} seed
|
||||||
|
* @param {boolean} excludeFoliage
|
||||||
|
* @param {number} threshold
|
||||||
|
*/
|
||||||
|
function buildColorAffinityMask(
|
||||||
|
sourceRgba,
|
||||||
|
editMaskRgba,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
seed,
|
||||||
|
excludeFoliage,
|
||||||
|
threshold
|
||||||
|
) {
|
||||||
|
const refined = new Uint8Array(editMaskRgba.length);
|
||||||
|
refined.set(editMaskRgba);
|
||||||
|
|
||||||
|
for (let y = 0; y < height; y += 1) {
|
||||||
|
for (let x = 0; x < width; x += 1) {
|
||||||
|
const index = (y * width + x) * 4;
|
||||||
|
if (editMaskRgba[index + 3] !== 0) {
|
||||||
|
refined[index] = 255;
|
||||||
|
refined[index + 1] = 255;
|
||||||
|
refined[index + 2] = 255;
|
||||||
|
refined[index + 3] = 255;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = sourceRgba[index];
|
||||||
|
const g = sourceRgba[index + 1];
|
||||||
|
const b = sourceRgba[index + 2];
|
||||||
|
|
||||||
|
const matches =
|
||||||
|
!isBackgroundPixel(r, g, b) &&
|
||||||
|
(!excludeFoliage || !isStrongFoliagePixel(r, g, b)) &&
|
||||||
|
colorDistance(r, g, b, seed.r, seed.g, seed.b) <= threshold;
|
||||||
|
|
||||||
|
if (!matches) {
|
||||||
|
refined[index] = 255;
|
||||||
|
refined[index + 1] = 255;
|
||||||
|
refined[index + 2] = 255;
|
||||||
|
refined[index + 3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return refined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 polygon을 ROI 힌트로만 쓰고, 축소·색상 유사도로 refined OpenAI mask 생성.
|
||||||
|
* @param {{ base64: string, mimeType: string }} sourceImage
|
||||||
|
* @param {Array<{ x: number, y: number }>} selection
|
||||||
|
* @param {import('$lib/flowerFlow/areaEditIntent.js').AreaEditIntent} editContext
|
||||||
|
*/
|
||||||
|
export async function buildRefinedAreaEditMask(sourceImage, selection, editContext) {
|
||||||
|
const buffer = Buffer.from(sourceImage.base64, 'base64');
|
||||||
|
const { width, height } = readImageDimensions(buffer, sourceImage.mimeType);
|
||||||
|
|
||||||
|
const rawMask = buildEditMaskRgba(width, height, selection);
|
||||||
|
const bboxW =
|
||||||
|
((editContext.selectionBounds.maxX - editContext.selectionBounds.minX) / 100) * width;
|
||||||
|
const bboxH =
|
||||||
|
((editContext.selectionBounds.maxY - editContext.selectionBounds.minY) / 100) * height;
|
||||||
|
const erodeRadius = Math.max(3, Math.round(Math.min(bboxW, bboxH) * 0.12));
|
||||||
|
|
||||||
|
let refined = erodeEditMask(rawMask, width, height, erodeRadius);
|
||||||
|
const erodedCount = countEditablePixels(refined);
|
||||||
|
|
||||||
|
if (editContext.isColorChange && erodedCount > 0) {
|
||||||
|
const sourceRgba = await sharp(buffer).ensureAlpha().raw().toBuffer();
|
||||||
|
const cx = Math.round((editContext.selectionCentroid.x / 100) * width);
|
||||||
|
const cy = Math.round((editContext.selectionCentroid.y / 100) * height);
|
||||||
|
const seed = medianSeedColor(sourceRgba, refined, width, height, cx, cy);
|
||||||
|
|
||||||
|
if (seed) {
|
||||||
|
const excludeFoliage =
|
||||||
|
editContext.targetObject === 'ribbon/bow' ||
|
||||||
|
editContext.targetObject === 'wrapping paper';
|
||||||
|
const threshold = editContext.targetObject === 'wrapping paper' ? 42 : 55;
|
||||||
|
const colorMask = buildColorAffinityMask(
|
||||||
|
sourceRgba,
|
||||||
|
refined,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
seed,
|
||||||
|
excludeFoliage,
|
||||||
|
threshold
|
||||||
|
);
|
||||||
|
const colorCount = countEditablePixels(colorMask);
|
||||||
|
|
||||||
|
if (colorCount >= Math.max(24, Math.round(erodedCount * 0.15))) {
|
||||||
|
refined = colorMask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const maskBuffer = encodeOpenAIMaskPng(width, height, refined);
|
||||||
|
|
||||||
|
return {
|
||||||
|
base64: maskBuffer.toString('base64'),
|
||||||
|
mimeType: 'image/png',
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
editPixelCount: countEditablePixels(refined),
|
||||||
|
rawPolygonPixelCount: countEditablePixels(rawMask)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -88,7 +88,7 @@ export function readImageDimensions(buffer, mimeType) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @param {number} width @param {number} height @param {Uint8Array} rgba */
|
/** @param {number} width @param {number} height @param {Uint8Array} rgba */
|
||||||
function encodePng(width, height, rgba) {
|
export function encodeOpenAIMaskPng(width, height, rgba) {
|
||||||
/** @type {number[]} */
|
/** @type {number[]} */
|
||||||
const rows = [];
|
const rows = [];
|
||||||
let stride = 0;
|
let stride = 0;
|
||||||
@@ -143,12 +143,12 @@ function crc32(data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OpenAI mask: transparent pixels = edit region, opaque = preserve.
|
* OpenAI mask RGBA: alpha 0 = edit region, opaque white = preserve.
|
||||||
* @param {number} width
|
* @param {number} width
|
||||||
* @param {number} height
|
* @param {number} height
|
||||||
* @param {Array<{ x: number, y: number }>} selection
|
* @param {Array<{ x: number, y: number }>} selection percent coords
|
||||||
*/
|
*/
|
||||||
export function buildOpenAIEditMask(width, height, selection) {
|
export function buildEditMaskRgba(width, height, selection) {
|
||||||
const polygon = closePolygon(
|
const polygon = closePolygon(
|
||||||
selection.map((point) => ({
|
selection.map((point) => ({
|
||||||
x: (point.x / 100) * width,
|
x: (point.x / 100) * width,
|
||||||
@@ -175,7 +175,71 @@ export function buildOpenAIEditMask(width, height, selection) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return encodePng(width, height, rgba);
|
return rgba;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {Uint8Array} rgba */
|
||||||
|
export function countEditablePixels(rgba) {
|
||||||
|
let count = 0;
|
||||||
|
for (let i = 3; i < rgba.length; i += 4) {
|
||||||
|
if (rgba[i] === 0) count += 1;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Uint8Array} maskRgba
|
||||||
|
* @param {number} width
|
||||||
|
* @param {number} height
|
||||||
|
* @param {number} radius
|
||||||
|
*/
|
||||||
|
export function erodeEditMask(maskRgba, width, height, radius) {
|
||||||
|
const eroded = new Uint8Array(maskRgba.length);
|
||||||
|
eroded.set(maskRgba);
|
||||||
|
|
||||||
|
for (let y = 0; y < height; y += 1) {
|
||||||
|
for (let x = 0; x < width; x += 1) {
|
||||||
|
const index = (y * width + x) * 4;
|
||||||
|
if (maskRgba[index + 3] !== 0) continue;
|
||||||
|
|
||||||
|
let keep = true;
|
||||||
|
for (let dy = -radius; dy <= radius && keep; dy += 1) {
|
||||||
|
for (let dx = -radius; dx <= radius; dx += 1) {
|
||||||
|
if (dx * dx + dy * dy > radius * radius) continue;
|
||||||
|
const nx = x + dx;
|
||||||
|
const ny = y + dy;
|
||||||
|
if (nx < 0 || ny < 0 || nx >= width || ny >= height) {
|
||||||
|
keep = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const neighborIndex = (ny * width + nx) * 4;
|
||||||
|
if (maskRgba[neighborIndex + 3] !== 0) {
|
||||||
|
keep = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!keep) {
|
||||||
|
eroded[index] = 255;
|
||||||
|
eroded[index + 1] = 255;
|
||||||
|
eroded[index + 2] = 255;
|
||||||
|
eroded[index + 3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return eroded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
||||||
|
return encodeOpenAIMaskPng(width, height, buildEditMaskRgba(width, height, selection));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
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 { loadGeneratedImageBytes } from '$lib/server/flowerFlow/loadGeneratedImage.js';
|
||||||
import { buildAreaEditMask } from '$lib/server/flowerFlow/selectionMask.js';
|
import { buildRefinedAreaEditMask } from '$lib/server/flowerFlow/refinedAreaMask.js';
|
||||||
import { uploadGeneratedImages } from '$lib/server/flowerFlow/imageStorage.js';
|
import { uploadGeneratedImages } from '$lib/server/flowerFlow/imageStorage.js';
|
||||||
|
import { inferAreaEditTarget } from '$lib/flowerFlow/areaEditIntent.js';
|
||||||
import { formatBouquetEditPrompt } from '$lib/flowerFlow/bouquetImageFormat.js';
|
import { formatBouquetEditPrompt } from '$lib/flowerFlow/bouquetImageFormat.js';
|
||||||
import { normalizeRecipeLists } from '$lib/flowerFlow/resolveRecipeFlowers.js';
|
import { normalizeRecipeLists } from '$lib/flowerFlow/resolveRecipeFlowers.js';
|
||||||
import { editBouquetImage, isImageGenerationConfigured } from '$lib/server/gemini/image.js';
|
import { editBouquetImage, isImageGenerationConfigured } from '$lib/server/gemini/image.js';
|
||||||
import { applyRecipeEdit } from '$lib/server/gemini/text.js';
|
import { applyRecipeEdit } from '$lib/server/gemini/text.js';
|
||||||
import { frameToBouquetOutput } from '$lib/server/openai/bouquetImageFrame.js';
|
import {
|
||||||
|
BOUQUET_OUTPUT_HEIGHT,
|
||||||
|
BOUQUET_OUTPUT_WIDTH,
|
||||||
|
frameToBouquetOutput
|
||||||
|
} from '$lib/server/openai/bouquetImageFrame.js';
|
||||||
import { RATE_LIMITS } from '$lib/server/rateLimit.js';
|
import { RATE_LIMITS } from '$lib/server/rateLimit.js';
|
||||||
import { enforceRateLimit, json, readJsonBody, toErrorResponse } from '$lib/server/http.js';
|
import { enforceRateLimit, json, readJsonBody, toErrorResponse } from '$lib/server/http.js';
|
||||||
|
|
||||||
@@ -43,14 +48,6 @@ function editForJob(jobId, job, instruction) {
|
|||||||
|
|
||||||
const task = (async () => {
|
const task = (async () => {
|
||||||
const priorRecipe = normalizeRecipeLists(job.recipe);
|
const priorRecipe = normalizeRecipeLists(job.recipe);
|
||||||
const recipeEditPrompt =
|
|
||||||
instruction.mode === 'area'
|
|
||||||
? `Localized masked edit (selected region of the photo only): ${instruction.prompt}`
|
|
||||||
: instruction.prompt;
|
|
||||||
const updatedRecipe = normalizeRecipeLists(
|
|
||||||
await applyRecipeEdit(job.recipe, recipeEditPrompt)
|
|
||||||
);
|
|
||||||
const recipeChanged = JSON.stringify(updatedRecipe) !== JSON.stringify(priorRecipe);
|
|
||||||
|
|
||||||
const sourceImage = await loadGeneratedImageBytes(job.images.primary);
|
const sourceImage = await loadGeneratedImageBytes(job.images.primary);
|
||||||
const normalizedBytes = await frameToBouquetOutput(
|
const normalizedBytes = await frameToBouquetOutput(
|
||||||
@@ -62,21 +59,51 @@ function editForJob(jobId, job, instruction) {
|
|||||||
mimeType: 'image/png'
|
mimeType: 'image/png'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const areaEditContext =
|
||||||
|
instruction.mode === 'area'
|
||||||
|
? inferAreaEditTarget({
|
||||||
|
userPrompt: instruction.prompt,
|
||||||
|
selection: instruction.selection,
|
||||||
|
imageWidth: BOUQUET_OUTPUT_WIDTH,
|
||||||
|
imageHeight: BOUQUET_OUTPUT_HEIGHT
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const recipeEditPrompt =
|
||||||
|
instruction.mode === 'area' && areaEditContext
|
||||||
|
? `Localized masked edit (${areaEditContext.targetObject} only): ${areaEditContext.normalizedPrompt}`
|
||||||
|
: instruction.prompt;
|
||||||
|
|
||||||
|
const updatedRecipe = normalizeRecipeLists(
|
||||||
|
await applyRecipeEdit(job.recipe, recipeEditPrompt)
|
||||||
|
);
|
||||||
|
const recipeChanged = JSON.stringify(updatedRecipe) !== JSON.stringify(priorRecipe);
|
||||||
|
|
||||||
const editPrompt = formatBouquetEditPrompt({
|
const editPrompt = formatBouquetEditPrompt({
|
||||||
userPrompt: instruction.prompt,
|
userPrompt: instruction.prompt,
|
||||||
mode: instruction.mode,
|
mode: instruction.mode,
|
||||||
selection: instruction.selection,
|
selection: instruction.selection,
|
||||||
recipe: updatedRecipe,
|
recipe: updatedRecipe,
|
||||||
recipeChanged
|
recipeChanged,
|
||||||
|
targetObject: areaEditContext?.targetObject,
|
||||||
|
normalizedPrompt: areaEditContext?.normalizedPrompt
|
||||||
});
|
});
|
||||||
|
|
||||||
const mask =
|
const mask =
|
||||||
instruction.mode === 'area' && instruction.selection.length >= 3
|
instruction.mode === 'area' && instruction.selection.length >= 3 && areaEditContext
|
||||||
? buildAreaEditMask(normalizedSource, instruction.selection)
|
? await buildRefinedAreaEditMask(
|
||||||
|
normalizedSource,
|
||||||
|
instruction.selection,
|
||||||
|
areaEditContext
|
||||||
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`[flower-flow] edit-images job=${jobId.slice(0, 8)} mode=${instruction.mode}${mask ? ' (masked)' : ''} → editing...`
|
`[flower-flow] edit-images job=${jobId.slice(0, 8)} mode=${instruction.mode}${
|
||||||
|
mask
|
||||||
|
? ` (refined mask target=${areaEditContext?.targetObject} editPx=${mask.editPixelCount}/${mask.rawPolygonPixelCount})`
|
||||||
|
: ''
|
||||||
|
} → editing...`
|
||||||
);
|
);
|
||||||
const generatedImage = await editBouquetImage(normalizedSource, editPrompt, { mask });
|
const generatedImage = await editBouquetImage(normalizedSource, editPrompt, { mask });
|
||||||
const images = await uploadGeneratedImages(
|
const images = await uploadGeneratedImages(
|
||||||
|
|||||||
@@ -3,9 +3,15 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import DescriptionCard from '$lib/components/ui/Artwork/DescriptionCard.svelte';
|
import DescriptionCard from '$lib/components/ui/Artwork/DescriptionCard.svelte';
|
||||||
|
import MuseumFrame from '$lib/components/ui/Artwork/MuseumFrame.svelte';
|
||||||
import FlowNav from '$lib/components/ui/FlowNav.svelte';
|
import FlowNav from '$lib/components/ui/FlowNav.svelte';
|
||||||
import EditComposerBar from '$lib/components/ui/edit/EditComposerBar.svelte';
|
import EditComposerBar from '$lib/components/ui/edit/EditComposerBar.svelte';
|
||||||
import Header from '$lib/components/ui/Header.svelte';
|
import Header from '$lib/components/ui/Header.svelte';
|
||||||
|
import {
|
||||||
|
ARTWORK_SLOT_FLOWER,
|
||||||
|
ARTWORK_SLOT_WRAPPER,
|
||||||
|
ARTWORK_SLOT_CARD
|
||||||
|
} from '$lib/artwork/artworkSlotLayout.js';
|
||||||
import { editImages, fetchJob, toDataUrl } from '$lib/flowerFlow/api.js';
|
import { editImages, fetchJob, toDataUrl } from '$lib/flowerFlow/api.js';
|
||||||
import { buildBriefBouquetTitle } from '$lib/flowerFlow/resolveRecipeFlowers.js';
|
import { buildBriefBouquetTitle } from '$lib/flowerFlow/resolveRecipeFlowers.js';
|
||||||
import { getFlowString, saveFlow } from '$lib/flowerFlow/session.js';
|
import { getFlowString, saveFlow } from '$lib/flowerFlow/session.js';
|
||||||
@@ -335,37 +341,26 @@
|
|||||||
class="flex h-dvh flex-col overflow-x-hidden bg-surface text-ink lg:h-screen lg:overflow-hidden"
|
class="flex h-dvh flex-col overflow-x-hidden bg-surface text-ink lg:h-screen lg:overflow-hidden"
|
||||||
>
|
>
|
||||||
<Header step={5} total={7} />
|
<Header step={5} total={7} />
|
||||||
<FlowNav
|
<FlowNav backHref="/message" onContinue={continueToResult} continueDisabled={editing} />
|
||||||
backHref="/message"
|
|
||||||
onContinue={continueToResult}
|
|
||||||
continueDisabled={editing}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<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 lg:pb-12"
|
class="relative flex min-h-0 w-full shrink-0 flex-col border-b border-line lg:h-full lg:min-h-0 lg:w-[44%] lg:shrink-0 lg:overflow-y-auto lg:border-r lg:border-b-0"
|
||||||
>
|
>
|
||||||
<div
|
<div class={ARTWORK_SLOT_WRAPPER}>
|
||||||
class="mx-auto flex min-h-0 w-full max-w-100 flex-1 flex-col items-center justify-center gap-6"
|
<div class={ARTWORK_SLOT_FLOWER}>
|
||||||
>
|
<MuseumFrame
|
||||||
<div
|
mode="bouquet"
|
||||||
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"
|
imageSrc={imageSrc || null}
|
||||||
>
|
imageAlt="Generated bouquet"
|
||||||
{#if loading}
|
{loading}
|
||||||
<div class="aspect-[3/4] w-full animate-pulse bg-placeholder"></div>
|
|
||||||
{:else if imageSrc}
|
|
||||||
<img
|
|
||||||
src={imageSrc}
|
|
||||||
alt="Generated bouquet"
|
|
||||||
class="aspect-[3/4] w-full object-contain object-center"
|
|
||||||
/>
|
/>
|
||||||
{:else}
|
|
||||||
<div class="aspect-[3/4] w-full bg-placeholder"></div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class={ARTWORK_SLOT_CARD}>
|
||||||
<DescriptionCard {title} {description} />
|
<DescriptionCard {title} {description} />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="relative flex min-h-0 flex-1 flex-col overflow-hidden pb-44 lg:pb-8">
|
<section class="relative flex min-h-0 flex-1 flex-col overflow-hidden pb-44 lg:pb-8">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { dev } from '$app/environment';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import Header from '$lib/components/ui/Header.svelte';
|
import Header from '$lib/components/ui/Header.svelte';
|
||||||
@@ -84,12 +85,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hydrateOrderFromFlow() {
|
||||||
|
recipe = getFlowObject('recipe');
|
||||||
|
moodAnalysis = getFlowObject('moodAnalysis');
|
||||||
|
userInput = getFlowObject('userInput');
|
||||||
|
|
||||||
|
const order = buildFloristOrderMessage({
|
||||||
|
userInput: { ...sessionUserInput, ...(userInput ?? {}) },
|
||||||
|
moodAnalysis,
|
||||||
|
recipe
|
||||||
|
});
|
||||||
|
orderPlainText = order.plainText;
|
||||||
|
orderKoPlainText = order.ko.plainText;
|
||||||
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
if (!jobId) {
|
if (!jobId) {
|
||||||
|
if (!dev) {
|
||||||
await goto(resolve('/create'));
|
await goto(resolve('/create'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
hydrateOrderFromFlow();
|
||||||
|
} else {
|
||||||
try {
|
try {
|
||||||
const job = await fetchJob(jobId);
|
const job = await fetchJob(jobId);
|
||||||
recipe = job.recipe ?? null;
|
recipe = job.recipe ?? null;
|
||||||
@@ -105,7 +122,8 @@
|
|||||||
orderPlainText = order.plainText;
|
orderPlainText = order.plainText;
|
||||||
orderKoPlainText = order.ko.plainText;
|
orderKoPlainText = order.ko.plainText;
|
||||||
} catch {
|
} catch {
|
||||||
// job 없어도 지도·꽃집 검색은 계속
|
if (dev) hydrateOrderFromFlow();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const center = await getUserMapCenter();
|
const center = await getUserMapCenter();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { dev } from '$app/environment';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import Header from '$lib/components/ui/Header.svelte';
|
import Header from '$lib/components/ui/Header.svelte';
|
||||||
@@ -13,7 +14,10 @@
|
|||||||
buildBouquetRationale,
|
buildBouquetRationale,
|
||||||
buildBriefBouquetTitle
|
buildBriefBouquetTitle
|
||||||
} from '$lib/flowerFlow/resolveRecipeFlowers.js';
|
} from '$lib/flowerFlow/resolveRecipeFlowers.js';
|
||||||
import { getFlowString } from '$lib/flowerFlow/session.js';
|
import { getFlowObject, getFlowString } from '$lib/flowerFlow/session.js';
|
||||||
|
|
||||||
|
/** dev에서 job fetch 없이 미리보기할 static 더미 이미지 */
|
||||||
|
const DEV_BOUQUET_PREVIEW = { url: '/dev/bouquet-m.svg' };
|
||||||
|
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
@@ -28,14 +32,28 @@
|
|||||||
const bouquetImageSrc = $derived(selectedImage ? toDataUrl(selectedImage) : null);
|
const bouquetImageSrc = $derived(selectedImage ? toDataUrl(selectedImage) : null);
|
||||||
const bouquetFlowers = $derived(resolveRecipeFlowers(recipe, getFlowerImageSrc));
|
const bouquetFlowers = $derived(resolveRecipeFlowers(recipe, getFlowerImageSrc));
|
||||||
|
|
||||||
|
function hydrateResultFromFlow() {
|
||||||
|
recipe = getFlowObject('recipe');
|
||||||
|
moodAnalysis = getFlowObject('moodAnalysis');
|
||||||
|
userInput = getFlowObject('userInput');
|
||||||
|
selectedImage = DEV_BOUQUET_PREVIEW;
|
||||||
|
mock = true;
|
||||||
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const jobId = getFlowString('jobId');
|
const jobId = getFlowString('jobId');
|
||||||
|
|
||||||
if (!jobId) {
|
if (!jobId) {
|
||||||
|
if (!dev) {
|
||||||
await goto(resolve('/create'));
|
await goto(resolve('/create'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hydrateResultFromFlow();
|
||||||
|
loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const job = await fetchJob(jobId);
|
const job = await fetchJob(jobId);
|
||||||
selectedImage = job.images?.primary ?? null;
|
selectedImage = job.images?.primary ?? null;
|
||||||
@@ -45,6 +63,12 @@
|
|||||||
mock = Boolean(job.mock);
|
mock = Boolean(job.mock);
|
||||||
loading = false;
|
loading = false;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (dev) {
|
||||||
|
hydrateResultFromFlow();
|
||||||
|
loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
error = err instanceof Error ? err.message : 'Failed to load result';
|
error = err instanceof Error ? err.message : 'Failed to load result';
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
@@ -79,6 +103,12 @@
|
|||||||
{:else if error}
|
{:else if error}
|
||||||
<p class="text-sm text-red-600">{error}</p>
|
<p class="text-sm text-red-600">{error}</p>
|
||||||
{:else}
|
{:else}
|
||||||
|
{#if dev && !recipe}
|
||||||
|
<p class="mb-4 text-sm text-muted">
|
||||||
|
Dev: 왼쪽 하단 <strong>Dev: → Result</strong>로 더미 job까지 한 번에 채울 수 있습니다.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if mock}
|
{#if mock}
|
||||||
<p class="mb-6 text-sm text-muted">Running in mock mode (no Gemini API key).</p>
|
<p class="mb-6 text-sm text-muted">Running in mock mode (no Gemini API key).</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -44,36 +44,22 @@
|
|||||||
let submitting = $state(false);
|
let submitting = $state(false);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
|
|
||||||
const recipientLabel = $derived.by(() => {
|
|
||||||
const who = typeof userInput.relationship === 'string' ? userInput.relationship : '';
|
|
||||||
return who ? who.toLowerCase() : 'them';
|
|
||||||
});
|
|
||||||
|
|
||||||
const recipientPronoun = $derived.by(() => {
|
|
||||||
const style = typeof userInput.style === 'string' ? userInput.style.toLowerCase() : '';
|
|
||||||
if (style === 'masculine') return 'his';
|
|
||||||
if (style === 'feminine') return 'her';
|
|
||||||
return 'their';
|
|
||||||
});
|
|
||||||
|
|
||||||
const MOODBOARD_TILE_COPY = {
|
const MOODBOARD_TILE_COPY = {
|
||||||
color: {
|
color: {
|
||||||
title: 'A hint of color',
|
title: 'Their fashion',
|
||||||
description:
|
description: 'One glimpse of their style. Add the other moodboard images when ready.'
|
||||||
'The first thread pulled. Warm or cool, bold or shy. Their palette begins to speak.'
|
|
||||||
},
|
},
|
||||||
season: {
|
season: {
|
||||||
title: 'Season in the air',
|
title: 'Their season',
|
||||||
description: 'Spring lightness or winter hush. Time of year will breathe through the bouquet.'
|
description: 'A season that fits them. Keep building the moodboard on the right.'
|
||||||
},
|
},
|
||||||
character: {
|
character: {
|
||||||
title: 'Their character',
|
title: 'Their place',
|
||||||
description:
|
description: 'A place that matches their vibe. A few more photos to go.'
|
||||||
'A face, a gesture, a presence. Something in them is starting to take floral form.'
|
|
||||||
},
|
},
|
||||||
location: {
|
location: {
|
||||||
title: 'A sense of place',
|
title: 'Their cafe',
|
||||||
description: 'City grit or quiet coast. Where they belong roots the arrangement in memory.'
|
description: 'A cafe that feels like them. Almost a full moodboard.'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -89,7 +75,8 @@
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
title: 'Their social world',
|
title: 'Their social world',
|
||||||
description: `Upload a screenshot of ${recipientPronoun} feed. One glance is often enough to sense the mood.`
|
description:
|
||||||
|
'Share a glimpse of their world! One glance is often enough to sense the mood.'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,8 +87,9 @@
|
|||||||
|
|
||||||
if (count === 0) {
|
if (count === 0) {
|
||||||
return {
|
return {
|
||||||
title: 'Gather their mood',
|
title: 'Build their moodboard',
|
||||||
description: `Four small glimpses of color, season, character, and place. Together they become the palette for a bouquet made for ${recipientLabel}.`
|
description:
|
||||||
|
'Upload fashion, season, place, and cafe photos on the right. Each one shapes their bouquet.'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,23 +99,21 @@
|
|||||||
|
|
||||||
if (count === 4) {
|
if (count === 4) {
|
||||||
return {
|
return {
|
||||||
title: 'A moodboard whole',
|
title: 'Moodboard complete',
|
||||||
description:
|
description: 'Four photos gathered. Their bouquet is ready to take shape.'
|
||||||
'Color, season, character, and place. The collage is complete, and their bouquet is ready to take shape.'
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (count === 2) {
|
if (count === 2) {
|
||||||
return {
|
return {
|
||||||
title: 'Taking shape',
|
title: 'Taking shape',
|
||||||
description:
|
description: 'Keep adding photos on the right. Each one sharpens the bouquet.'
|
||||||
'The moodboard is finding its rhythm. Keep adding. Each image is another note in their story.'
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: 'Almost there',
|
title: 'Almost there',
|
||||||
description: 'One last glimpse and their world will be fully gathered on the page.'
|
description: 'One more image and the moodboard is complete.'
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -239,7 +225,7 @@
|
|||||||
|
|
||||||
<div class="mb-3 flex shrink-0 justify-center px-4 lg:mb-4 lg:px-6">
|
<div class="mb-3 flex shrink-0 justify-center px-4 lg:mb-4 lg:px-6">
|
||||||
<div
|
<div
|
||||||
class="relative grid w-full max-w-[15rem] grid-cols-2 items-center rounded-full bg-white p-1 shadow-md ring-1 ring-black/5"
|
class="relative grid w-full max-w-[15rem] grid-cols-2 items-center rounded-full bg-white p-1 ring-1 ring-black/5"
|
||||||
role="tablist"
|
role="tablist"
|
||||||
aria-label="Upload mode"
|
aria-label="Upload mode"
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user