refactor(studio): ♻️ Implement modular studio logic in App, hooks, and UI components to improve state management and performance

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-29 15:03:05 -07:00
parent cac7ae71f2
commit 076fd98e79
10 changed files with 1613 additions and 0 deletions

229
studio/src/App.tsx Normal file
View file

@ -0,0 +1,229 @@
import { useState } from 'react';
import styled from 'styled-components';
import { AdvancedPanel } from './components/AdvancedPanel';
import { IdentityPanel } from './components/IdentityPanel';
import { ResultsGallery } from './components/ResultsGallery';
import { SceneBuilder } from './components/SceneBuilder';
import { StudioLayout } from './components/StudioLayout';
import { useGenerate } from './hooks/useGenerate';
import { theme } from './theme';
import type { GeneratedImage, PersonAppearance, SceneState, StudioRequest } from './types';
// ─── Prompt builder ───────────────────────────────────────────────────────────
function buildPrompt(scene: SceneState): string {
const parts: string[] = [];
if (scene.promptCore.trim()) parts.push(scene.promptCore.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());
parts.push('photorealistic, high quality, sharp focus, professional photography');
return parts.join(', ');
}
// ─── Default state ────────────────────────────────────────────────────────────
const DEFAULT_SCENE: SceneState = {
promptCore: '',
background: '',
shotType: 'full-body',
cameraAngle: 'eye-level',
selectedPose: null,
poseCategory: null,
outfitDescription: '',
expressionMood: '',
};
const DEFAULT_REQUEST: Pick<
StudioRequest,
| 'model'
| 'maturity_rating'
| 'steps'
| 'guidance_scale'
| 'identity_strength'
| 'use_flux_pulid'
| 'pulid_weight'
| 'enable_instantid'
| 'ip_adapter_scale'
| 'num_candidates'
| 'enable_anatomy_fix'
> = {
model: 'juggernaut-xi-v11',
maturity_rating: 'sfw',
steps: 40,
guidance_scale: 7.5,
identity_strength: 0.8,
use_flux_pulid: false,
pulid_weight: 1.0,
enable_instantid: true,
ip_adapter_scale: 0.6,
num_candidates: 1,
enable_anatomy_fix: false,
};
// ─── Generate button ──────────────────────────────────────────────────────────
const GenerateRow = styled.div`
display: flex;
align-items: center;
gap: ${theme.spacing.md};
`;
const GenerateBtn = styled.button<{ $loading: boolean }>`
flex: 1;
padding: ${theme.spacing.md} ${theme.spacing.xl};
background: ${({ $loading }) => ($loading ? theme.colors.bgActive : theme.colors.accent)};
border: none;
border-radius: ${theme.radius.lg};
color: white;
font-size: ${theme.font.size.xl};
font-weight: ${theme.font.weight.bold};
cursor: ${({ $loading }) => ($loading ? 'not-allowed' : 'pointer')};
transition: ${theme.transition};
letter-spacing: -0.01em;
opacity: ${({ $loading }) => ($loading ? 0.7 : 1)};
&:hover:not(:disabled) {
background: ${theme.colors.accentHover};
box-shadow: ${theme.shadow.glow};
}
`;
const GenerateStatus = styled.div`
font-size: ${theme.font.size.sm};
color: ${theme.colors.textMuted};
white-space: nowrap;
`;
// ─── Sidebar with two stacked panels ─────────────────────────────────────────
const SidebarStack = styled.div`
display: flex;
flex-direction: column;
gap: ${theme.spacing.xxl};
`;
const SidebarDivider = styled.div`
height: 1px;
background: ${theme.colors.border};
`;
// ─── Main content ─────────────────────────────────────────────────────────────
export function App(): JSX.Element {
const [selectedIdentityId, setSelectedIdentityId] = useState<string | undefined>(undefined);
const [scene, setScene] = useState<SceneState>(DEFAULT_SCENE);
const [advancedValues, setAdvancedValues] = useState(DEFAULT_REQUEST);
const [images, setImages] = useState<GeneratedImage[]>([]);
function handleImageReady(img: GeneratedImage): void {
setImages((prev) => [...prev, img]);
}
const generateMutation = useGenerate(handleImageReady);
function buildRequest(): StudioRequest {
const prompt = buildPrompt(scene);
const hasPoseOrOutfit = !!scene.selectedPose || !!scene.outfitDescription.trim();
// Map PoseDefinition poseType to PersonAppearance pose_type
const rawPoseType = scene.selectedPose?.poseType;
const poseType: PersonAppearance['pose_type'] =
rawPoseType === 'lying' || rawPoseType === 'kneeling' || rawPoseType === 'leaning'
? 'custom'
: (rawPoseType as PersonAppearance['pose_type']);
return {
...advancedValues,
prompt,
layout: 'portrait',
identity_id: selectedIdentityId,
person_appearance: hasPoseOrOutfit
? {
pose_type: poseType,
outfit_description: scene.outfitDescription.trim() || undefined,
}
: undefined,
};
}
function handleGenerate(): void {
generateMutation.mutate(buildRequest());
}
function handleAdvancedChange(patch: Partial<typeof advancedValues>): void {
setAdvancedValues((prev) => ({ ...prev, ...patch }));
}
const isGenerating = generateMutation.isPending;
const sidebar = (
<SidebarStack>
<IdentityPanel
selectedIdentityId={selectedIdentityId}
onSelect={setSelectedIdentityId}
/>
<SidebarDivider />
<AdvancedPanel
values={advancedValues}
onChange={handleAdvancedChange}
hasIdentity={!!selectedIdentityId}
/>
</SidebarStack>
);
const generateBar = (
<GenerateRow>
<GenerateBtn
$loading={isGenerating}
disabled={isGenerating}
onClick={handleGenerate}
>
{isGenerating ? 'Generating…' : 'Generate'}
</GenerateBtn>
{generateMutation.isError && (
<GenerateStatus style={{ color: theme.colors.error }}>
{generateMutation.error.message}
</GenerateStatus>
)}
{isGenerating && (
<GenerateStatus>Running pipeline</GenerateStatus>
)}
</GenerateRow>
);
return (
<StudioLayout
sidebar={sidebar}
main={<SceneBuilder scene={scene} onChange={setScene} />}
generateBar={generateBar}
gallery={<ResultsGallery images={images} />}
imageCount={images.length}
/>
);
}

