489 lines
14 KiB
Svelte
489 lines
14 KiB
Svelte
<script>
|
|
import { onMount, tick } from 'svelte';
|
|
import { goto } from '$app/navigation';
|
|
import { resolve } from '$app/paths';
|
|
import DescriptionCard from '$lib/components/ui/Artwork/DescriptionCard.svelte';
|
|
import FlowContinueBar, { FLOW_CONTINUE_BUTTON } from '$lib/components/ui/FlowContinueBar.svelte';
|
|
import Header from '$lib/components/ui/Header.svelte';
|
|
import { editImages, fetchJob, finalizeJob, toDataUrl } from '$lib/flowerFlow/api.js';
|
|
import { buildBriefBouquetTitle } from '$lib/flowerFlow/resolveRecipeFlowers.js';
|
|
import { getFlowString, saveFlow } from '$lib/flowerFlow/session.js';
|
|
|
|
const jobId = getFlowString('jobId');
|
|
const QUICK_PROMPTS = [
|
|
'Make it more romantic',
|
|
'Use warmer colors',
|
|
'Add more volume',
|
|
'Keep the same flowers'
|
|
];
|
|
|
|
let loading = $state(true);
|
|
let error = $state('');
|
|
let prompt = $state('');
|
|
let areaSelectionActive = $state(false);
|
|
let drawing = $state(false);
|
|
let selectionPoints = $state([]);
|
|
let initialImage = $state(null);
|
|
let generatedImage = $state(null);
|
|
let moodAnalysis = $state(null);
|
|
let editing = $state(false);
|
|
let continuing = $state(false);
|
|
/** @type {Array<{ id: string, role: 'user' | 'assistant', prompt?: string, mode?: string, status?: 'pending' | 'done' | 'error', afterImage?: { mimeType: string, base64: string } | null, error?: string }>} */
|
|
let chatMessages = $state([]);
|
|
/** @type {HTMLDivElement | null} */
|
|
let chatScrollEl = $state(null);
|
|
|
|
const imageSrc = $derived(toDataUrl(generatedImage));
|
|
const hasAreaSelection = $derived(selectionPoints.length > 2);
|
|
const title = $derived(buildBriefBouquetTitle(moodAnalysis));
|
|
const description = $derived.by(() => {
|
|
if (hasAreaSelection) {
|
|
return 'Your prompt will apply to the marked area only.';
|
|
}
|
|
|
|
if (areaSelectionActive) {
|
|
return 'Use the pencil to draw a red outline, then describe that area.';
|
|
}
|
|
|
|
return 'Tap the pencil on the image to mark an area, or edit the whole bouquet.';
|
|
});
|
|
const selectionPolyline = $derived(
|
|
selectionPoints.map((point) => `${point.x},${point.y}`).join(' ')
|
|
);
|
|
const latestAssistantId = $derived.by(() => {
|
|
for (let index = chatMessages.length - 1; index >= 0; index -= 1) {
|
|
const message = chatMessages[index];
|
|
if (message.role === 'assistant' && message.status === 'done') {
|
|
return message.id;
|
|
}
|
|
}
|
|
|
|
return '';
|
|
});
|
|
|
|
async function scrollChatToBottom() {
|
|
await tick();
|
|
if (!chatScrollEl) return;
|
|
chatScrollEl.scrollTop = chatScrollEl.scrollHeight;
|
|
}
|
|
|
|
$effect(() => {
|
|
chatMessages.length;
|
|
editing;
|
|
scrollChatToBottom();
|
|
});
|
|
|
|
/**
|
|
* @param {PointerEvent} event
|
|
*/
|
|
function getPoint(event) {
|
|
const rect = /** @type {SVGElement} */ (event.currentTarget).getBoundingClientRect();
|
|
return {
|
|
x: Math.max(0, Math.min(100, ((event.clientX - rect.left) / rect.width) * 100)),
|
|
y: Math.max(0, Math.min(100, ((event.clientY - rect.top) / rect.height) * 100))
|
|
};
|
|
}
|
|
|
|
/** @param {PointerEvent} event */
|
|
function startDrawing(event) {
|
|
if (!areaSelectionActive) return;
|
|
event.preventDefault();
|
|
/** @type {SVGElement} */ (event.currentTarget).setPointerCapture(event.pointerId);
|
|
drawing = true;
|
|
selectionPoints = [getPoint(event)];
|
|
}
|
|
|
|
/** @param {PointerEvent} event */
|
|
function draw(event) {
|
|
if (!drawing || !areaSelectionActive) return;
|
|
event.preventDefault();
|
|
selectionPoints = [...selectionPoints, getPoint(event)];
|
|
}
|
|
|
|
function stopDrawing() {
|
|
drawing = false;
|
|
}
|
|
|
|
function startAreaSelection() {
|
|
areaSelectionActive = true;
|
|
}
|
|
|
|
function cancelAreaSelection() {
|
|
areaSelectionActive = false;
|
|
drawing = false;
|
|
selectionPoints = [];
|
|
}
|
|
|
|
function toggleAreaTool() {
|
|
if (areaSelectionActive) {
|
|
cancelAreaSelection();
|
|
return;
|
|
}
|
|
|
|
startAreaSelection();
|
|
}
|
|
|
|
/** @param {string} text */
|
|
function addQuickPrompt(text) {
|
|
prompt = prompt ? `${prompt}, ${text.toLowerCase()}` : text;
|
|
}
|
|
|
|
/** @param {KeyboardEvent} event */
|
|
function handlePromptKeydown(event) {
|
|
if (event.key !== 'Enter' || event.shiftKey) return;
|
|
event.preventDefault();
|
|
applyEdit();
|
|
}
|
|
|
|
function getEditInstruction() {
|
|
if (!prompt.trim()) {
|
|
error = 'Tell us what to change first.';
|
|
return null;
|
|
}
|
|
|
|
if (selectionPoints.length > 0 && !hasAreaSelection) {
|
|
error = 'Finish drawing the area you want to change, or cancel with X.';
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
mode: hasAreaSelection ? 'area' : 'whole',
|
|
prompt: prompt.trim(),
|
|
selection: hasAreaSelection ? selectionPoints : []
|
|
};
|
|
}
|
|
|
|
async function applyEdit() {
|
|
const instruction = getEditInstruction();
|
|
if (!instruction || !jobId) return;
|
|
|
|
const assistantMessageId = `assistant-${Date.now()}`;
|
|
|
|
chatMessages = [
|
|
...chatMessages,
|
|
{
|
|
id: `user-${Date.now()}`,
|
|
role: 'user',
|
|
prompt: instruction.prompt,
|
|
mode: instruction.mode
|
|
},
|
|
{
|
|
id: assistantMessageId,
|
|
role: 'assistant',
|
|
status: 'pending'
|
|
}
|
|
];
|
|
prompt = '';
|
|
cancelAreaSelection();
|
|
error = '';
|
|
editing = true;
|
|
|
|
try {
|
|
const result = await editImages(jobId, instruction);
|
|
const afterImage = result.images?.primary ?? null;
|
|
generatedImage = afterImage;
|
|
chatMessages = chatMessages.map((message) =>
|
|
message.id === assistantMessageId ? { ...message, status: 'done', afterImage } : message
|
|
);
|
|
saveFlow({
|
|
editInstruction: instruction,
|
|
imagePrompt: result.imagePrompt,
|
|
mock: result.mock
|
|
});
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : 'Edit failed';
|
|
chatMessages = chatMessages.map((entry) =>
|
|
entry.id === assistantMessageId ? { ...entry, status: 'error', error: message } : entry
|
|
);
|
|
error = message;
|
|
} finally {
|
|
editing = false;
|
|
}
|
|
}
|
|
|
|
async function continueToResult() {
|
|
if (!jobId) {
|
|
await goto(resolve('/create'));
|
|
return;
|
|
}
|
|
|
|
continuing = true;
|
|
error = '';
|
|
|
|
try {
|
|
await finalizeJob(jobId);
|
|
await goto(resolve('/result'));
|
|
} catch (err) {
|
|
error = err instanceof Error ? err.message : 'Failed to continue to result';
|
|
continuing = false;
|
|
}
|
|
}
|
|
|
|
onMount(async () => {
|
|
if (!jobId) {
|
|
await goto(resolve('/create'));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const job = await fetchJob(jobId);
|
|
if (!job.images?.primary) {
|
|
await goto(resolve('/generating'));
|
|
return;
|
|
}
|
|
|
|
initialImage = job.images.primary;
|
|
generatedImage = job.images.primary;
|
|
moodAnalysis = job.moodAnalysis ?? null;
|
|
loading = false;
|
|
} catch (err) {
|
|
error = err instanceof Error ? err.message : 'Failed to load generated bouquet';
|
|
loading = false;
|
|
}
|
|
});
|
|
</script>
|
|
|
|
{#snippet editableImageFrame(image, editable = false)}
|
|
<div class="relative w-full max-w-44 overflow-hidden bg-track ring-1 ring-black/5 sm:max-w-52">
|
|
{#if image}
|
|
<img
|
|
src={toDataUrl(image)}
|
|
alt="Generated bouquet"
|
|
class={[
|
|
'aspect-[3/4] w-full object-contain object-center',
|
|
editable && areaSelectionActive ? 'opacity-90' : ''
|
|
]}
|
|
draggable="false"
|
|
/>
|
|
{:else}
|
|
<div class="aspect-[3/4] w-full bg-placeholder"></div>
|
|
{/if}
|
|
|
|
{#if editable && image}
|
|
<button
|
|
type="button"
|
|
class="absolute top-2 right-2 z-20 flex size-8 items-center justify-center rounded-full bg-white/95 text-ink shadow-md ring-1 ring-black/10 transition-colors hover:bg-white"
|
|
aria-label={areaSelectionActive ? 'Cancel area selection' : 'Select area to edit'}
|
|
onclick={toggleAreaTool}
|
|
>
|
|
{#if areaSelectionActive}
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
class="size-4"
|
|
aria-hidden="true"
|
|
>
|
|
<path d="M18 6 6 18" />
|
|
<path d="m6 6 12 12" />
|
|
</svg>
|
|
{:else}
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
class="size-4"
|
|
aria-hidden="true"
|
|
>
|
|
<path d="M12 20h9" />
|
|
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" />
|
|
</svg>
|
|
{/if}
|
|
</button>
|
|
|
|
{#if areaSelectionActive}
|
|
<svg
|
|
class="absolute inset-0 z-10 h-full w-full touch-none"
|
|
viewBox="0 0 100 100"
|
|
preserveAspectRatio="none"
|
|
role="application"
|
|
aria-label="Draw an area to edit"
|
|
onpointerdown={startDrawing}
|
|
onpointermove={draw}
|
|
onpointerup={stopDrawing}
|
|
onpointercancel={stopDrawing}
|
|
onpointerleave={stopDrawing}
|
|
>
|
|
{#if selectionPoints.length > 1}
|
|
<polyline
|
|
points={selectionPolyline}
|
|
fill="rgba(239,68,68,0.12)"
|
|
stroke="#ef4444"
|
|
stroke-width="1.2"
|
|
vector-effect="non-scaling-stroke"
|
|
/>
|
|
{/if}
|
|
</svg>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
{/snippet}
|
|
|
|
<div
|
|
class="flex h-dvh flex-col overflow-x-hidden bg-surface text-ink lg:h-screen lg:overflow-hidden"
|
|
>
|
|
<Header step={5} total={7} />
|
|
|
|
<main class="flex min-h-0 flex-1 flex-col lg:flex-row">
|
|
<section
|
|
class="flex min-h-0 w-full shrink-0 flex-col border-b border-line px-6 py-6 lg:w-[44%] lg:border-r lg:border-b-0 lg:px-10 lg:py-8 lg:pb-12"
|
|
>
|
|
<div
|
|
class="mx-auto flex min-h-0 w-full max-w-100 flex-1 flex-col items-center justify-center gap-6"
|
|
>
|
|
<div
|
|
class="w-full max-w-24 overflow-hidden bg-track shadow-sm ring-1 ring-black/5 sm:max-w-28 lg:max-w-75"
|
|
>
|
|
{#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>
|
|
|
|
<DescriptionCard {title} {description} />
|
|
</div>
|
|
</section>
|
|
|
|
<section class="relative flex min-h-0 flex-1 flex-col overflow-hidden pb-44 lg:pb-8">
|
|
<div class="mx-auto flex min-h-0 w-full max-w-2xl flex-1 flex-col gap-4 px-6 py-5 lg:py-6">
|
|
<div class="shrink-0">
|
|
<p class="text-xs tracking-[0.2em] text-muted uppercase">Edit bouquet</p>
|
|
<h2 class="mt-1 text-lg">Tell us how you want to refine it.</h2>
|
|
</div>
|
|
|
|
<div bind:this={chatScrollEl} class="min-h-0 flex-1 space-y-4 overflow-y-auto pr-1">
|
|
<div class="space-y-2">
|
|
<p class="text-xs text-muted">Generated image</p>
|
|
{@render editableImageFrame(initialImage ?? generatedImage, chatMessages.length === 0)}
|
|
</div>
|
|
|
|
{#each chatMessages as message (message.id)}
|
|
{#if message.role === 'user'}
|
|
<div class="flex justify-end">
|
|
<div
|
|
class="max-w-[46%] rounded-3xl bg-pill px-4 py-3 text-sm leading-relaxed text-surface"
|
|
>
|
|
<p>{message.prompt}</p>
|
|
{#if message.mode === 'area'}
|
|
<p class="mt-2 text-xs opacity-70">Selected area only</p>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{:else if message.status === 'pending'}
|
|
<div class="flex justify-start">
|
|
<div
|
|
class="max-w-[46%] rounded-3xl bg-track px-4 py-3 text-sm leading-relaxed text-muted ring-1 ring-black/5"
|
|
>
|
|
Editing bouquet image...
|
|
</div>
|
|
</div>
|
|
{:else if message.status === 'error'}
|
|
<div class="flex justify-start">
|
|
<div
|
|
class="max-w-[46%] rounded-3xl bg-surface px-4 py-3 text-sm leading-relaxed text-red-600 ring-1 ring-red-200"
|
|
>
|
|
{message.error}
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
<div class="space-y-2">
|
|
{@render editableImageFrame(message.afterImage, message.id === latestAssistantId)}
|
|
<p class="text-xs text-muted">Result</p>
|
|
</div>
|
|
{/if}
|
|
{/each}
|
|
</div>
|
|
|
|
<div class="flex shrink-0 flex-wrap gap-2">
|
|
{#each QUICK_PROMPTS as quickPrompt (quickPrompt)}
|
|
<button
|
|
type="button"
|
|
onclick={() => addQuickPrompt(quickPrompt)}
|
|
class="rounded-full bg-placeholder px-3 py-1 text-xs text-ink hover:bg-line-strong"
|
|
>
|
|
{quickPrompt}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
|
|
<FlowContinueBar class="lg:mx-auto lg:w-full lg:max-w-2xl">
|
|
{#if error}
|
|
<p class="rounded bg-surface/95 px-3 py-2 text-sm text-red-600 ring-1 ring-black/5">
|
|
{error}
|
|
</p>
|
|
{/if}
|
|
|
|
<div
|
|
class="flex w-full items-center gap-2 rounded-full border border-pill bg-surface py-1.5 pr-1.5 pl-5"
|
|
>
|
|
<textarea
|
|
bind:value={prompt}
|
|
rows="1"
|
|
placeholder={hasAreaSelection
|
|
? 'Tell me how to change the selected area...'
|
|
: areaSelectionActive
|
|
? 'Draw on the image, then describe the area...'
|
|
: 'Tell me how you would like to change your bouquet...'}
|
|
onkeydown={handlePromptKeydown}
|
|
class="max-h-24 min-h-6 flex-1 resize-none border-none bg-transparent py-1 text-sm leading-normal outline-none placeholder:text-muted"
|
|
></textarea>
|
|
|
|
<button
|
|
type="button"
|
|
aria-label={editing ? 'Applying edit' : 'Send edit'}
|
|
disabled={!prompt.trim() || editing || continuing}
|
|
onclick={applyEdit}
|
|
class="flex size-9 shrink-0 items-center justify-center rounded-full bg-pill text-surface transition-opacity disabled:opacity-40"
|
|
>
|
|
{#if editing}
|
|
<span
|
|
class="size-4 animate-spin rounded-full border-2 border-surface/30 border-t-surface"
|
|
aria-hidden="true"
|
|
></span>
|
|
{:else}
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
class="size-4"
|
|
aria-hidden="true"
|
|
>
|
|
<path d="M12 19V5" />
|
|
<path d="m5 12 7-7 7 7" />
|
|
</svg>
|
|
{/if}
|
|
</button>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
disabled={editing || continuing}
|
|
onclick={continueToResult}
|
|
class={FLOW_CONTINUE_BUTTON}
|
|
>
|
|
{continuing ? 'Preparing result...' : 'Continue to result ->'}
|
|
</button>
|
|
</FlowContinueBar>
|
|
</section>
|
|
</main>
|
|
</div>
|