feat(studio): Add and update core studio application components and TypeScript types for new functionality

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-30 21:37:00 -07:00
parent 034bc70eb2
commit a91818ff86
3 changed files with 312 additions and 118 deletions

View file

@ -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<string, string> = {
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<string, string> = {
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<string, string> = {
'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<string, string> = {
'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<SceneState>('imajin:scene', DEFAULT_SCENE);
const [advancedValues, setAdvancedValues] = usePersistedState('imajin:advanced', DEFAULT_ADVANCED);
const [lightboxIndex, setLightboxIndex] = useState<number | null>(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<StudioRequest[]>([]);
const [generateUntilEnabled, setGenerateUntilEnabled] = useState<boolean>(() => {
try {
const v = JSON.parse(localStorage.getItem('imajin:gen-until') ?? 'false');
return typeof v === 'boolean' ? v : false;
} catch { return false; }
});
const [generateUntilThreshold, setGenerateUntilThreshold] = useState<number>(() => {
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<number>(() => {
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<number>(() => {
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<number>(() => {
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<number | null>(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<number[]>([]);
function handleImageReady(img: Parameters<typeof addImage>[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<StudioRequest[]>([]);
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<typeof advancedValues>): void {
setAdvancedValues((prev) => ({ ...prev, ...patch }));
}
@ -333,7 +440,7 @@ export function App(): ReactElement {
const sidebar = (
<IdentityPanel
selectedIdentityId={selectedIdentityId}
onSelect={setSelectedIdentityId}
onSelect={handleIdentitySelect}
selectedExpressionId={selectedExpressionId}
selectedExpressionB64={selectedExpressionB64}
onExpressionSelect={(id, b64) => {
@ -356,37 +463,110 @@ export function App(): ReactElement {
const queuedLabel = batchQueued > 0 ? ` (${batchQueued} queued)` : '';
const generateBar = (
<GenerateRow>
<BatchInput
type="number"
min={1}
max={20}
value={batchCount}
disabled={isGenerating}
onChange={(e) => setBatchCount(Math.max(1, Math.min(20, Number(e.target.value) || 1)))}
title="Batch size"
/>
<GenerateBtn
$loading={isGenerating}
disabled={isGenerating}
onClick={handleGenerate}
>
{isGenerating ? `${attemptLabel}${queuedLabel}` : batchCount > 1 ? `Generate ×${batchCount}` : 'Generate'}
</GenerateBtn>
{isError && error && (
<GenerateStatus style={{ color: theme.colors.error }}>
{error.message}
</GenerateStatus>
)}
{!isGenerating && exhausted && (
<GenerateStatus style={{ color: theme.colors.textMuted }}>
All {totalAttempts} attempts used · score {lastScore?.toFixed(2) ?? '—'}
</GenerateStatus>
)}
<LibraryLink to="/library">
Library {images.length > 0 ? `(${images.length})` : ''}
</LibraryLink>
</GenerateRow>
<>
<GenerateRow>
<BatchInput
type="number"
min={1}
max={20}
value={batchCount}
disabled={isGenerating}
onChange={(e) => setBatchCount(Math.max(1, Math.min(20, Number(e.target.value) || 1)))}
title="Batch size"
/>
<GenerateBtn
$loading={isGenerating}
disabled={isGenerating}
onClick={handleGenerate}
>
{isGenerating
? `${attemptLabel}${queuedLabel}`
: generateUntilEnabled
? `Generate until ${generateUntilThreshold}%${batchCount > 1 ? ` ×${batchCount}` : ''}`
: batchCount > 1
? `Generate ×${batchCount}`
: 'Generate'}
</GenerateBtn>
{isError && error && (
<GenerateStatus style={{ color: theme.colors.error }}>
{error.message}
</GenerateStatus>
)}
{!isGenerating && exhausted && (
<GenerateStatus style={{ color: theme.colors.textMuted }}>
All {totalAttempts} attempts used · score {lastScore?.toFixed(2) ?? '—'}
</GenerateStatus>
)}
<ImproveBtn onClick={() => setShowImproveModal(true)} title="Improve prompt with Qwen3">
Improve
</ImproveBtn>
<LibraryLink to="/repaint">Repaint</LibraryLink>
<LibraryLink to="/library">
Library {images.length > 0 ? `(${images.length})` : ''}
</LibraryLink>
</GenerateRow>
<UntilRow>
<UntilToggle
$active={generateUntilEnabled}
onClick={() => setGenerateUntilEnabled((v) => !v)}
>
{generateUntilEnabled ? '✓' : '○'} Until quality
</UntilToggle>
{generateUntilEnabled && (
<>
<ThresholdInput
type="number"
min={10}
max={99}
step={5}
value={generateUntilThreshold}
onChange={(e) => setGenerateUntilThreshold(Math.max(10, Math.min(99, Number(e.target.value) || 80)))}
title="Minimum quality score (%)"
/>
<UntilStatus>%</UntilStatus>
<ThresholdInput
type="number"
min={1}
max={8}
step={1}
value={generateUntilInitialBatch}
onChange={(e) => setGenerateUntilInitialBatch(Math.max(1, Math.min(8, Number(e.target.value) || 3)))}
title="Initial probe batch — how many images to generate first"
/>
<UntilStatus></UntilStatus>
<ThresholdInput
type="number"
min={1}
max={16}
step={1}
value={generateUntilMinAdditional}
onChange={(e) => setGenerateUntilMinAdditional(Math.max(1, Math.min(generateUntilMaxAdditional, Number(e.target.value) || 2)))}
title="Min follow-up batch (when pass rate is high)"
/>
<UntilStatus></UntilStatus>
<ThresholdInput
type="number"
min={1}
max={16}
step={1}
value={generateUntilMaxAdditional}
onChange={(e) => setGenerateUntilMaxAdditional(Math.max(generateUntilMinAdditional, Math.min(16, Number(e.target.value) || 8)))}
title="Max follow-up batch (when pass rate is low)"
/>
{generateUntilBest !== null && (
<UntilStatus style={{ color: generateUntilBest * 100 >= generateUntilThreshold ? theme.colors.success : theme.colors.textMuted }}>
best {(generateUntilBest * 100).toFixed(0)}% · {generateUntilPassed}/{batchCount} passed
</UntilStatus>
)}
{(isGenerating || batchQueued > 0) && (
<UntilToggle $active={false} onClick={stopQueue} style={{ marginLeft: 'auto' }}>
Stop
</UntilToggle>
)}
</>
)}
</UntilRow>
</>
);
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 && (
<PromptImproveModal
sceneDescription={scene.promptCore || buildPrompt(scene, !!selectedIdentityId, advancedValues.maturity_rating)}
onApply={(improved) => setScene((prev) => ({ ...prev, promptCore: improved }))}
onClose={() => setShowImproveModal(false)}
/>
)}
</>
);
}

View file

@ -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(
<Routes>
<Route path="/" element={<App />} />
<Route path="/library" element={<Library />} />
<Route path="/repaint" element={<Repaint />} />
<Route path="/services" element={<Services />} />
</Routes>
</ImageLibraryProvider>

View file

@ -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; // 01, 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) ──────────────────────