View file

@ -0,0 +1,346 @@
import { ChangeEvent } from 'react';
import styled from 'styled-components';
import { theme } from '../../theme';
import type { MaturityRating, ModelId, StudioRequest } from '../../types';
const Panel = styled.div`
display: flex;
flex-direction: column;
gap: ${theme.spacing.md};
`;
const SectionLabel = styled.div`
font-size: ${theme.font.size.xs};
font-weight: ${theme.font.weight.semibold};
letter-spacing: 0.08em;
text-transform: uppercase;
color: ${theme.colors.textMuted};
padding: 0 ${theme.spacing.sm};
`;
const FieldGroup = styled.div`
display: flex;
flex-direction: column;
gap: ${theme.spacing.xs};
padding: 0 ${theme.spacing.sm};
`;
const FieldLabel = styled.label`
font-size: ${theme.font.size.xs};
color: ${theme.colors.textMuted};
display: flex;
justify-content: space-between;
align-items: center;
`;
const FieldValue = styled.span`
color: ${theme.colors.accent};
font-variant-numeric: tabular-nums;
`;
const Slider = styled.input`
width: 100%;
appearance: none;
height: 3px;
background: ${theme.colors.border};
border-radius: ${theme.radius.full};
outline: none;
cursor: pointer;
&::-webkit-slider-thumb {
appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: ${theme.colors.accent};
cursor: pointer;
transition: ${theme.transition};
}
`;
const SelectEl = styled.select`
width: 100%;
padding: ${theme.spacing.sm} ${theme.spacing.md};
background: ${theme.colors.bgCard};
border: 1px solid ${theme.colors.border};
border-radius: ${theme.radius.md};
color: ${theme.colors.text};
font-size: ${theme.font.size.base};
outline: none;
cursor: pointer;
transition: ${theme.transition};
appearance: none;
&:focus { border-color: ${theme.colors.accent}; }
`;
const MaturityToggle = styled.div`
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: ${theme.spacing.xs};
`;
const MaturityBtn = styled.button<{ $active: boolean; $rating: MaturityRating }>`
padding: ${theme.spacing.sm};
border-radius: ${theme.radius.md};
border: 1px solid ${({ $active, $rating }) => {
if (!$active) return theme.colors.border;
return $rating === 'sfw' ? theme.colors.sfw : $rating === 'nsfw' ? theme.colors.warning : theme.colors.error;
}};
background: ${({ $active, $rating }) => {
if (!$active) return 'transparent';
return $rating === 'sfw' ? 'rgba(34, 197, 94, 0.15)' : $rating === 'nsfw' ? 'rgba(245, 158, 11, 0.15)' : 'rgba(239, 68, 68, 0.15)';
}};
color: ${({ $active, $rating }) => {
if (!$active) return theme.colors.textMuted;
return $rating === 'sfw' ? theme.colors.sfw : $rating === 'nsfw' ? theme.colors.warning : theme.colors.error;
}};
font-size: ${theme.font.size.sm};
font-weight: ${theme.font.weight.medium};
cursor: pointer;
transition: ${theme.transition};
text-transform: uppercase;
letter-spacing: 0.05em;
`;
const Toggle = styled.label`
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
font-size: ${theme.font.size.sm};
color: ${theme.colors.text};
`;
const ToggleSwitch = styled.input`
appearance: none;
width: 32px;
height: 18px;
background: ${theme.colors.border};
border-radius: ${theme.radius.full};
position: relative;
cursor: pointer;
transition: ${theme.transition};
flex-shrink: 0;
&:checked { background: ${theme.colors.accent}; }
&::after {
content: '';
position: absolute;
top: 3px;
left: 3px;
width: 12px;
height: 12px;
border-radius: 50%;
background: white;
transition: ${theme.transition};
}
&:checked::after { transform: translateX(14px); }
`;
const Divider = styled.div`
height: 1px;
background: ${theme.colors.border};
`;
const MODELS: { value: ModelId; label: string; tag?: string }[] = [
{ value: 'flux-dev', label: 'FLUX.1 Dev', tag: 'ID-preserving' },
{ value: 'juggernaut-xi-v11', label: 'Juggernaut XI v11', tag: 'Photorealistic' },
{ value: 'juggernaut-xl-v9', label: 'Juggernaut XL v9' },
{ value: 'realvisxl-v4', label: 'RealVisXL v4' },
{ value: 'epicrealism-xl', label: 'EpicRealism XL' },
{ value: 'animagine-xl-4.0-opt', label: 'Animagine XL 4.0', tag: 'Anime' },
{ value: 'flux-schnell', label: 'FLUX.1 Schnell', tag: 'Fast' },
];
type AdvancedFields = Pick<
StudioRequest,
| 'model'
| 'maturity_rating'
| 'steps'
| 'guidance_scale'
| 'identity_strength'
| 'use_flux_pulid'
| 'pulid_weight'
| 'enable_instantid'
| 'ip_adapter_scale'
| 'num_candidates'
| 'enable_anatomy_fix'
>;
interface AdvancedPanelProps {
values: AdvancedFields;
onChange: (patch: Partial<AdvancedFields>) => void;
hasIdentity: boolean;
}
export function AdvancedPanel({ values, onChange, hasIdentity }: AdvancedPanelProps): JSX.Element {
function handleModelChange(e: ChangeEvent<HTMLSelectElement>): void {
const model = e.target.value as ModelId;
onChange({ model, use_flux_pulid: model === 'flux-dev' || model === 'flux-schnell' });
}
function handleSlider(field: keyof AdvancedFields, e: ChangeEvent<HTMLInputElement>): void {
onChange({ [field]: parseFloat(e.target.value) } as Partial<AdvancedFields>);
}
function handleIntSlider(field: keyof AdvancedFields, e: ChangeEvent<HTMLInputElement>): void {
onChange({ [field]: parseInt(e.target.value, 10) } as Partial<AdvancedFields>);
}
return (
<Panel>
<SectionLabel>Content rating</SectionLabel>
<FieldGroup>
<MaturityToggle>
{(['sfw', 'nsfw', 'explicit'] as MaturityRating[]).map((r) => (
<MaturityBtn
key={r}
$active={values.maturity_rating === r}
$rating={r}
onClick={() => onChange({ maturity_rating: r })}
>
{r}
</MaturityBtn>
))}
</MaturityToggle>
</FieldGroup>
<Divider />
<SectionLabel>Model</SectionLabel>
<FieldGroup>
<SelectEl value={values.model} onChange={handleModelChange}>
{MODELS.map(({ value, label, tag }) => (
<option key={value} value={value}>
{label}{tag ? `${tag}` : ''}
</option>
))}
</SelectEl>
</FieldGroup>
<Divider />
<SectionLabel>Generation</SectionLabel>
<FieldGroup>
<FieldLabel>
Steps
<FieldValue>{values.steps}</FieldValue>
</FieldLabel>
<Slider
type="range"
min={10}
max={50}
step={1}
value={values.steps}
onChange={(e) => handleIntSlider('steps', e)}
/>
</FieldGroup>
<FieldGroup>
<FieldLabel>
Guidance scale
<FieldValue>{values.guidance_scale.toFixed(1)}</FieldValue>
</FieldLabel>
<Slider
type="range"
min={1}
max={20}
step={0.5}
value={values.guidance_scale}
onChange={(e) => handleSlider('guidance_scale', e)}
/>
</FieldGroup>
<FieldGroup>
<FieldLabel>
Candidates (keep best)
<FieldValue>{values.num_candidates}</FieldValue>
</FieldLabel>
<Slider
type="range"
min={1}
max={4}
step={1}
value={values.num_candidates}
onChange={(e) => handleIntSlider('num_candidates', e)}
/>
</FieldGroup>
<FieldGroup>
<Toggle>
Anatomy correction
<ToggleSwitch
type="checkbox"
checked={values.enable_anatomy_fix}
onChange={(e) => onChange({ enable_anatomy_fix: e.target.checked })}
/>
</Toggle>
</FieldGroup>
{hasIdentity && (
<>
<Divider />
<SectionLabel>Identity preservation</SectionLabel>
<FieldGroup>
<FieldLabel>
Identity strength
<FieldValue>{values.identity_strength.toFixed(2)}</FieldValue>
</FieldLabel>
<Slider
type="range"
min={0}
max={1.5}
step={0.05}
value={values.identity_strength}
onChange={(e) => handleSlider('identity_strength', e)}
/>
</FieldGroup>
{values.use_flux_pulid ? (
<FieldGroup>
<FieldLabel>
PuLID weight
<FieldValue>{values.pulid_weight.toFixed(2)}</FieldValue>
</FieldLabel>
<Slider
type="range"
min={0}
max={3}
step={0.1}
value={values.pulid_weight}
onChange={(e) => handleSlider('pulid_weight', e)}
/>
</FieldGroup>
) : (
<>
<FieldGroup>
<Toggle>
InstantID (face keypoints)
<ToggleSwitch
type="checkbox"
checked={values.enable_instantid}
onChange={(e) => onChange({ enable_instantid: e.target.checked })}
/>
</Toggle>
</FieldGroup>
<FieldGroup>
<FieldLabel>
IP-Adapter scale
<FieldValue>{values.ip_adapter_scale.toFixed(2)}</FieldValue>
</FieldLabel>
<Slider
type="range"
min={0}
max={1}
step={0.05}
value={values.ip_adapter_scale}
onChange={(e) => handleSlider('ip_adapter_scale', e)}
/>
</FieldGroup>
</>
)}
</>
)}
</Panel>
);
}

