diff --git a/ui/src/app/jobs/new/SimpleJob.tsx b/ui/src/app/jobs/new/SimpleJob.tsx index eee787cc..5dede70f 100644 --- a/ui/src/app/jobs/new/SimpleJob.tsx +++ b/ui/src/app/jobs/new/SimpleJob.tsx @@ -22,8 +22,9 @@ import { SliderInput, } from '@/components/formInputs'; import Card from '@/components/Card'; -import { X, Copy, Wand2 } from 'lucide-react'; +import { X, Copy, Wand2, SquareDashed } from 'lucide-react'; import { openUpsamplePromptsModal, toAspectRatio } from '@/components/UpsamplePromptsModal'; +import { openPromptBoxEditor } from '@/components/PromptBoxEditorModal'; import AddSingleImageModal, { openAddImageModal } from '@/components/AddSingleImageModal'; import SampleControlImage from '@/components/SampleControlImage'; import { FlipHorizontal2, FlipVertical2 } from 'lucide-react'; @@ -1268,7 +1269,7 @@ export default function SimpleJob({ - {modelArch?.additionalSections?.includes('upsample_prompts') && ( + {modelArch?.additionalSections?.includes('ideogram_4_prompt') && ( + + )} +
{!isAudioModel && ( + ); diff --git a/ui/src/components/PromptBoxEditorModal.tsx b/ui/src/components/PromptBoxEditorModal.tsx new file mode 100644 index 00000000..9d0d3c69 --- /dev/null +++ b/ui/src/components/PromptBoxEditorModal.tsx @@ -0,0 +1,279 @@ +'use client'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { Dialog, DialogBackdrop, DialogPanel } from '@headlessui/react'; +import { createGlobalState } from 'react-global-hooks'; +import { SquareDashed, X } from 'lucide-react'; +import classNames from 'classnames'; +import { BoundingBoxEditor, extractBoxes } from './BoundingBoxOverlay'; +import IdeogramCaptionSidebar, { isIdeogramCaption } from './IdeogramCaptionSidebar'; + +export interface PromptBoxEditorState { + prompt: string; // current prompt text (plain or Ideogram JSON) + aspectRatio?: string; // 'W:H' to shape the placeholder; defaults to 1:1 + title?: string; + onApply: (newPrompt: string) => void; +} + +export const promptBoxEditorState = createGlobalState(null); + +export const openPromptBoxEditor = (state: PromptBoxEditorState) => { + promptBoxEditorState.set(state); +}; + +function safeParse(text: string): any { + try { + return JSON.parse(text); + } catch { + return null; + } +} + +// Empty Ideogram caption skeleton, used to convert a plain prompt into a +// structured one so boxes/fields become editable. +function seedCaption(highLevel: string): string { + return JSON.stringify( + { + high_level_description: highLevel.trim(), + style_description: { aesthetics: '', lighting: '', photo: '', medium: '' }, + compositional_deconstruction: { background: '', elements: [] }, + }, + null, + 2, + ); +} + +// Parse a 'W:H' string into a CSS aspect-ratio value. Falls back to 1/1. +function aspectStyle(aspectRatio?: string): string { + if (!aspectRatio) return '1 / 1'; + const m = aspectRatio.trim().match(/^(\d+)\s*[:/]\s*(\d+)$/); + if (!m) return '1 / 1'; + const w = parseInt(m[1], 10); + const h = parseInt(m[2], 10); + if (!w || !h) return '1 / 1'; + return `${w} / ${h}`; +} + +const PromptBoxEditorModal: React.FC = () => { + const [mounted, setMounted] = useState(false); + const [modalInfo, setModalInfo] = promptBoxEditorState.use(); + const isOpen = modalInfo !== null; + + const [caption, setCaption] = useState(''); + const [savedCaption, setSavedCaption] = useState(''); + const [selectedBoxIndex, setSelectedBoxIndex] = useState(null); + const [isDrawing, setIsDrawing] = useState(false); + const [showBoxes, setShowBoxes] = useState(true); + + useEffect(() => setMounted(true), []); + + // Seed local state whenever the modal opens with a new prompt. + useEffect(() => { + const p = modalInfo?.prompt ?? ''; + setCaption(p); + setSavedCaption(p); + setSelectedBoxIndex(null); + setIsDrawing(false); + setShowBoxes(true); + }, [modalInfo]); + + const isIdeogram = useMemo(() => isIdeogramCaption(caption), [caption]); + const isDirty = caption.trim() !== savedCaption.trim(); + + const apply = useCallback(() => { + if (!modalInfo) return; + modalInfo.onApply(caption); + setSavedCaption(caption); + }, [modalInfo, caption]); + + const onClose = useCallback(() => { + // Persist edits on close so nothing is lost. + if (modalInfo && caption.trim() !== savedCaption.trim()) { + modalInfo.onApply(caption); + } + setModalInfo(null); + }, [modalInfo, caption, savedCaption, setModalInfo]); + + // Mutate the caption JSON's element array, updating local state only. Returns + // whatever the mutator returns. + const editCaption = useCallback( + (fn: (elements: any[], data: any) => any): any => { + const data = safeParse(caption); + if (!data) return undefined; + const elements = data?.compositional_deconstruction?.elements; + if (!Array.isArray(elements)) return undefined; + const result = fn(elements, data); + setCaption(JSON.stringify(data, null, 2)); + return result; + }, + [caption], + ); + + const handleBoxChange = useCallback( + (elementIndex: number, box: { y1: number; x1: number; y2: number; x2: number }) => { + editCaption(els => { + if (els[elementIndex]) els[elementIndex].bbox = [box.y1, box.x1, box.y2, box.x2]; + }); + }, + [editCaption], + ); + + const handleCreateBox = useCallback( + (box: { y1: number; x1: number; y2: number; x2: number }) => { + const newIndex = editCaption(els => { + els.push({ type: 'obj', bbox: [box.y1, box.x1, box.y2, box.x2], desc: '' }); + return els.length - 1; + }); + setSelectedBoxIndex(typeof newIndex === 'number' ? newIndex : null); + setIsDrawing(false); + }, + [editCaption], + ); + + const editBoxes = useMemo(() => extractBoxes(safeParse(caption)), [caption]); + + if (!mounted) return null; + + return createPortal( + + +
+
+ + {/* Layout preview area: a gradient stand-in for the (nonexistent) image */} +
+
+ {/* subtle grid so box positions are readable */} +
+ {!isIdeogram && ( +
+ + No image — this is a layout preview. +
+ Convert to a structured caption to place boxes. +
+
+ )} + {isIdeogram && showBoxes && ( + + )} +
+ + {/* Controls over the preview */} +
+ {isIdeogram && ( + + )} + +
+
+ + {/* Right sidebar: structured caption editor (or plain prompt) */} +
+
+ {modalInfo?.title ?? 'Edit Prompt'} +
+ {isIdeogram ? ( + { + setSelectedBoxIndex(i); + if (i != null) setShowBoxes(true); + }} + isDrawing={isDrawing} + onToggleDrawing={() => setIsDrawing(d => !d)} + onSave={apply} + isDirty={isDirty} + /> + ) : ( +
+