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="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"/>
|
||||
<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"/>
|
||||
<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"/>
|
||||
<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="91" height="71" fill="#F1C130"/>
|
||||
<rect x="122" y="39" width="49.9859" height="39" fill="#EDBA54"/>
|
||||
<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="M136 307.86L265.5 264L202.383 360.612V371.263L217.362 423L179.915 412.095L136 307.86Z" fill="#D9D9D9"/>
|
||||
<defs>
|
||||
<clipPath id="bgblur_0_391_391_clip_path" transform="translate(-137 -68)"><rect x="147" y="78" width="91" height="71"/>
|
||||
</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">
|
||||
<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="1" stop-color="#08B816"/>
|
||||
</linearGradient>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 1.6 KiB |
@@ -1,5 +1,7 @@
|
||||
<script>
|
||||
import { dev } from '$app/environment';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { seedDevFlow } from '$lib/flowerFlow/devSeed.js';
|
||||
|
||||
/** 나중에 mute 하려면 true 로 변경 */
|
||||
@@ -25,19 +27,46 @@
|
||||
// 페이지 상단 const jobId = getFlowString(...) 갱신을 위해 새로고침
|
||||
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>
|
||||
|
||||
{#if dev && !DEV_SEED_MUTED}
|
||||
<div class="dev-seed fixed bottom-4 left-4 z-50 flex flex-col items-start gap-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading}
|
||||
onclick={fillDevData}
|
||||
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="AI 없이 더미 job + sessionStorage 채우기 (개발용)"
|
||||
>
|
||||
{loading ? 'Seeding…' : 'Dev: Fill data'}
|
||||
</button>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading}
|
||||
onclick={fillDevData}
|
||||
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="AI 없이 더미 job + sessionStorage 채우기 (개발용)"
|
||||
>
|
||||
{loading ? 'Seeding…' : 'Dev: Fill data'}
|
||||
</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'}
|
||||
<p class="max-w-48 rounded bg-surface/95 px-2 py-1 text-xs text-red-600 shadow-sm">
|
||||
{message}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
<script>
|
||||
// The exhibited artwork — always shown on the left, acting like a step indicator.
|
||||
import Vase from './Vase.svelte';
|
||||
import DescriptionCard from './DescriptionCard.svelte';
|
||||
import ComingSoonTape from './ComingSoonTape.svelte';
|
||||
import MuseumFrame from './MuseumFrame.svelte';
|
||||
import { downloadGeneratedImage } from '$lib/flowerFlow/downloadGeneratedImage.js';
|
||||
import {
|
||||
ARTWORK_SLOT_FLOWER,
|
||||
ARTWORK_SLOT_WRAPPER,
|
||||
ARTWORK_SLOT_CARD
|
||||
} from '$lib/artwork/artworkSlotLayout.js';
|
||||
|
||||
let {
|
||||
title = 'Title',
|
||||
@@ -23,6 +28,8 @@
|
||||
let downloading = $state(false);
|
||||
let downloadError = $state('');
|
||||
|
||||
const frameMode = $derived(imageSrc ? 'bouquet' : 'artwork');
|
||||
|
||||
async function handleDownload() {
|
||||
if (!downloadImage || downloading) return;
|
||||
|
||||
@@ -45,41 +52,27 @@
|
||||
<!--
|
||||
mobile: row · desktop: 꽃 슬롯 높이 고정 → 설명 카드 길이와 무관하게 Y·크기 유지
|
||||
-->
|
||||
<div
|
||||
class="mx-auto flex min-h-0 w-full max-w-100 flex-1 flex-row items-start gap-8 px-6 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="flex h-[11rem] shrink-0 items-end justify-center sm:h-[13rem] lg:h-[min(24rem,36vh)] lg:w-full"
|
||||
>
|
||||
{#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
|
||||
type="button"
|
||||
disabled={downloading}
|
||||
onclick={handleDownload}
|
||||
class="mt-2 text-xs text-muted underline-offset-4 hover:text-ink hover:underline disabled:opacity-50"
|
||||
>
|
||||
{downloading ? 'Downloading...' : 'Download image'}
|
||||
</button>
|
||||
{#if downloadError}
|
||||
<p class="mt-1 text-center text-[0.65rem] text-red-600">{downloadError}</p>
|
||||
{/if}
|
||||
<div class={ARTWORK_SLOT_WRAPPER}>
|
||||
<div class={ARTWORK_SLOT_FLOWER}>
|
||||
<div class="mx-auto flex w-full flex-col items-center">
|
||||
<MuseumFrame mode={frameMode} {variant} {imageSrc} imageAlt="Selected bouquet" />
|
||||
{#if imageSrc && downloadImage}
|
||||
<button
|
||||
type="button"
|
||||
disabled={downloading}
|
||||
onclick={handleDownload}
|
||||
class="mt-2 text-xs text-muted underline-offset-4 hover:text-ink hover:underline disabled:opacity-50"
|
||||
>
|
||||
{downloading ? 'Downloading...' : 'Download image'}
|
||||
</button>
|
||||
{#if downloadError}
|
||||
<p class="mt-1 text-center text-[0.65rem] text-red-600">{downloadError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<Vase {variant} />
|
||||
{/if}
|
||||
{/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} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
</h3>
|
||||
<p
|
||||
class={[
|
||||
'mt-2 text-xs',
|
||||
'mt-2 line-clamp-4 text-xs',
|
||||
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));
|
||||
</script>
|
||||
|
||||
<!-- MuseumFrame artwork 모드에서 크기·위치 제어. 단독 사용 시 fallback. -->
|
||||
<img
|
||||
{src}
|
||||
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"
|
||||
height="443"
|
||||
/>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
type="button"
|
||||
onclick={() => onchange(option)}
|
||||
class={[
|
||||
'text-xl tracking-wide transition-colors',
|
||||
'text-lg tracking-wide transition-colors',
|
||||
selected === option ? 'text-ink' : 'text-muted hover:text-ink'
|
||||
]}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
import { goto } from '$app/navigation';
|
||||
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';
|
||||
|
||||
function handleStart() {
|
||||
@@ -10,25 +10,28 @@
|
||||
</script>
|
||||
|
||||
<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"
|
||||
>
|
||||
<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">
|
||||
<GrowthMetaphorIllustration />
|
||||
|
||||
<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 />
|
||||
<p class="mt-3 text-left text-sm tracking-[0.18em] text-muted sm:mt-4 sm:text-base">
|
||||
Every Bouquet Starts with a Muse
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p class="mt-3 text-left text-sm tracking-[0.18em] text-muted sm:mt-4 sm:text-base">
|
||||
Every Bouquet Starts with a Muse
|
||||
</p>
|
||||
</div>
|
||||
<!--
|
||||
아트워크 하단 선과 동일한 max-w-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
|
||||
</h1>
|
||||
|
||||
<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>
|
||||
<h1 class="mt-2 text-4xl leading-none font-bold tracking-wide sm:text-5xl lg:text-6xl">
|
||||
Fleumuse
|
||||
</h1>
|
||||
<div class="shrink-0">
|
||||
<Button onclick={handleStart}>start creating</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -52,12 +52,12 @@
|
||||
? `<p style="margin:4px 0 0;font-size:12px;color:#666">${escapeHtml(shop.distance)}</p>`
|
||||
: '';
|
||||
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">
|
||||
<p style="margin:0;font-size:15px;font-weight:600;color:#1a1a1a">${escapeHtml(shop.name)}</p>
|
||||
<p style="margin:4px 0 0;font-size:13px;color:#555">${escapeHtml(shop.address)}</p>
|
||||
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="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="display:block;margin:4px 0 0;font-size:13px;color:#555;word-break:break-word;overflow-wrap:anywhere">${escapeHtml(shop.address)}</p>
|
||||
${distance}
|
||||
${phone}
|
||||
</div>`;
|
||||
|
||||
@@ -48,8 +48,8 @@
|
||||
</script>
|
||||
|
||||
<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">
|
||||
<h1 class="text-2xl leading-relaxed font-light text-muted md:text-3xl lg:text-[2rem]">
|
||||
<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-ink md:text-3xl lg:text-[2rem]">
|
||||
Find a nearby florist
|
||||
</h1>
|
||||
<p class="mt-3 text-sm text-muted">Move the map, then refresh to search this area.</p>
|
||||
@@ -59,7 +59,6 @@
|
||||
{#if mock}
|
||||
<p class="mt-2 text-xs text-muted">Showing sample shops (no Kakao API key).</p>
|
||||
{/if}
|
||||
<div class="mt-6 border-b border-pill lg:mt-8"></div>
|
||||
</header>
|
||||
|
||||
<div class="shrink-0 px-6 pb-4 md:px-10 lg:px-12">
|
||||
@@ -94,7 +93,7 @@
|
||||
</button>
|
||||
</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}
|
||||
<p class="text-sm text-muted">Searching for flower shops...</p>
|
||||
{:else}
|
||||
|
||||
@@ -2,25 +2,29 @@
|
||||
let { shops = [], selectedId = $bindable(null), onselect } = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex min-w-0 flex-col gap-2">
|
||||
{#each shops as shop (shop.id)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onselect(shop.id)}
|
||||
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
|
||||
? 'border-pill bg-pill text-surface'
|
||||
: 'border-line-strong bg-surface text-ink hover:border-ink/30'
|
||||
]}
|
||||
>
|
||||
<p class="text-sm font-medium">{shop.name}</p>
|
||||
<p class="mt-1 text-xs opacity-80">{shop.address}</p>
|
||||
<p class="text-sm leading-snug font-medium [overflow-wrap:anywhere] break-words">
|
||||
{shop.name}
|
||||
</p>
|
||||
<p class="mt-1 text-xs leading-snug [overflow-wrap:anywhere] break-words opacity-80">
|
||||
{shop.address}
|
||||
</p>
|
||||
{#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 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}
|
||||
</button>
|
||||
{:else}
|
||||
|
||||
@@ -65,10 +65,30 @@
|
||||
</script>
|
||||
|
||||
<div class="moodboard min-h-0 w-full flex-1">
|
||||
<UploadTile label="Color" bind:file={colorFile} class="tile tile-color" />
|
||||
<UploadTile label="Season" bind:file={seasonFile} class="tile tile-season" />
|
||||
<UploadTile label="Character" bind:file={characterFile} class="tile tile-character" />
|
||||
<UploadTile label="Location" bind:file={locationFile} class="tile tile-location" />
|
||||
<UploadTile
|
||||
label="Fashion"
|
||||
prompt="What outfit reminds you of them?"
|
||||
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>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -40,7 +40,11 @@
|
||||
</script>
|
||||
|
||||
<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>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
// both the moodboard and the SNS feed.
|
||||
let {
|
||||
label = null,
|
||||
/** 빈 타일 중앙에 표시할 안내 문장 */
|
||||
prompt = null,
|
||||
showLabel = true,
|
||||
class: klass = '',
|
||||
style = '',
|
||||
@@ -44,7 +46,7 @@
|
||||
type="file"
|
||||
accept="image/*"
|
||||
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}
|
||||
/>
|
||||
|
||||
@@ -63,13 +65,15 @@
|
||||
</span>
|
||||
{:else}
|
||||
<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
|
||||
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
|
||||
>
|
||||
{#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>
|
||||
{/if}
|
||||
</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 =
|
||||
'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
|
||||
*/
|
||||
@@ -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',
|
||||
'- 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',
|
||||
`- ${BOUQUET_NO_PERSON_CONSTRAINT}`,
|
||||
`- ${BOUQUET_IMAGE_ASPECT_PROMPT}`,
|
||||
'- White background, soft natural lighting, front-facing, orderable from a real Korean florist'
|
||||
].join('\n');
|
||||
@@ -60,6 +69,7 @@ export function formatStrictRecipeConstraints(recipe) {
|
||||
export function formatStrictBouquetImagePrompt(recipe) {
|
||||
return [
|
||||
'Generate a realistic Korean florist bouquet product photo.',
|
||||
BOUQUET_CATALOG_SCENE_PROMPT,
|
||||
'',
|
||||
formatStrictRecipeConstraints(recipe)
|
||||
].join('\n');
|
||||
@@ -72,37 +82,40 @@ export function formatStrictBouquetImagePrompt(recipe) {
|
||||
* mode?: string,
|
||||
* selection?: Array<{ x: number, y: number }>,
|
||||
* recipe?: { mainFlowers?: string[], subFlowers?: string[], greenery?: string[] },
|
||||
* recipeChanged?: boolean
|
||||
* recipeChanged?: boolean,
|
||||
* targetObject?: string,
|
||||
* normalizedPrompt?: string
|
||||
* }} options
|
||||
* @returns {string}
|
||||
*/
|
||||
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;
|
||||
|
||||
if (isAreaEdit) {
|
||||
return [
|
||||
'You are editing the attached florist bouquet photograph with a binary mask.',
|
||||
'This is a localized inpainting edit — NOT a full bouquet redesign or re-render.',
|
||||
'You are editing a realistic bouquet product photo.',
|
||||
'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}`,
|
||||
'',
|
||||
'How to edit inside the mask:',
|
||||
'- 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',
|
||||
`User request: ${userPrompt}`,
|
||||
`Inferred target object: ${targetObject}`,
|
||||
`Normalized edit instruction: ${normalizedPrompt}`,
|
||||
'',
|
||||
'Output exactly one edited photo. No before/after collage.'
|
||||
].join('\n');
|
||||
|
||||
@@ -10,12 +10,254 @@
|
||||
|
||||
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} fallback
|
||||
* @param {string} value
|
||||
*/
|
||||
function joinKeywords(items, fallback) {
|
||||
return items?.length ? items.slice(0, 4).join(', ') : fallback;
|
||||
function isHangul(value) {
|
||||
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 } };
|
||||
}
|
||||
|
||||
const relationship = userInput?.relationship ?? 'someone special';
|
||||
const occasion = userInput?.occasion ?? 'a special occasion';
|
||||
const budget = userInput?.budget
|
||||
? `₩${Number(userInput.budget).toLocaleString('en-US')}`
|
||||
: 'a flexible range';
|
||||
const relationship = userInput?.relationship ?? 'Someone special';
|
||||
const style = userInput?.style;
|
||||
const budget = userInput?.budget;
|
||||
|
||||
const moodFeel = joinKeywords(
|
||||
[
|
||||
...(moodAnalysis?.moodKeywords ?? []),
|
||||
...(moodAnalysis?.styleImpression ?? []),
|
||||
...(moodAnalysis?.textureKeywords ?? [])
|
||||
],
|
||||
'gentle and warm'
|
||||
);
|
||||
const moodKeywords = collectMoodKeywords(moodAnalysis, recipe);
|
||||
const moodEn = buildMoodPhrase(moodKeywords, 'en');
|
||||
const moodKo = buildMoodPhrase(moodKeywords, 'ko');
|
||||
|
||||
const colorTone = joinKeywords(
|
||||
[...(moodAnalysis?.colorPalette ?? []), ...(recipe?.colors ?? [])],
|
||||
'soft natural'
|
||||
);
|
||||
const relEn = relationshipPhraseEn(relationship, style);
|
||||
const relKo = relationshipPhraseKo(relationship, style);
|
||||
const budgetEn = formatBudgetEn(budget);
|
||||
const budgetKo = formatBudgetKo(budget);
|
||||
|
||||
const plainText =
|
||||
`Hello, I'd like to inquire about a flower order. ` +
|
||||
`It's a bouquet for ${relationship} for ${occasion}, with a budget around ${budget}. ` +
|
||||
`I'd like to gift something with a ${moodFeel} feel, using ${colorTone} tones. ` +
|
||||
`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 }
|
||||
];
|
||||
`Hi! I'd like to order a bouquet for ${relEn}. ` +
|
||||
`My budget is around ${budgetEn}, and I'm looking for something ${moodEn}. ` +
|
||||
`Would it be possible to create a bouquet inspired by the reference images below?`;
|
||||
|
||||
const koPlainText =
|
||||
`안녕하세요, 꽃 주문 문의드립니다. ` +
|
||||
`${relationship}에게 ${occasion} 꽃다발을 준비하고 싶습니다. 예산은 약 ${budget}입니다. ` +
|
||||
`${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 }
|
||||
];
|
||||
`안녕하세요! ${relKo}에게 선물할 꽃다발 주문하려고 합니다. ` +
|
||||
`예산은 ${budgetKo} 정도이고, ${moodKo} 무드로 제작 부탁드립니다. ` +
|
||||
`아래 레퍼런스 이미지와 비슷한 느낌으로 가능할까요?`;
|
||||
|
||||
return {
|
||||
plainText,
|
||||
segments,
|
||||
ko: { plainText: koPlainText, segments: koSegments }
|
||||
segments: buildSegments(plainText, [relEn, budgetEn, moodEn]),
|
||||
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).
|
||||
* @param {{ moodKeywords?: string[], styleImpression?: string[] } | null | undefined} moodAnalysis
|
||||
* @param {{ relationship?: string, notes?: string } | null | undefined} userInput
|
||||
* @param {{ concept?: string } | null | undefined} recipe
|
||||
* Map DescriptionCard — 1~2줄 작품 설명 (꽃 종류 + 넓은 꽃말 테마).
|
||||
* @param {string | null | undefined} word
|
||||
* @returns {string | null}
|
||||
*/
|
||||
function buildMapOrderIntro(moodAnalysis, userInput, recipe) {
|
||||
const recipient = userInput?.relationship?.trim();
|
||||
const mood = pickKeywords(
|
||||
[...(moodAnalysis?.moodKeywords ?? []), ...(moodAnalysis?.styleImpression ?? [])],
|
||||
2
|
||||
);
|
||||
const hasCardMessage = Boolean(extractCardMessage(userInput));
|
||||
|
||||
if (hasCardMessage && recipient) {
|
||||
return `A bouquet for ${recipient}, shaped around your card message`;
|
||||
}
|
||||
if (hasCardMessage) {
|
||||
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';
|
||||
function broadFlowerTheme(word) {
|
||||
if (!word?.trim()) return null;
|
||||
const w = word.toLowerCase();
|
||||
if (/love|romance|passion|devotion|사랑|연애|로맨/.test(w)) return 'love';
|
||||
if (/friend|companionship|우정|친구/.test(w)) return 'friendship';
|
||||
if (/health|healing|vitality|건강|회복/.test(w)) return 'health';
|
||||
if (/thank|grateful|감사/.test(w)) return 'gratitude';
|
||||
if (/joy|happy|celebrat|cheer|기쁨|축하/.test(w)) return 'joy';
|
||||
if (/hope|faith|희망/.test(w)) return 'hope';
|
||||
if (/warm|tender|gentle|따뜻|부드러/.test(w)) return 'warmth';
|
||||
if (/peace|calm|평화|차분/.test(w)) return 'peace';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {{
|
||||
* moodAnalysis?: { moodKeywords?: string[], styleImpression?: string[] } | null,
|
||||
@@ -214,16 +198,30 @@ function buildMapOrderIntro(moodAnalysis, userInput, 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);
|
||||
|
||||
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 flowerList = flowers.map((flower) => flower.name).join(', ');
|
||||
const names = 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);
|
||||
}
|
||||
|
||||
/** result/map DescriptionCard 본문 최대 글자수 */
|
||||
export const ARTWORK_DESCRIPTION_MAX_LENGTH = 120;
|
||||
|
||||
/**
|
||||
* Why this bouquet fits — mood from images, message, and main flower language.
|
||||
* @param {{ moodKeywords?: string[], styleImpression?: string[], colorPalette?: string[] } | null | undefined} moodAnalysis
|
||||
* @param {{ relationship?: string, notes?: string } | null | undefined} userInput
|
||||
* @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 recipient = userInput?.relationship?.trim();
|
||||
const subject = recipient ? `${recipient}'s` : 'The';
|
||||
@@ -330,12 +337,15 @@ export function buildBouquetRationale(moodAnalysis, userInput, recipe) {
|
||||
}
|
||||
|
||||
if (parts.length === 0) {
|
||||
return recipient
|
||||
? `This bouquet reflects the feeling in ${recipient}'s images.`
|
||||
: 'This bouquet reflects the feeling in the images.';
|
||||
return truncateDescription(
|
||||
recipient
|
||||
? `This bouquet reflects the feeling in ${recipient}'s 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 */
|
||||
function encodePng(width, height, rgba) {
|
||||
export function encodeOpenAIMaskPng(width, height, rgba) {
|
||||
/** @type {number[]} */
|
||||
const rows = [];
|
||||
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} 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(
|
||||
selection.map((point) => ({
|
||||
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 { 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 { inferAreaEditTarget } from '$lib/flowerFlow/areaEditIntent.js';
|
||||
import { formatBouquetEditPrompt } from '$lib/flowerFlow/bouquetImageFormat.js';
|
||||
import { normalizeRecipeLists } from '$lib/flowerFlow/resolveRecipeFlowers.js';
|
||||
import { editBouquetImage, isImageGenerationConfigured } from '$lib/server/gemini/image.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 { enforceRateLimit, json, readJsonBody, toErrorResponse } from '$lib/server/http.js';
|
||||
|
||||
@@ -43,14 +48,6 @@ function editForJob(jobId, job, instruction) {
|
||||
|
||||
const task = (async () => {
|
||||
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 normalizedBytes = await frameToBouquetOutput(
|
||||
@@ -62,21 +59,51 @@ function editForJob(jobId, job, instruction) {
|
||||
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({
|
||||
userPrompt: instruction.prompt,
|
||||
mode: instruction.mode,
|
||||
selection: instruction.selection,
|
||||
recipe: updatedRecipe,
|
||||
recipeChanged
|
||||
recipeChanged,
|
||||
targetObject: areaEditContext?.targetObject,
|
||||
normalizedPrompt: areaEditContext?.normalizedPrompt
|
||||
});
|
||||
|
||||
const mask =
|
||||
instruction.mode === 'area' && instruction.selection.length >= 3
|
||||
? buildAreaEditMask(normalizedSource, instruction.selection)
|
||||
instruction.mode === 'area' && instruction.selection.length >= 3 && areaEditContext
|
||||
? await buildRefinedAreaEditMask(
|
||||
normalizedSource,
|
||||
instruction.selection,
|
||||
areaEditContext
|
||||
)
|
||||
: null;
|
||||
|
||||
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 images = await uploadGeneratedImages(
|
||||
|
||||
@@ -3,9 +3,15 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
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 EditComposerBar from '$lib/components/ui/edit/EditComposerBar.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 { buildBriefBouquetTitle } from '$lib/flowerFlow/resolveRecipeFlowers.js';
|
||||
import { getFlowString, saveFlow } from '$lib/flowerFlow/session.js';
|
||||
@@ -335,36 +341,25 @@
|
||||
class="flex h-dvh flex-col overflow-x-hidden bg-surface text-ink lg:h-screen lg:overflow-hidden"
|
||||
>
|
||||
<Header step={5} total={7} />
|
||||
<FlowNav
|
||||
backHref="/message"
|
||||
onContinue={continueToResult}
|
||||
continueDisabled={editing}
|
||||
/>
|
||||
<FlowNav backHref="/message" onContinue={continueToResult} continueDisabled={editing} />
|
||||
|
||||
<main class="flex min-h-0 flex-1 flex-col lg:flex-row">
|
||||
<section
|
||||
class="flex min-h-0 w-full shrink-0 flex-col border-b border-line px-6 py-6 lg:w-[44%] lg:border-r lg:border-b-0 lg:px-10 lg:py-8 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
|
||||
class="mx-auto flex min-h-0 w-full max-w-100 flex-1 flex-col items-center justify-center gap-6"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-24 overflow-hidden bg-track shadow-sm ring-1 ring-black/5 sm:max-w-28 lg:max-w-75"
|
||||
>
|
||||
{#if 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 class={ARTWORK_SLOT_WRAPPER}>
|
||||
<div class={ARTWORK_SLOT_FLOWER}>
|
||||
<MuseumFrame
|
||||
mode="bouquet"
|
||||
imageSrc={imageSrc || null}
|
||||
imageAlt="Generated bouquet"
|
||||
{loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DescriptionCard {title} {description} />
|
||||
<div class={ARTWORK_SLOT_CARD}>
|
||||
<DescriptionCard {title} {description} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { dev } from '$app/environment';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import Header from '$lib/components/ui/Header.svelte';
|
||||
@@ -84,28 +85,45 @@
|
||||
}
|
||||
}
|
||||
|
||||
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 () => {
|
||||
if (!jobId) {
|
||||
await goto(resolve('/create'));
|
||||
return;
|
||||
}
|
||||
if (!dev) {
|
||||
await goto(resolve('/create'));
|
||||
return;
|
||||
}
|
||||
hydrateOrderFromFlow();
|
||||
} else {
|
||||
try {
|
||||
const job = await fetchJob(jobId);
|
||||
recipe = job.recipe ?? null;
|
||||
moodAnalysis = job.moodAnalysis ?? null;
|
||||
userInput = job.userInput ?? null;
|
||||
selectedImage = job.images?.primary ?? null;
|
||||
|
||||
try {
|
||||
const job = await fetchJob(jobId);
|
||||
recipe = job.recipe ?? null;
|
||||
moodAnalysis = job.moodAnalysis ?? null;
|
||||
userInput = job.userInput ?? null;
|
||||
selectedImage = job.images?.primary ?? null;
|
||||
|
||||
const order = buildFloristOrderMessage({
|
||||
userInput: { ...sessionUserInput, ...job.userInput },
|
||||
moodAnalysis: job.moodAnalysis,
|
||||
recipe: job.recipe
|
||||
});
|
||||
orderPlainText = order.plainText;
|
||||
orderKoPlainText = order.ko.plainText;
|
||||
} catch {
|
||||
// job 없어도 지도·꽃집 검색은 계속
|
||||
const order = buildFloristOrderMessage({
|
||||
userInput: { ...sessionUserInput, ...job.userInput },
|
||||
moodAnalysis: job.moodAnalysis,
|
||||
recipe: job.recipe
|
||||
});
|
||||
orderPlainText = order.plainText;
|
||||
orderKoPlainText = order.ko.plainText;
|
||||
} catch {
|
||||
if (dev) hydrateOrderFromFlow();
|
||||
}
|
||||
}
|
||||
|
||||
const center = await getUserMapCenter();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { dev } from '$app/environment';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import Header from '$lib/components/ui/Header.svelte';
|
||||
@@ -13,7 +14,10 @@
|
||||
buildBouquetRationale,
|
||||
buildBriefBouquetTitle
|
||||
} 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 error = $state('');
|
||||
@@ -28,11 +32,25 @@
|
||||
const bouquetImageSrc = $derived(selectedImage ? toDataUrl(selectedImage) : null);
|
||||
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 () => {
|
||||
const jobId = getFlowString('jobId');
|
||||
|
||||
if (!jobId) {
|
||||
await goto(resolve('/create'));
|
||||
if (!dev) {
|
||||
await goto(resolve('/create'));
|
||||
return;
|
||||
}
|
||||
|
||||
hydrateResultFromFlow();
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -45,6 +63,12 @@
|
||||
mock = Boolean(job.mock);
|
||||
loading = false;
|
||||
} catch (err) {
|
||||
if (dev) {
|
||||
hydrateResultFromFlow();
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
error = err instanceof Error ? err.message : 'Failed to load result';
|
||||
loading = false;
|
||||
}
|
||||
@@ -79,6 +103,12 @@
|
||||
{:else if error}
|
||||
<p class="text-sm text-red-600">{error}</p>
|
||||
{:else}
|
||||
{#if dev && !recipe}
|
||||
<p class="mb-4 text-sm text-muted">
|
||||
Dev: 왼쪽 하단 <strong>Dev: → Result</strong>로 더미 job까지 한 번에 채울 수 있습니다.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if mock}
|
||||
<p class="mb-6 text-sm text-muted">Running in mock mode (no Gemini API key).</p>
|
||||
{/if}
|
||||
|
||||
@@ -44,36 +44,22 @@
|
||||
let submitting = $state(false);
|
||||
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 = {
|
||||
color: {
|
||||
title: 'A hint of color',
|
||||
description:
|
||||
'The first thread pulled. Warm or cool, bold or shy. Their palette begins to speak.'
|
||||
title: 'Their fashion',
|
||||
description: 'One glimpse of their style. Add the other moodboard images when ready.'
|
||||
},
|
||||
season: {
|
||||
title: 'Season in the air',
|
||||
description: 'Spring lightness or winter hush. Time of year will breathe through the bouquet.'
|
||||
title: 'Their season',
|
||||
description: 'A season that fits them. Keep building the moodboard on the right.'
|
||||
},
|
||||
character: {
|
||||
title: 'Their character',
|
||||
description:
|
||||
'A face, a gesture, a presence. Something in them is starting to take floral form.'
|
||||
title: 'Their place',
|
||||
description: 'A place that matches their vibe. A few more photos to go.'
|
||||
},
|
||||
location: {
|
||||
title: 'A sense of place',
|
||||
description: 'City grit or quiet coast. Where they belong roots the arrangement in memory.'
|
||||
title: 'Their cafe',
|
||||
description: 'A cafe that feels like them. Almost a full moodboard.'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -89,7 +75,8 @@
|
||||
|
||||
return {
|
||||
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) {
|
||||
return {
|
||||
title: 'Gather their mood',
|
||||
description: `Four small glimpses of color, season, character, and place. Together they become the palette for a bouquet made for ${recipientLabel}.`
|
||||
title: 'Build their moodboard',
|
||||
description:
|
||||
'Upload fashion, season, place, and cafe photos on the right. Each one shapes their bouquet.'
|
||||
};
|
||||
}
|
||||
|
||||
@@ -111,23 +99,21 @@
|
||||
|
||||
if (count === 4) {
|
||||
return {
|
||||
title: 'A moodboard whole',
|
||||
description:
|
||||
'Color, season, character, and place. The collage is complete, and their bouquet is ready to take shape.'
|
||||
title: 'Moodboard complete',
|
||||
description: 'Four photos gathered. Their bouquet is ready to take shape.'
|
||||
};
|
||||
}
|
||||
|
||||
if (count === 2) {
|
||||
return {
|
||||
title: 'Taking shape',
|
||||
description:
|
||||
'The moodboard is finding its rhythm. Keep adding. Each image is another note in their story.'
|
||||
description: 'Keep adding photos on the right. Each one sharpens the bouquet.'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
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="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"
|
||||
aria-label="Upload mode"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user