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:
parent
cac7ae71f2
commit
076fd98e79
10 changed files with 1613 additions and 0 deletions
229
studio/src/App.tsx
Normal file
229
studio/src/App.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
346
studio/src/components/AdvancedPanel/index.tsx
Normal file
346
studio/src/components/AdvancedPanel/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
265
studio/src/components/IdentityPanel/index.tsx
Normal file
265
studio/src/components/IdentityPanel/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
171
studio/src/components/ResultsGallery/index.tsx
Normal file
171
studio/src/components/ResultsGallery/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
145
studio/src/components/SceneBuilder/PoseGallery.tsx
Normal file
145
studio/src/components/SceneBuilder/PoseGallery.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
240
studio/src/components/SceneBuilder/index.tsx
Normal file
240
studio/src/components/SceneBuilder/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
128
studio/src/components/StudioLayout/index.tsx
Normal file
128
studio/src/components/StudioLayout/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
studio/src/hooks/useGenerate.ts
Normal file
28
studio/src/hooks/useGenerate.ts
Normal 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));
|
||||
},
|
||||
});
|
||||
}
|
||||
37
studio/src/hooks/useIdentities.ts
Normal file
37
studio/src/hooks/useIdentities.ts
Normal 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
24
studio/src/main.tsx
Normal 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>,
|
||||
);
|
||||
Loading…
Add table
Reference in a new issue