View file

@ -0,0 +1,265 @@
import { KeyboardEvent, MouseEvent, useState } from 'react';
import styled from 'styled-components';
import { useCreateIdentity, useDeleteIdentity, useIdentities } from '../../hooks/useIdentities';
import type { Identity } from '../../types';
import { theme } from '../../theme';
const Panel = styled.div`
display: flex;
flex-direction: column;
gap: ${theme.spacing.md};
`;
const SectionLabel = styled.div`
font-size: ${theme.font.size.xs};
font-weight: ${theme.font.weight.semibold};
letter-spacing: 0.08em;
text-transform: uppercase;
color: ${theme.colors.textMuted};
padding: 0 ${theme.spacing.sm};
`;
const IdentityList = styled.div`
display: flex;
flex-direction: column;
gap: ${theme.spacing.xs};
`;
const IdentityCard = styled.button<{ $active: boolean }>`
display: flex;
align-items: center;
gap: ${theme.spacing.sm};
padding: ${theme.spacing.sm} ${theme.spacing.md};
border-radius: ${theme.radius.md};
border: 1px solid ${({ $active }) => ($active ? theme.colors.accent : theme.colors.border)};
background: ${({ $active }) => ($active ? theme.colors.accentDim : theme.colors.bgCard)};
color: ${theme.colors.text};
cursor: pointer;
text-align: left;
transition: ${theme.transition};
width: 100%;
&:hover {
border-color: ${({ $active }) => ($active ? theme.colors.accentHover : theme.colors.borderHover)};
background: ${({ $active }) => ($active ? theme.colors.accentDim : theme.colors.bgHover)};
}
`;
const Avatar = styled.div`
width: 32px;
height: 32px;
border-radius: ${theme.radius.full};
background: ${theme.colors.bgActive};
display: flex;
align-items: center;
justify-content: center;
font-size: ${theme.font.size.sm};
color: ${theme.colors.accent};
font-weight: ${theme.font.weight.bold};
flex-shrink: 0;
`;
const IdentityInfo = styled.div`
flex: 1;
min-width: 0;
`;
const IdentityName = styled.div`
font-size: ${theme.font.size.base};
font-weight: ${theme.font.weight.medium};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;
const IdentityMeta = styled.div`
font-size: ${theme.font.size.xs};
color: ${theme.colors.textMuted};
margin-top: 1px;
`;
const DeleteBtn = styled.button`
background: none;
border: none;
color: ${theme.colors.textDim};
cursor: pointer;
padding: ${theme.spacing.xs};
border-radius: ${theme.radius.sm};
font-size: ${theme.font.size.sm};
line-height: 1;
transition: ${theme.transition};
flex-shrink: 0;
&:hover {
color: ${theme.colors.error};
background: rgba(239, 68, 68, 0.1);
}
`;
const Divider = styled.div`
height: 1px;
background: ${theme.colors.border};
margin: ${theme.spacing.xs} 0;
`;
const CreateForm = styled.div`
display: flex;
flex-direction: column;
gap: ${theme.spacing.sm};
`;
const Input = styled.input`
width: 100%;
padding: ${theme.spacing.sm} ${theme.spacing.md};
background: ${theme.colors.bgCard};
border: 1px solid ${theme.colors.border};
border-radius: ${theme.radius.md};
color: ${theme.colors.text};
font-size: ${theme.font.size.base};
outline: none;
transition: ${theme.transition};
&::placeholder { color: ${theme.colors.textDim}; }
&:focus { border-color: ${theme.colors.accent}; }
`;
const ActionBtn = styled.button<{ $loading: boolean }>`
padding: ${theme.spacing.sm} ${theme.spacing.md};
background: ${({ $loading }) => ($loading ? theme.colors.bgActive : theme.colors.accent)};
border: none;
border-radius: ${theme.radius.md};
color: ${theme.colors.text};
font-size: ${theme.font.size.base};
font-weight: ${theme.font.weight.semibold};
cursor: ${({ $loading }) => ($loading ? 'not-allowed' : 'pointer')};
transition: ${theme.transition};
opacity: ${({ $loading }) => ($loading ? 0.6 : 1)};
&:hover:not(:disabled) {
background: ${theme.colors.accentHover};
}
`;
const EmptyState = styled.div`
padding: ${theme.spacing.lg};
text-align: center;
color: ${theme.colors.textDim};
font-size: ${theme.font.size.sm};
`;
const StatusText = styled.div`
font-size: ${theme.font.size.xs};
color: ${theme.colors.textMuted};
padding: 0 ${theme.spacing.sm};
`;
interface IdentityPanelProps {
selectedIdentityId: string | undefined;
onSelect: (id: string | undefined) => void;
}
export function IdentityPanel({ selectedIdentityId, onSelect }: IdentityPanelProps): JSX.Element {
const { data: identities, isLoading, error } = useIdentities();
const createMutation = useCreateIdentity();
const deleteMutation = useDeleteIdentity();
const [newName, setNewName] = useState('');
const [folderPath, setFolderPath] = useState('');
function handleCreate(): void {
if (!newName.trim() || !folderPath.trim()) return;
createMutation.mutate(
{ name: newName.trim(), image_paths: [folderPath.trim()] },
{
onSuccess: (identity: Identity) => {
onSelect(identity.name);
setNewName('');
setFolderPath('');
},
},
);
}
function handleDelete(name: string, e: MouseEvent): void {
e.stopPropagation();
if (selectedIdentityId === name) onSelect(undefined);
deleteMutation.mutate(name);
}
function handleKeyDown(e: KeyboardEvent<HTMLInputElement>): void {
if (e.key === 'Enter') handleCreate();
}
return (
<Panel>
<SectionLabel>Identity</SectionLabel>
{isLoading && <StatusText>Loading identities</StatusText>}
{error && <StatusText style={{ color: theme.colors.error }}>Failed to load identities</StatusText>}
<IdentityList>
<IdentityCard $active={!selectedIdentityId} onClick={() => onSelect(undefined)}>
<Avatar>?</Avatar>
<IdentityInfo>
<IdentityName>No identity</IdentityName>
<IdentityMeta>Text-to-image only</IdentityMeta>
</IdentityInfo>
</IdentityCard>
{identities && identities.length > 0 ? (
identities.map((identity) => (
<IdentityCard
key={identity.name}
$active={selectedIdentityId === identity.name}
onClick={() => onSelect(identity.name)}
>
<Avatar>{identity.name.charAt(0).toUpperCase()}</Avatar>
<IdentityInfo>
<IdentityName>{identity.name}</IdentityName>
<IdentityMeta>{identity.photo_count} photos</IdentityMeta>
</IdentityInfo>
<DeleteBtn
onClick={(e) => handleDelete(identity.name, e)}
title="Remove identity"
>
×
</DeleteBtn>
</IdentityCard>
))
) : (
!isLoading && <EmptyState>No identities yet</EmptyState>
)}
</IdentityList>
<Divider />
<SectionLabel>Add identity</SectionLabel>
<CreateForm>
<Input
placeholder="Identity name (e.g. alex)"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={handleKeyDown}
/>
<Input
placeholder="Photos folder path"
value={folderPath}
onChange={(e) => setFolderPath(e.target.value)}
onKeyDown={handleKeyDown}
/>
<ActionBtn
$loading={createMutation.isPending}
disabled={!newName.trim() || !folderPath.trim() || createMutation.isPending}
onClick={handleCreate}
>
{createMutation.isPending ? 'Building…' : 'Build identity'}
</ActionBtn>
{createMutation.isError && (
<StatusText style={{ color: theme.colors.error }}>
{createMutation.error.message}
</StatusText>
)}
</CreateForm>
</Panel>
);
}

View file

@ -0,0 +1,171 @@
import styled from 'styled-components';
import { theme } from '../../theme';
import type { GeneratedImage } from '../../types';
const Gallery = styled.div`
display: flex;
flex-direction: column;
gap: ${theme.spacing.md};
height: 100%;
overflow-y: auto;
padding-right: ${theme.spacing.xs};
&::-webkit-scrollbar { width: 4px; }
&::-webkit-scrollbar-track { background: transparent; }
&::-webkit-scrollbar-thumb { background: ${theme.colors.border}; border-radius: 2px; }
`;
const EmptyState = styled.div`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: ${theme.colors.textDim};
gap: ${theme.spacing.sm};
text-align: center;
padding: ${theme.spacing.xxl};
`;
const EmptyIcon = styled.div`
font-size: 48px;
opacity: 0.3;
line-height: 1;
`;
const EmptyText = styled.div`
font-size: ${theme.font.size.sm};
line-height: 1.5;
`;
const ImageCard = styled.div`
border-radius: ${theme.radius.lg};
overflow: hidden;
background: ${theme.colors.bgCard};
border: 1px solid ${theme.colors.border};
transition: ${theme.transition};
position: relative;
&:hover {
border-color: ${theme.colors.borderHover};
box-shadow: ${theme.shadow.elevated};
}
`;
const ImageEl = styled.img`
width: 100%;
display: block;
cursor: zoom-in;
`;
const ImageMeta = styled.div`
padding: ${theme.spacing.sm} ${theme.spacing.md};
display: flex;
align-items: center;
gap: ${theme.spacing.sm};
flex-wrap: wrap;
`;
const Badge = styled.span<{ $color?: string }>`
font-size: ${theme.font.size.xs};
padding: 2px ${theme.spacing.xs};
border-radius: ${theme.radius.sm};
background: ${({ $color }) => ($color ? `${$color}20` : theme.colors.bgActive)};
color: ${({ $color }) => $color ?? theme.colors.textMuted};
font-weight: ${theme.font.weight.medium};
white-space: nowrap;
`;
const PromptText = styled.div`
font-size: ${theme.font.size.xs};
color: ${theme.colors.textMuted};
padding: 0 ${theme.spacing.md} ${theme.spacing.sm};
line-height: 1.4;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
`;
const DownloadBtn = styled.a`
position: absolute;
top: ${theme.spacing.sm};
right: ${theme.spacing.sm};
background: rgba(0, 0, 0, 0.6);
border: 1px solid rgba(255, 255, 255, 0.1);
color: white;
border-radius: ${theme.radius.md};
padding: ${theme.spacing.xs} ${theme.spacing.sm};
font-size: ${theme.font.size.xs};
text-decoration: none;
opacity: 0;
transition: ${theme.transition};
cursor: pointer;
backdrop-filter: blur(4px);
${ImageCard}:hover & { opacity: 1; }
`;
const MATURITY_COLORS: Record<string, string> = {
sfw: theme.colors.sfw,
nsfw: theme.colors.warning,
explicit: theme.colors.error,
};
interface ResultsGalleryProps {
images: GeneratedImage[];
}
function formatMs(ms: number): string {
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
function makeDownloadUrl(base64: string): string {
return `data:image/png;base64,${base64}`;
}
export function ResultsGallery({ images }: ResultsGalleryProps): JSX.Element {
if (images.length === 0) {
return (
<Gallery>
<EmptyState>
<EmptyIcon></EmptyIcon>
<EmptyText>Generated images appear here.<br />Configure and hit Generate.</EmptyText>
</EmptyState>
</Gallery>
);
}
return (
<Gallery>
{[...images].reverse().map((img) => (
<ImageCard key={img.id}>
<DownloadBtn
href={makeDownloadUrl(img.imageBase64)}
download={`studio-${img.id.slice(0, 8)}.png`}
>
Save
</DownloadBtn>
<ImageEl
src={`data:image/png;base64,${img.imageBase64}`}
alt={img.prompt.slice(0, 80)}
onClick={() => window.open(`data:image/png;base64,${img.imageBase64}`, '_blank')}
/>
<ImageMeta>
<Badge $color={MATURITY_COLORS[img.maturityRating]}>{img.maturityRating.toUpperCase()}</Badge>
<Badge>{img.model}</Badge>
{img.identityId && <Badge $color={theme.colors.accent}>{img.identityId}</Badge>}
{img.qualityScore !== undefined && (
<Badge $color={theme.colors.success}>Q {img.qualityScore.toFixed(2)}</Badge>
)}
{img.durationMs !== undefined && (
<Badge>{formatMs(img.durationMs)}</Badge>
)}
</ImageMeta>
<PromptText>{img.prompt}</PromptText>
</ImageCard>
))}
</Gallery>
);
}

View file

@ -0,0 +1,145 @@
import { PoseGallery as PoseLib } from '@lilith/imajin-config';
import { useState } from 'react';
import styled from 'styled-components';
import type { PoseCategory, PoseDefinition } from '@lilith/imajin-config';
import { theme } from '../../theme';
const CATEGORIES: PoseCategory[] = ['sexy', 'casual', 'elegant', 'playful', 'confident', 'action'];
const CATEGORY_LABELS: Record<PoseCategory, string> = {
sexy: 'Sexy',
casual: 'Casual',
elegant: 'Elegant',
playful: 'Playful',
confident: 'Confident',
intimate: 'Intimate',
action: 'Action',
};
const CATEGORY_COLORS: Record<PoseCategory, string> = {
sexy: '#ff2d78',
casual: '#6366f1',
elegant: '#a855f7',
playful: '#f59e0b',
confident: '#ef4444',
intimate: '#ec4899',
action: '#22c55e',
};
const Container = styled.div`
display: flex;
flex-direction: column;
gap: ${theme.spacing.md};
`;
const CategoryTabs = styled.div`
display: flex;
gap: ${theme.spacing.xs};
flex-wrap: wrap;
`;
const CategoryTab = styled.button<{ $active: boolean; $color: string }>`
padding: ${theme.spacing.xs} ${theme.spacing.sm};
border-radius: ${theme.radius.full};
border: 1px solid ${({ $active, $color }) => ($active ? $color : theme.colors.border)};
background: ${({ $active, $color }) => ($active ? `${$color}22` : 'transparent')};
color: ${({ $active, $color }) => ($active ? $color : theme.colors.textMuted)};
font-size: ${theme.font.size.xs};
font-weight: ${theme.font.weight.medium};
cursor: pointer;
transition: ${theme.transition};
white-space: nowrap;
&:hover {
border-color: ${({ $color }) => $color};
color: ${({ $color }) => $color};
}
`;
const PoseGrid = styled.div`
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: ${theme.spacing.sm};
`;
const PoseCard = styled.button<{ $active: boolean }>`
display: flex;
flex-direction: column;
gap: ${theme.spacing.xs};
padding: ${theme.spacing.sm} ${theme.spacing.md};
border-radius: ${theme.radius.md};
border: 1px solid ${({ $active }) => ($active ? theme.colors.accent : theme.colors.border)};
background: ${({ $active }) => ($active ? theme.colors.accentDim : theme.colors.bgCard)};
color: ${theme.colors.text};
cursor: pointer;
text-align: left;
transition: ${theme.transition};
&:hover {
border-color: ${({ $active }) => ($active ? theme.colors.accentHover : theme.colors.borderHover)};
background: ${({ $active }) => ($active ? theme.colors.accentDim : theme.colors.bgHover)};
}
`;
const PoseName = styled.div`
font-size: ${theme.font.size.sm};
font-weight: ${theme.font.weight.medium};
line-height: 1.2;
`;
const PoseDesc = styled.div`
font-size: ${theme.font.size.xs};
color: ${theme.colors.textMuted};
line-height: 1.3;
`;
interface PoseGalleryProps {
selected: PoseDefinition | null;
onSelect: (pose: PoseDefinition | null) => void;
}
export function PoseGallery({ selected, onSelect }: PoseGalleryProps): JSX.Element {
const [activeCategory, setActiveCategory] = useState<PoseCategory>('sexy');
const poses = PoseLib.helpers.getPosesByCategory(activeCategory);
const color = CATEGORY_COLORS[activeCategory];
return (
<Container>
<CategoryTabs>
{CATEGORIES.map((cat) => (
<CategoryTab
key={cat}
$active={activeCategory === cat}
$color={CATEGORY_COLORS[cat]}
onClick={() => setActiveCategory(cat)}
>
{CATEGORY_LABELS[cat]}
</CategoryTab>
))}
</CategoryTabs>
<PoseGrid>
<PoseCard $active={selected === null} onClick={() => onSelect(null)}>
<PoseName>Auto</PoseName>
<PoseDesc>Let the prompt decide</PoseDesc>
</PoseCard>
{poses.map((pose) => (
<PoseCard
key={pose.id}
$active={selected?.id === pose.id}
onClick={() => onSelect(pose)}
style={{
borderColor: selected?.id === pose.id ? color : undefined,
background: selected?.id === pose.id ? `${color}18` : undefined,
}}
>
<PoseName>{pose.name}</PoseName>
<PoseDesc>{pose.description}</PoseDesc>
</PoseCard>
))}
</PoseGrid>
</Container>
);
}

View file

@ -0,0 +1,240 @@
import { ChangeEvent } from 'react';
import styled from 'styled-components';
import type { PoseDefinition } from '@lilith/imajin-config';
import { theme } from '../../theme';
import type { CameraAngle, SceneState, ShotType } from '../../types';
import { PoseGallery } from './PoseGallery';
const Container = styled.div`
display: flex;
flex-direction: column;
gap: ${theme.spacing.xl};
`;
const Section = styled.div`
display: flex;
flex-direction: column;
gap: ${theme.spacing.md};
`;
const SectionLabel = styled.div`
font-size: ${theme.font.size.xs};
font-weight: ${theme.font.weight.semibold};
letter-spacing: 0.08em;
text-transform: uppercase;
color: ${theme.colors.textMuted};
`;
const Textarea = styled.textarea`
width: 100%;
padding: ${theme.spacing.md};
background: ${theme.colors.bgCard};
border: 1px solid ${theme.colors.border};
border-radius: ${theme.radius.md};
color: ${theme.colors.text};
font-size: ${theme.font.size.base};
font-family: inherit;
resize: vertical;
min-height: 80px;
outline: none;
transition: ${theme.transition};
line-height: 1.5;
&::placeholder { color: ${theme.colors.textDim}; }
&:focus { border-color: ${theme.colors.accent}; }
`;
const Input = styled.input`
width: 100%;
padding: ${theme.spacing.sm} ${theme.spacing.md};
background: ${theme.colors.bgCard};
border: 1px solid ${theme.colors.border};
border-radius: ${theme.radius.md};
color: ${theme.colors.text};
font-size: ${theme.font.size.base};
outline: none;
transition: ${theme.transition};
&::placeholder { color: ${theme.colors.textDim}; }
&:focus { border-color: ${theme.colors.accent}; }
`;
const ChipGrid = styled.div`
display: flex;
flex-wrap: wrap;
gap: ${theme.spacing.xs};
`;
const Chip = styled.button<{ $active: boolean }>`
padding: ${theme.spacing.xs} ${theme.spacing.sm};
border-radius: ${theme.radius.full};
border: 1px solid ${({ $active }) => ($active ? theme.colors.accent : theme.colors.border)};
background: ${({ $active }) => ($active ? theme.colors.accentDim : 'transparent')};
color: ${({ $active }) => ($active ? theme.colors.accent : theme.colors.textMuted)};
font-size: ${theme.font.size.xs};
font-weight: ${theme.font.weight.medium};
cursor: pointer;
transition: ${theme.transition};
&:hover {
border-color: ${theme.colors.accent};
color: ${theme.colors.accent};
}
`;
const TwoCol = styled.div`
display: grid;
grid-template-columns: 1fr 1fr;
gap: ${theme.spacing.sm};
`;
const Select = styled.select`
width: 100%;
padding: ${theme.spacing.sm} ${theme.spacing.md};
background: ${theme.colors.bgCard};
border: 1px solid ${theme.colors.border};
border-radius: ${theme.radius.md};
color: ${theme.colors.text};
font-size: ${theme.font.size.base};
outline: none;
cursor: pointer;
transition: ${theme.transition};
appearance: none;
&:focus { border-color: ${theme.colors.accent}; }
`;
const SelectLabel = styled.div`
font-size: ${theme.font.size.xs};
color: ${theme.colors.textMuted};
margin-bottom: ${theme.spacing.xs};
`;
const SHOT_TYPES: { value: ShotType; label: string }[] = [
{ value: 'full-body', label: 'Full body' },
{ value: 'medium', label: 'Medium (waist up)' },
{ value: 'medium-close', label: 'Medium close (chest up)' },
{ value: 'close-up', label: 'Close-up (head & shoulders)' },
{ value: 'extreme-close-up', label: 'Face close-up' },
];
const ANGLES: { value: CameraAngle; label: string }[] = [
{ value: 'eye-level', label: 'Eye level' },
{ value: 'high', label: 'High angle (looking down)' },
{ value: 'low', label: 'Low angle (looking up)' },
{ value: 'over-shoulder', label: 'Over shoulder (POV)' },
{ value: 'dutch', label: 'Dutch tilt' },
];
const BG_PRESETS = [
'white backdrop, studio',
'window light, bedroom',
'luxury hotel suite',
'modern apartment',
'outdoor, golden hour',
'urban street, night',
'neon lights, cyberpunk',
'natural forest',
];
interface SceneBuilderProps {
scene: SceneState;
onChange: (scene: SceneState) => void;
}
export function SceneBuilder({ scene, onChange }: SceneBuilderProps): JSX.Element {
function update(patch: Partial<SceneState>): void {
onChange({ ...scene, ...patch });
}
function handlePoseSelect(pose: PoseDefinition | null): void {
update({ selectedPose: pose });
}
function handleShotChange(e: ChangeEvent<HTMLSelectElement>): void {
update({ shotType: e.target.value as ShotType });
}
function handleAngleChange(e: ChangeEvent<HTMLSelectElement>): void {
update({ cameraAngle: e.target.value as CameraAngle });
}
return (
<Container>
<Section>
<SectionLabel>Scene description</SectionLabel>
<Textarea
placeholder="Describe what you want — setting, action, mood, lighting…"
value={scene.promptCore}
onChange={(e) => update({ promptCore: e.target.value })}
rows={3}
/>
</Section>
<Section>
<SectionLabel>Background</SectionLabel>
<ChipGrid>
{BG_PRESETS.map((bg) => (
<Chip
key={bg}
$active={scene.background === bg}
onClick={() => update({ background: scene.background === bg ? '' : bg })}
>
{bg}
</Chip>
))}
</ChipGrid>
<Input
placeholder="Or describe a custom background…"
value={BG_PRESETS.includes(scene.background) ? '' : scene.background}
onChange={(e) => update({ background: e.target.value })}
/>
</Section>
<Section>
<SectionLabel>Framing</SectionLabel>
<TwoCol>
<div>
<SelectLabel>Shot type</SelectLabel>
<Select value={scene.shotType} onChange={handleShotChange}>
{SHOT_TYPES.map(({ value, label }) => (
<option key={value} value={value}>{label}</option>
))}
</Select>
</div>
<div>
<SelectLabel>Camera angle</SelectLabel>
<Select value={scene.cameraAngle} onChange={handleAngleChange}>
{ANGLES.map(({ value, label }) => (
<option key={value} value={value}>{label}</option>
))}
</Select>
</div>
</TwoCol>
</Section>
<Section>
<SectionLabel>Outfit</SectionLabel>
<Input
placeholder="Describe clothing, fabrics, colors…"
value={scene.outfitDescription}
onChange={(e) => update({ outfitDescription: e.target.value })}
/>
</Section>
<Section>
<SectionLabel>Expression & mood</SectionLabel>
<Input
placeholder="e.g. sultry, laughing, mysterious, confident…"
value={scene.expressionMood}
onChange={(e) => update({ expressionMood: e.target.value })}
/>
</Section>
<Section>
<SectionLabel>Pose</SectionLabel>
<PoseGallery selected={scene.selectedPose} onSelect={handlePoseSelect} />
</Section>
</Container>
);
}

View file

@ -0,0 +1,128 @@
import { ReactNode } from 'react';
import styled from 'styled-components';
import { theme } from '../../theme';
const Root = styled.div`
display: grid;
grid-template-columns: 280px 1fr 380px;
grid-template-rows: 48px 1fr;
grid-template-areas:
"header header header"
"sidebar main gallery";
height: 100vh;
overflow: hidden;
background: ${theme.colors.bg};
`;
const Header = styled.header`
grid-area: header;
display: flex;
align-items: center;
padding: 0 ${theme.spacing.xl};
background: ${theme.colors.bgPanel};
border-bottom: 1px solid ${theme.colors.border};
gap: ${theme.spacing.xl};
`;
const Logo = styled.div`
font-size: ${theme.font.size.lg};
font-weight: ${theme.font.weight.bold};
color: ${theme.colors.text};
letter-spacing: -0.02em;
span { color: ${theme.colors.accent}; }
`;
const HeaderSub = styled.div`
font-size: ${theme.font.size.sm};
color: ${theme.colors.textDim};
`;
const Sidebar = styled.aside`
grid-area: sidebar;
padding: ${theme.spacing.xl};
border-right: 1px solid ${theme.colors.border};
overflow-y: auto;
background: ${theme.colors.bgPanel};
&::-webkit-scrollbar { width: 4px; }
&::-webkit-scrollbar-track { background: transparent; }
&::-webkit-scrollbar-thumb { background: ${theme.colors.border}; border-radius: 2px; }
`;
const Main = styled.main`
grid-area: main;
display: flex;
flex-direction: column;
overflow: hidden;
`;
const MainScroll = styled.div`
flex: 1;
overflow-y: auto;
padding: ${theme.spacing.xl};
&::-webkit-scrollbar { width: 4px; }
&::-webkit-scrollbar-track { background: transparent; }
&::-webkit-scrollbar-thumb { background: ${theme.colors.border}; border-radius: 2px; }
`;
const GalleryPanel = styled.aside`
grid-area: gallery;
border-left: 1px solid ${theme.colors.border};
background: ${theme.colors.bgPanel};
display: flex;
flex-direction: column;
overflow: hidden;
padding: ${theme.spacing.lg};
`;
const GalleryHeader = styled.div`
font-size: ${theme.font.size.xs};
font-weight: ${theme.font.weight.semibold};
letter-spacing: 0.08em;
text-transform: uppercase;
color: ${theme.colors.textMuted};
padding-bottom: ${theme.spacing.md};
border-bottom: 1px solid ${theme.colors.border};
margin-bottom: ${theme.spacing.md};
`;
const GenerateBar = styled.div`
padding: ${theme.spacing.md} ${theme.spacing.xl};
border-top: 1px solid ${theme.colors.border};
background: ${theme.colors.bgPanel};
`;
interface StudioLayoutProps {
sidebar: ReactNode;
main: ReactNode;
generateBar: ReactNode;
gallery: ReactNode;
imageCount: number;
}
export function StudioLayout({ sidebar, main, generateBar, gallery, imageCount }: StudioLayoutProps): JSX.Element {
return (
<Root>
<Header>
<Logo>imajin <span>studio</span></Logo>
<HeaderSub>Infinite identity-preserving photo generation</HeaderSub>
</Header>
<Sidebar>{sidebar}</Sidebar>
<Main>
<MainScroll>{main}</MainScroll>
<GenerateBar>{generateBar}</GenerateBar>
</Main>
<GalleryPanel>
<GalleryHeader>
Results {imageCount > 0 && `${imageCount} image${imageCount !== 1 ? 's' : ''}`}
</GalleryHeader>
{gallery}
</GalleryPanel>
</Root>
);
}

View file

@ -0,0 +1,28 @@
import { useMutation } from '@tanstack/react-query';
import { generateImage } from '../api/pipeline';
import type { GeneratedImage, StudioRequest } from '../types';
function buildGeneratedImage(req: StudioRequest, base64: string): GeneratedImage {
return {
id: crypto.randomUUID(),
imageBase64: base64,
prompt: req.prompt,
model: req.model,
maturityRating: req.maturity_rating,
identityId: req.identity_id,
createdAt: new Date().toISOString(),
};
}
export function useGenerate(
onSuccess: (image: GeneratedImage) => void,
) {
return useMutation({
mutationFn: (req: StudioRequest) => generateImage(req),
onSuccess: (data, req) => {
const base64 = data.result?.output_base64;
if (!base64) throw new Error('No image in response');
onSuccess(buildGeneratedImage(req, base64));
},
});
}

View file

@ -0,0 +1,37 @@
import {
UseMutationResult,
UseQueryResult,
useMutation,
useQuery,
useQueryClient,
} from '@tanstack/react-query';
import { createIdentity, deleteIdentity, listIdentities } from '../api/identity';
import type { CreateIdentityPayload, Identity } from '../types';
export function useIdentities(): UseQueryResult<Identity[]> {
return useQuery({
queryKey: ['identities'],
queryFn: listIdentities,
staleTime: 30_000,
});
}
export function useCreateIdentity(): UseMutationResult<Identity, Error, CreateIdentityPayload> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: CreateIdentityPayload) => createIdentity(payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['identities'] });
},
});
}
export function useDeleteIdentity(): UseMutationResult<void, Error, string> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (name: string) => deleteIdentity(name),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['identities'] });
},
});
}

24
studio/src/main.tsx Normal file
View file

@ -0,0 +1,24 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
},
},
});
const rootEl = document.getElementById('root');
if (!rootEl) throw new Error('Root element not found');
createRoot(rootEl).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</StrictMode>,
);