diff --git a/studio/src/App.tsx b/studio/src/App.tsx index dbaa7f79..19c010a8 100644 --- a/studio/src/App.tsx +++ b/studio/src/App.tsx @@ -6,98 +6,24 @@ import { AdvancedPanel } from './components/AdvancedPanel'; import { FilmStrip } from './components/FilmStrip'; import { IdentityPanel } from './components/IdentityPanel'; import { Lightbox } from './components/Lightbox'; +import { PromptImproveModal } from './components/PromptImproveModal'; import { SceneBuilder } from './components/SceneBuilder'; import { StudioLayout } from './components/StudioLayout'; import { useGenerate } from './hooks/useGenerate'; import { useIdentities } from './hooks/useIdentities'; import { useImageLibrary } from './hooks/useImageLibrary'; +import { useUserData } from './hooks/useUserData'; import { usePromptHistory } from './components/SceneBuilder/PromptHistory'; import { theme } from './theme'; +import { computeNextBatchSize } from './lib/generateUntil'; +import { buildNegativePrompt, buildPrompt } from './lib/prompt'; import type { LayoutId, MaturityRating, ModelId, PersonAppearance, SceneState, StudioRequest } from './types'; -// ─── Lighting / Style maps ──────────────────────────────────────────────────── - -const LIGHTING_PROMPTS: Record = { - studio: 'soft studio lighting, even illumination, clean shadows', - natural: 'natural daylight, soft diffused light, window light', - golden: 'golden hour light, warm backlighting, soft rim light', - dramatic: 'dramatic lighting, single key light, deep shadows, chiaroscuro', - neon: 'neon lighting, colorful ambient glow, cyberpunk atmosphere', - backlit: 'backlit, silhouette lighting, rim light halo', -}; - -const STYLE_PROMPTS: Record = { - cinematic: 'cinematic composition, film grain, anamorphic bokeh, movie still', - editorial: 'editorial photography, magazine quality, high fashion', - intimate: 'intimate atmosphere, soft focus, candlelight warmth', - fashion: 'fashion photography, designer aesthetic, haute couture', - candid: 'candid moment, natural expression, documentary style', - portrait: 'fine art portrait, painter light, museum quality', -}; - -// ─── Prompt builder ─────────────────────────────────────────────────────────── - -function buildPrompt(scene: SceneState, hasIdentity: boolean): string { - const parts: string[] = []; - - if (scene.promptCore.trim()) { - parts.push(scene.promptCore.trim()); - } else if (hasIdentity) { - // Without a text subject, InstantID ControlNet produces garbage. - // "1woman" anchors the diffusion model to generate a person. - parts.push('1woman'); - } - // Body descriptor injected immediately after subject — high conditioning weight - // anchors proportions before pose/outfit can pull the model's priors. - if (scene.bodyDescriptor.trim()) parts.push(scene.bodyDescriptor.trim()); - if (scene.outfitDescription.trim()) parts.push(scene.outfitDescription.trim()); - - if (scene.selectedPose) { - parts.push(...scene.selectedPose.promptKeywords); - } - - const shotMap: Record = { - 'full-body': 'full body shot', - medium: 'medium shot, waist up', - 'medium-close': 'medium close-up, chest up', - 'close-up': 'close-up, head and shoulders', - 'extreme-close-up': 'extreme close-up, face', - }; - parts.push(shotMap[scene.shotType] ?? scene.shotType); - - const angleMap: Record = { - 'eye-level': 'eye level', - high: 'high angle camera', - low: 'low angle camera', - 'over-shoulder': 'over shoulder shot', - dutch: 'dutch angle', - }; - parts.push(angleMap[scene.cameraAngle] ?? scene.cameraAngle); - - if (scene.background.trim()) parts.push(scene.background.trim()); - if (scene.expressionMood.trim()) parts.push(scene.expressionMood.trim()); - - if (scene.lighting && LIGHTING_PROMPTS[scene.lighting]) { - parts.push(LIGHTING_PROMPTS[scene.lighting]); - } - if (scene.stylePreset && STYLE_PROMPTS[scene.stylePreset]) { - parts.push(STYLE_PROMPTS[scene.stylePreset]); - } - - // Grounding: prevent floating/clipping artifacts when complex backgrounds are present - if (scene.background.trim() && scene.background !== 'white backdrop, studio' && scene.background !== 'professional studio, clean background') { - parts.push('subject standing naturally, physically grounded, coherent spatial relationship with background'); - } - - parts.push('photorealistic, high quality, sharp focus, professional photography'); - return parts.join(', '); -} - // ─── Default state ──────────────────────────────────────────────────────────── const DEFAULT_SCENE: SceneState = { promptCore: '', - bodyDescriptor: 'tall woman 6 feet, hourglass figure, 30 inch waist, 42 inch hips, natural E cup breasts, long blonde hair, green eyes', + bodyDescriptor: 'tall woman 6 feet, hourglass figure, narrow waist, wide hips, full E cup bust, large natural breasts, long blonde hair, green eyes, slender waist, curvy hips', background: 'professional studio, clean background', shotType: 'medium-close', cameraAngle: 'eye-level', @@ -109,6 +35,7 @@ const DEFAULT_SCENE: SceneState = { denoisingStrength: 0.6, lighting: '', stylePreset: '', + subjectGender: 'female', }; type AdvancedValues = Pick< @@ -208,6 +135,20 @@ const BatchInput = styled.input` &:focus { outline: none; border-color: ${theme.colors.accent}; } `; +const ImproveBtn = styled.button` + font-size: ${theme.font.size.sm}; + color: ${theme.colors.textMuted}; + background: none; + border: 1px solid ${theme.colors.border}; + border-radius: ${theme.radius.md}; + padding: ${theme.spacing.sm} ${theme.spacing.md}; + cursor: pointer; + white-space: nowrap; + transition: ${theme.transition}; + + &:hover { color: ${theme.colors.text}; border-color: ${theme.colors.borderHover}; } +`; + const LibraryLink = styled(Link)` font-size: ${theme.font.size.sm}; color: ${theme.colors.textMuted}; @@ -221,6 +162,49 @@ const LibraryLink = styled(Link)` &:hover { color: ${theme.colors.text}; border-color: ${theme.colors.borderHover}; } `; +const UntilRow = styled.div` + display: flex; + align-items: center; + gap: ${theme.spacing.sm}; +`; + +const UntilToggle = styled.button<{ $active: boolean }>` + padding: ${theme.spacing.xs} ${theme.spacing.sm}; + border-radius: ${theme.radius.md}; + border: 1px solid ${({ $active }) => $active ? theme.colors.success : theme.colors.border}; + background: ${({ $active }) => $active ? `${theme.colors.success}15` : 'transparent'}; + color: ${({ $active }) => $active ? theme.colors.success : theme.colors.textMuted}; + font-size: ${theme.font.size.xs}; + cursor: pointer; + transition: ${theme.transition}; + white-space: nowrap; + + &:hover { border-color: ${theme.colors.success}; color: ${theme.colors.success}; } +`; + +const ThresholdInput = styled.input` + width: 52px; + padding: ${theme.spacing.xs} ${theme.spacing.sm}; + background: ${theme.colors.bgCard}; + border: 1px solid ${theme.colors.border}; + border-radius: ${theme.radius.md}; + color: ${theme.colors.text}; + font-size: ${theme.font.size.xs}; + text-align: center; + appearance: textfield; + -moz-appearance: textfield; + + &::-webkit-inner-spin-button, &::-webkit-outer-spin-button { appearance: none; } + &:focus { outline: none; border-color: ${theme.colors.success}; } +`; + +const UntilStatus = styled.div` + font-size: ${theme.font.size.xs}; + color: ${theme.colors.textMuted}; + white-space: nowrap; +`; + + // ─── App ────────────────────────────────────────────────────────────────────── export function App(): ReactElement { @@ -232,16 +216,32 @@ export function App(): ReactElement { const [scene, setScene] = usePersistedState('imajin:scene', DEFAULT_SCENE); const [advancedValues, setAdvancedValues] = usePersistedState('imajin:advanced', DEFAULT_ADVANCED); const [lightboxIndex, setLightboxIndex] = useState(null); + const [showImproveModal, setShowImproveModal] = useState(false); const { images, add: addImage, favorite } = useImageLibrary(); const { data: identities } = useIdentities(); const promptHistory = usePromptHistory(); + const { userData } = useUserData(); + + // Seed bodyDescriptor from userdata when the stored value is empty + useEffect(() => { + if (!userData?.bodyDescriptor) return; + setScene((prev) => prev.bodyDescriptor.trim() ? prev : { ...prev, bodyDescriptor: userData.bodyDescriptor! }); + }, [userData]); + + const handleIdentitySelect = useCallback((id: string | undefined) => { + setSelectedIdentityId(id); + if (!id || !identities) return; + const identity = identities.find((i) => i.id === id || i.name === id); + if (!identity?.gender) return; + setScene((prev) => ({ ...prev, subjectGender: identity.gender === 'M' ? 'male' : 'female' })); + }, [identities, setScene]); // Auto-select quinn (or first identity) on initial load if nothing is selected useEffect(() => { if (selectedIdentityId !== undefined || !identities || identities.length === 0) return; const quinn = identities.find((id) => id.id === 'quinn') ?? identities[0]; - setSelectedIdentityId(quinn.id); - }, [identities, selectedIdentityId]); + handleIdentitySelect(quinn.id); + }, [identities, selectedIdentityId, handleIdentitySelect]); // Reset expression + body reference when identity changes useEffect(() => { @@ -250,6 +250,53 @@ export function App(): ReactElement { setBodyReferenceB64(null); }, [selectedIdentityId]); + const [batchCount, setBatchCount] = useState(1); + const [batchQueued, setBatchQueued] = useState(0); + const pendingBatch = useRef([]); + + const [generateUntilEnabled, setGenerateUntilEnabled] = useState(() => { + try { + const v = JSON.parse(localStorage.getItem('imajin:gen-until') ?? 'false'); + return typeof v === 'boolean' ? v : false; + } catch { return false; } + }); + const [generateUntilThreshold, setGenerateUntilThreshold] = useState(() => { + try { + const v = JSON.parse(localStorage.getItem('imajin:gen-until-threshold') ?? '80'); + return typeof v === 'number' ? v : 80; + } catch { return 80; } + }); + const [generateUntilInitialBatch, setGenerateUntilInitialBatch] = useState(() => { + try { + const v = JSON.parse(localStorage.getItem('imajin:gen-until-initial') ?? '3'); + return typeof v === 'number' ? v : 3; + } catch { return 3; } + }); + const [generateUntilMinAdditional, setGenerateUntilMinAdditional] = useState(() => { + try { + const v = JSON.parse(localStorage.getItem('imajin:gen-until-min') ?? '2'); + return typeof v === 'number' ? v : 2; + } catch { return 2; } + }); + const [generateUntilMaxAdditional, setGenerateUntilMaxAdditional] = useState(() => { + try { + const v = JSON.parse(localStorage.getItem('imajin:gen-until-max') ?? '8'); + return typeof v === 'number' ? v : 8; + } catch { return 8; } + }); + useEffect(() => { localStorage.setItem('imajin:gen-until', JSON.stringify(generateUntilEnabled)); }, [generateUntilEnabled]); + useEffect(() => { localStorage.setItem('imajin:gen-until-threshold', JSON.stringify(generateUntilThreshold)); }, [generateUntilThreshold]); + useEffect(() => { localStorage.setItem('imajin:gen-until-initial', JSON.stringify(generateUntilInitialBatch)); }, [generateUntilInitialBatch]); + useEffect(() => { localStorage.setItem('imajin:gen-until-min', JSON.stringify(generateUntilMinAdditional)); }, [generateUntilMinAdditional]); + useEffect(() => { localStorage.setItem('imajin:gen-until-max', JSON.stringify(generateUntilMaxAdditional)); }, [generateUntilMaxAdditional]); + const [generateUntilBest, setGenerateUntilBest] = useState(null); + const [generateUntilPassed, setGenerateUntilPassed] = useState(0); + const generateUntilPassedRef = useRef(0); + const generateUntilStoppedRef = useRef(false); + // Phase tracking: how many images were in the active probe/follow-up batch, and their scores + const phaseExpectedRef = useRef(0); + const phaseScoresRef = useRef([]); + function handleImageReady(img: Parameters[0]): void { void addImage(img); promptHistory.push({ @@ -257,11 +304,42 @@ export function App(): ReactElement { outfitDescription: scene.outfitDescription, background: scene.background, }); - } + if (!generateUntilEnabled || generateUntilStoppedRef.current) { + generateUntilStoppedRef.current = false; + return; + } + const score = img.qualityScore ?? 0; + phaseScoresRef.current.push(score); + setGenerateUntilBest((prev) => Math.max(prev ?? 0, score)); - const [batchCount, setBatchCount] = useState(1); - const [batchQueued, setBatchQueued] = useState(0); - const pendingBatch = useRef([]); + const passed = score >= generateUntilThreshold / 100; + const newPassed = generateUntilPassedRef.current + (passed ? 1 : 0); + generateUntilPassedRef.current = newPassed; + setGenerateUntilPassed(newPassed); + + if (newPassed >= batchCount) { + // Target reached mid-phase — clear anything still queued + pendingBatch.current = []; + setBatchQueued(0); + return; + } + + // Phase complete when we've collected all expected scores and the queue is drained + if (phaseScoresRef.current.length >= phaseExpectedRef.current && pendingBatch.current.length === 0) { + const nextSize = computeNextBatchSize( + phaseScoresRef.current, + generateUntilThreshold / 100, + generateUntilMinAdditional, + generateUntilMaxAdditional, + ); + phaseScoresRef.current = []; + phaseExpectedRef.current = nextSize; + const batch = Array.from({ length: nextSize }, () => ({ ...buildRequest(), seed: undefined })); + pendingBatch.current = batch; + setBatchQueued(batch.length); + // useEffect will drain the queue sequentially + } + } const { isPending: isGenerating, attempt, totalAttempts, lastScore, exhausted, isError, error, generate } = useGenerate(handleImageReady); @@ -274,7 +352,8 @@ export function App(): ReactElement { }, [isGenerating, isError, generate]); function buildRequest(): StudioRequest { - const prompt = buildPrompt(scene, !!selectedIdentityId); + const prompt = buildPrompt(scene, !!selectedIdentityId, advancedValues.maturity_rating); + const negativePrompt = buildNegativePrompt(advancedValues.negative_prompt ?? '', advancedValues.maturity_rating); // Only pose types with preset skeletons can drive ControlNet const PRESET_POSE_TYPES = new Set(['standing', 'sitting', 'walking', 'running']); @@ -290,6 +369,7 @@ export function App(): ReactElement { return { ...advancedValues, prompt, + negative_prompt: negativePrompt, identity_id: selectedIdentityId, person_appearance: hasPoseOrOutfit ? { @@ -308,7 +388,23 @@ export function App(): ReactElement { function handleGenerate(): void { pendingBatch.current = []; setBatchQueued(0); + setGenerateUntilBest(null); + setGenerateUntilPassed(0); + generateUntilPassedRef.current = 0; + generateUntilStoppedRef.current = false; + phaseScoresRef.current = []; + phaseExpectedRef.current = 0; const req = buildRequest(); + if (generateUntilEnabled) { + // Enqueue the initial probe batch; handleImageReady will enqueue adaptive follow-up phases + const initialSize = Math.max(1, generateUntilInitialBatch); + phaseExpectedRef.current = initialSize; + const rest = Array.from({ length: initialSize - 1 }, () => ({ ...req, seed: undefined })); + pendingBatch.current = rest; + setBatchQueued(rest.length); + generate({ ...req, seed: undefined }); + return; + } if (batchCount <= 1) { generate(req); return; @@ -319,6 +415,17 @@ export function App(): ReactElement { generate({ ...req, seed: undefined }); } + function stopQueue(): void { + generateUntilStoppedRef.current = true; + pendingBatch.current = []; + setBatchQueued(0); + setGenerateUntilBest(null); + setGenerateUntilPassed(0); + generateUntilPassedRef.current = 0; + phaseScoresRef.current = []; + phaseExpectedRef.current = 0; + } + function handleAdvancedChange(patch: Partial): void { setAdvancedValues((prev) => ({ ...prev, ...patch })); } @@ -333,7 +440,7 @@ export function App(): ReactElement { const sidebar = ( { @@ -356,37 +463,110 @@ export function App(): ReactElement { const queuedLabel = batchQueued > 0 ? ` (${batchQueued} queued)` : ''; const generateBar = ( - - setBatchCount(Math.max(1, Math.min(20, Number(e.target.value) || 1)))} - title="Batch size" - /> - - {isGenerating ? `${attemptLabel}${queuedLabel}` : batchCount > 1 ? `Generate ×${batchCount}` : 'Generate'} - - {isError && error && ( - - {error.message} - - )} - {!isGenerating && exhausted && ( - - All {totalAttempts} attempts used · score {lastScore?.toFixed(2) ?? '—'} - - )} - - Library {images.length > 0 ? `(${images.length})` : ''} - - + <> + + setBatchCount(Math.max(1, Math.min(20, Number(e.target.value) || 1)))} + title="Batch size" + /> + + {isGenerating + ? `${attemptLabel}${queuedLabel}` + : generateUntilEnabled + ? `Generate until ${generateUntilThreshold}%${batchCount > 1 ? ` ×${batchCount}` : ''}` + : batchCount > 1 + ? `Generate ×${batchCount}` + : 'Generate'} + + {isError && error && ( + + {error.message} + + )} + {!isGenerating && exhausted && ( + + All {totalAttempts} attempts used · score {lastScore?.toFixed(2) ?? '—'} + + )} + setShowImproveModal(true)} title="Improve prompt with Qwen3"> + Improve + + Repaint + + Library {images.length > 0 ? `(${images.length})` : ''} + + + + setGenerateUntilEnabled((v) => !v)} + > + {generateUntilEnabled ? '✓' : '○'} Until quality + + {generateUntilEnabled && ( + <> + setGenerateUntilThreshold(Math.max(10, Math.min(99, Number(e.target.value) || 80)))} + title="Minimum quality score (%)" + /> + % + setGenerateUntilInitialBatch(Math.max(1, Math.min(8, Number(e.target.value) || 3)))} + title="Initial probe batch — how many images to generate first" + /> + + setGenerateUntilMinAdditional(Math.max(1, Math.min(generateUntilMaxAdditional, Number(e.target.value) || 2)))} + title="Min follow-up batch (when pass rate is high)" + /> + + setGenerateUntilMaxAdditional(Math.max(generateUntilMinAdditional, Math.min(16, Number(e.target.value) || 8)))} + title="Max follow-up batch (when pass rate is low)" + /> + {generateUntilBest !== null && ( + = generateUntilThreshold ? theme.colors.success : theme.colors.textMuted }}> + best {(generateUntilBest * 100).toFixed(0)}% · {generateUntilPassed}/{batchCount} passed + + )} + {(isGenerating || batchQueued > 0) && ( + + Stop + + )} + + )} + + ); return ( @@ -399,6 +579,7 @@ export function App(): ReactElement { maturityRating={advancedValues.maturity_rating} onChange={setScene} onRestoreHistory={handleRestoreHistory} + userBodyDescriptor={userData?.bodyDescriptor} /> } generateBar={generateBar} @@ -426,6 +607,13 @@ export function App(): ReactElement { onClose={() => setLightboxIndex(null)} /> )} + {showImproveModal && ( + setScene((prev) => ({ ...prev, promptCore: improved }))} + onClose={() => setShowImproveModal(false)} + /> + )} ); } diff --git a/studio/src/main.tsx b/studio/src/main.tsx index 85371182..0fd34752 100644 --- a/studio/src/main.tsx +++ b/studio/src/main.tsx @@ -5,6 +5,7 @@ import { BrowserRouter, Route, Routes } from 'react-router-dom'; import { App } from './App'; import { ImageLibraryContext, useImageLibraryState } from './hooks/useImageLibrary'; import { Library } from './pages/Library'; +import { Repaint } from './pages/Repaint'; import { Services } from './pages/Services'; const queryClient = new QueryClient({ @@ -36,6 +37,7 @@ createRoot(rootEl).render( } /> } /> + } /> } /> diff --git a/studio/src/types.ts b/studio/src/types.ts index 54da5b36..01028a97 100644 --- a/studio/src/types.ts +++ b/studio/src/types.ts @@ -22,6 +22,7 @@ export interface Identity { name: string; image_count: number; created_at: string; + gender?: 'M' | 'F'; // InsightFace majority-vote — undefined for legacy identities } export interface CreateIdentityPayload { @@ -120,6 +121,8 @@ export interface StudioRequest { // ─── Scene Builder state (frontend only) ──────────────────────────────────── +export type SubjectGender = 'female' | 'male'; + export interface SceneState { promptCore: string; bodyDescriptor: string; // injected early in prompt to anchor proportions @@ -134,6 +137,7 @@ export interface SceneState { denoisingStrength: number; // 0–1, how much to transform the reference lighting: string; // '' = none, or preset key stylePreset: string; // '' = none, or preset key + subjectGender: SubjectGender; // injected as subject anchor when promptCore has no explicit gender } // ─── Image analysis (from imajin-classifier enrichment) ──────────────────────