feat(studio): Update SceneBuilder and StudioLayout components to add new scene manipulation features and enhance UI layout

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-30 14:20:10 -07:00
parent 41be4a2fd6
commit 3fd0d7b8e0
2 changed files with 209 additions and 320 deletions

View file

@ -1,4 +1,4 @@
import { ChangeEvent, ReactElement, useRef, useState } from 'react';
import { ChangeEvent, ReactElement, useRef } from 'react';
import styled from 'styled-components';
import type { PoseDefinition } from '@lilith/imajin-config';
import { theme } from '../../theme';
@ -6,34 +6,16 @@ import type { CameraAngle, MaturityRating, SceneState, ShotType } from '../../ty
import { PoseGallery } from './PoseGallery';
import { PromptHistory, type HistoryEntry } from './PromptHistory';
// 2-column grid — sections span 1 or 2 columns depending on content width needs
const Container = styled.div`
display: flex;
flex-direction: column;
display: grid;
grid-template-columns: 1fr 1fr;
gap: ${theme.spacing.xl};
align-items: start;
`;
const TabRow = styled.div`
display: flex;
gap: ${theme.spacing.xs};
padding-bottom: ${theme.spacing.lg};
border-bottom: 1px solid ${theme.colors.border};
`;
const Tab = styled.button<{ $active: boolean }>`
padding: ${theme.spacing.xs} ${theme.spacing.md};
font-size: ${theme.font.size.sm};
font-weight: ${theme.font.weight.semibold};
border: 1px solid ${({ $active }) => ($active ? theme.colors.accent : theme.colors.border)};
border-radius: ${theme.radius.full};
background: ${({ $active }) => ($active ? theme.colors.accentDim : 'transparent')};
color: ${({ $active }) => ($active ? theme.colors.accent : theme.colors.textMuted)};
cursor: pointer;
transition: ${theme.transition};
&:hover {
border-color: ${({ $active }) => ($active ? theme.colors.accent : theme.colors.borderHover)};
color: ${({ $active }) => ($active ? theme.colors.accent : theme.colors.text)};
}
const FullWidth = styled.div`
grid-column: 1 / -1;
`;
const Section = styled.div`
@ -329,11 +311,8 @@ interface SceneBuilderProps {
// ─── Component ───────────────────────────────────────────────────────────────
type SceneTab = 'scene' | 'subject';
export function SceneBuilder({ scene, maturityRating, onChange, onRestoreHistory }: SceneBuilderProps): ReactElement {
const fileInputRef = useRef<HTMLInputElement>(null);
const [tab, setTab] = useState<SceneTab>('scene');
function update(patch: Partial<SceneState>): void {
onChange({ ...scene, ...patch });
@ -376,177 +355,166 @@ export function SceneBuilder({ scene, maturityRating, onChange, onRestoreHistory
return (
<Container>
{/* Prompt — always visible above tabs */}
{/* Row 1: Prompt — full width */}
<FullWidth>
<Section>
<SectionHeader>
<SectionLabel>Scene description</SectionLabel>
<SectionActions>
<SmallBtn onClick={handleRandomize} title="Randomize background and mood"></SmallBtn>
<PromptHistory onRestore={handleRestoreHistory} />
</SectionActions>
</SectionHeader>
<Textarea
placeholder={SCENE_SAMPLES[maturityRating]}
value={scene.promptCore}
onChange={(e) => update({ promptCore: e.target.value })}
rows={2}
/>
</Section>
</FullWidth>
{/* Row 2: Background — full width (chip grid needs the space) */}
<FullWidth>
<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>
</FullWidth>
{/* Row 3: Framing (left) | Lighting (right) */}
<Section>
<SectionHeader>
<SectionLabel>Scene description</SectionLabel>
<SectionActions>
<SmallBtn onClick={handleRandomize} title="Randomize background and mood">
</SmallBtn>
<PromptHistory onRestore={handleRestoreHistory} />
</SectionActions>
</SectionHeader>
<Textarea
placeholder={SCENE_SAMPLES[maturityRating]}
value={scene.promptCore}
onChange={(e) => update({ promptCore: e.target.value })}
rows={3}
<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>Lighting</SectionLabel>
<ChipGrid>
{LIGHTING_OPTIONS.map(({ value, label }) => (
<Chip
key={value}
$active={scene.lighting === value}
onClick={() => update({ lighting: scene.lighting === value ? '' : value })}
>
{label}
</Chip>
))}
</ChipGrid>
</Section>
{/* Row 4: Style (left) | Expression & mood (right) */}
<Section>
<SectionLabel>Style</SectionLabel>
<ChipGrid>
{STYLE_OPTIONS.map(({ value, label }) => (
<Chip
key={value}
$active={scene.stylePreset === value}
onClick={() => update({ stylePreset: scene.stylePreset === value ? '' : value })}
>
{label}
</Chip>
))}
</ChipGrid>
</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>
<TabRow>
<Tab $active={tab === 'scene'} onClick={() => setTab('scene')}>Scene</Tab>
<Tab $active={tab === 'subject'} onClick={() => setTab('subject')}>
Subject{scene.selectedPose ? ' ·' : ''}
</Tab>
</TabRow>
{/* Row 5: Outfit (left) | Reference image (right) */}
<Section>
<SectionLabel>Outfit</SectionLabel>
<Input
placeholder="Describe clothing, fabrics, colors…"
value={scene.outfitDescription}
onChange={(e) => update({ outfitDescription: e.target.value })}
/>
</Section>
{tab === 'scene' && (
<>
<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>Reference image</SectionLabel>
{scene.referenceImage ? (
<RefImageRow>
<RefThumb src={`data:image/png;base64,${scene.referenceImage}`} alt="Reference" />
<RefControls>
<SliderLabel>
Denoising strength
<SliderValue>{scene.denoisingStrength.toFixed(2)}</SliderValue>
</SliderLabel>
<Slider
type="range"
min={0.1}
max={0.95}
step={0.05}
value={scene.denoisingStrength}
onChange={(e) => update({ denoisingStrength: parseFloat(e.target.value) })}
/>
<RemoveBtn onClick={() => update({ referenceImage: undefined })}> Remove</RemoveBtn>
</RefControls>
</RefImageRow>
) : (
<DropZone onClick={() => fileInputRef.current?.click()}>
Drop or click to upload a reference image<br />
<span style={{ fontSize: theme.font.size.xs, opacity: 0.6 }}>
img2img conditioning
</span>
</DropZone>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={handleFileChange}
/>
</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>Lighting</SectionLabel>
<ChipGrid>
{LIGHTING_OPTIONS.map(({ value, label }) => (
<Chip
key={value}
$active={scene.lighting === value}
onClick={() => update({ lighting: scene.lighting === value ? '' : value })}
>
{label}
</Chip>
))}
</ChipGrid>
</Section>
<Section>
<SectionLabel>Style</SectionLabel>
<ChipGrid>
{STYLE_OPTIONS.map(({ value, label }) => (
<Chip
key={value}
$active={scene.stylePreset === value}
onClick={() => update({ stylePreset: scene.stylePreset === value ? '' : value })}
>
{label}
</Chip>
))}
</ChipGrid>
</Section>
</>
)}
{tab === 'subject' && (
<>
<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>Reference image</SectionLabel>
{scene.referenceImage ? (
<RefImageRow>
<RefThumb
src={`data:image/png;base64,${scene.referenceImage}`}
alt="Reference"
/>
<RefControls>
<SliderLabel>
Denoising strength
<SliderValue>{scene.denoisingStrength.toFixed(2)}</SliderValue>
</SliderLabel>
<Slider
type="range"
min={0.1}
max={0.95}
step={0.05}
value={scene.denoisingStrength}
onChange={(e) => update({ denoisingStrength: parseFloat(e.target.value) })}
/>
<RemoveBtn onClick={() => update({ referenceImage: undefined })}>
Remove
</RemoveBtn>
</RefControls>
</RefImageRow>
) : (
<DropZone onClick={() => fileInputRef.current?.click()}>
Drop or click to upload a reference image<br />
<span style={{ fontSize: theme.font.size.xs, opacity: 0.6 }}>
Style, lighting, composition used as img2img conditioning
</span>
</DropZone>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={handleFileChange}
/>
</Section>
<Section>
<SectionLabel>Pose</SectionLabel>
<PoseGallery selected={scene.selectedPose} onSelect={handlePoseSelect} />
</Section>
</>
)}
{/* Row 6: Pose — full width */}
<FullWidth>
<Section>
<SectionLabel>Pose</SectionLabel>
<PoseGallery selected={scene.selectedPose} onSelect={handlePoseSelect} />
</Section>
</FullWidth>
</Container>
);
}

View file

@ -1,12 +1,12 @@
import { ReactElement, ReactNode, useState } from 'react';
import { ReactElement, ReactNode } from 'react';
import styled from 'styled-components';
import { theme } from '../../theme';
// ─── Layout shell ─────────────────────────────────────────────────────────────
const Root = styled.div<{ $panelOpen: boolean }>`
const Root = styled.div`
display: grid;
grid-template-columns: 220px 1fr ${({ $panelOpen }) => ($panelOpen ? '360px' : '48px')};
grid-template-columns: 220px 1fr 340px;
grid-template-rows: 48px 1fr;
grid-template-areas:
"header header header"
@ -14,7 +14,6 @@ const Root = styled.div<{ $panelOpen: boolean }>`
height: 100vh;
overflow: hidden;
background: ${theme.colors.bg};
transition: grid-template-columns 0.2s ease;
`;
const Header = styled.header`
@ -76,9 +75,7 @@ const GenerateBar = styled.div`
background: ${theme.colors.bgPanel};
`;
// ─── Right panel ─────────────────────────────────────────────────────────────
type PanelTab = 'settings' | 'results';
// ─── Right panel — always-visible Settings + Results stack ────────────────────
const RightPanel = styled.aside`
grid-area: panel;
@ -89,83 +86,37 @@ const RightPanel = styled.aside`
overflow: hidden;
`;
const TabBtn = styled.button<{ $active: boolean }>`
writing-mode: vertical-rl;
text-orientation: mixed;
transform: rotate(180deg);
padding: ${theme.spacing.sm} 6px;
font-size: ${theme.font.size.xs};
font-weight: ${theme.font.weight.semibold};
letter-spacing: 0.06em;
text-transform: uppercase;
border: none;
border-radius: ${theme.radius.sm};
background: ${({ $active }) => ($active ? theme.colors.accentDim : 'transparent')};
color: ${({ $active }) => ($active ? theme.colors.accent : theme.colors.textMuted)};
cursor: pointer;
transition: ${theme.transition};
white-space: nowrap;
&:hover {
color: ${({ $active }) => ($active ? theme.colors.accent : theme.colors.text)};
background: ${({ $active }) => ($active ? theme.colors.accentDim : theme.colors.bgCard)};
}
const PanelSection = styled.div<{ $flex?: number; $maxHeight?: string }>`
flex: ${({ $flex }) => $flex ?? '0 0 auto'};
${({ $maxHeight }) => $maxHeight ? `max-height: ${$maxHeight};` : ''}
display: flex;
flex-direction: column;
overflow: hidden;
`;
const CollapseBtn = styled.button<{ $panelOpen: boolean }>`
width: 28px;
height: 28px;
const PanelSectionHeader = styled.div`
display: flex;
align-items: center;
justify-content: center;
border: 1px solid ${theme.colors.border};
border-radius: ${theme.radius.sm};
background: transparent;
color: ${theme.colors.textMuted};
cursor: pointer;
font-size: 10px;
transition: ${theme.transition};
&:hover {
color: ${theme.colors.text};
border-color: ${theme.colors.borderHover};
}
`;
const PanelHeader = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
padding: ${theme.spacing.sm} ${theme.spacing.md};
padding: ${theme.spacing.sm} ${theme.spacing.lg};
border-bottom: 1px solid ${theme.colors.border};
min-height: 40px;
flex-shrink: 0;
`;
const PanelTabs = styled.div`
display: flex;
gap: 2px;
`;
const PanelTab = styled.button<{ $active: boolean }>`
padding: ${theme.spacing.xs} ${theme.spacing.sm};
const PanelSectionTitle = styled.div`
font-size: ${theme.font.size.xs};
font-weight: ${theme.font.weight.semibold};
letter-spacing: 0.06em;
letter-spacing: 0.08em;
text-transform: uppercase;
border: 1px solid ${({ $active }) => ($active ? theme.colors.accent : theme.colors.border)};
border-radius: ${theme.radius.sm};
background: ${({ $active }) => ($active ? theme.colors.accentDim : 'transparent')};
color: ${({ $active }) => ($active ? theme.colors.accent : theme.colors.textMuted)};
cursor: pointer;
transition: ${theme.transition};
&:hover {
color: ${({ $active }) => ($active ? theme.colors.accent : theme.colors.text)};
border-color: ${({ $active }) => ($active ? theme.colors.accent : theme.colors.borderHover)};
}
color: ${theme.colors.textMuted};
`;
const PanelBody = styled.div`
const PanelSectionCount = styled.div`
margin-left: auto;
font-size: ${theme.font.size.xs};
color: ${theme.colors.textDim};
`;
const PanelScroll = styled.div`
flex: 1;
overflow-y: auto;
padding: ${theme.spacing.lg};
@ -175,12 +126,10 @@ const PanelBody = styled.div`
&::-webkit-scrollbar-thumb { background: ${theme.colors.border}; border-radius: 2px; }
`;
const CollapsedStrip = styled.div`
display: flex;
flex-direction: column;
align-items: center;
padding-top: ${theme.spacing.sm};
gap: ${theme.spacing.sm};
const Divider = styled.div`
height: 1px;
background: ${theme.colors.border};
flex-shrink: 0;
`;
// ─── Component ────────────────────────────────────────────────────────────────
@ -195,24 +144,8 @@ interface StudioLayoutProps {
}
export function StudioLayout({ sidebar, main, generateBar, settings, gallery, imageCount }: StudioLayoutProps): ReactElement {
const [activeTab, setActiveTab] = useState<PanelTab>('settings');
const [panelOpen, setPanelOpen] = useState(true);
function handleTabClick(tab: PanelTab): void {
if (!panelOpen) {
setPanelOpen(true);
setActiveTab(tab);
} else if (activeTab === tab) {
setPanelOpen(false);
} else {
setActiveTab(tab);
}
}
const resultsLabel = imageCount > 0 ? `Results (${imageCount})` : 'Results';
return (
<Root $panelOpen={panelOpen}>
<Root>
<Header>
<Logo>imajin <span>studio</span></Logo>
<HeaderSub>Infinite identity-preserving photo generation</HeaderSub>
@ -226,38 +159,26 @@ export function StudioLayout({ sidebar, main, generateBar, settings, gallery, im
</Main>
<RightPanel>
{panelOpen ? (
<>
<PanelHeader>
<PanelTabs>
<PanelTab $active={activeTab === 'settings'} onClick={() => handleTabClick('settings')}>
Settings
</PanelTab>
<PanelTab $active={activeTab === 'results'} onClick={() => handleTabClick('results')}>
{resultsLabel}
</PanelTab>
</PanelTabs>
<CollapseBtn $panelOpen={panelOpen} onClick={() => setPanelOpen(false)} title="Collapse panel">
</CollapseBtn>
</PanelHeader>
<PanelBody>
{activeTab === 'settings' ? settings : gallery}
</PanelBody>
</>
) : (
<CollapsedStrip>
<CollapseBtn $panelOpen={panelOpen} onClick={() => setPanelOpen(true)} title="Expand panel">
</CollapseBtn>
<TabBtn $active={false} onClick={() => handleTabClick('settings')}>
Settings
</TabBtn>
<TabBtn $active={false} onClick={() => handleTabClick('results')}>
{resultsLabel}
</TabBtn>
</CollapsedStrip>
)}
{/* Settings — scrollable, takes natural height up to 60% */}
<PanelSection $maxHeight="60%">
<PanelSectionHeader>
<PanelSectionTitle>Settings</PanelSectionTitle>
</PanelSectionHeader>
<PanelScroll>{settings}</PanelScroll>
</PanelSection>
<Divider />
{/* Results — fills remaining space */}
<PanelSection $flex={1}>
<PanelSectionHeader>
<PanelSectionTitle>Results</PanelSectionTitle>
{imageCount > 0 && (
<PanelSectionCount>{imageCount} image{imageCount !== 1 ? 's' : ''}</PanelSectionCount>
)}
</PanelSectionHeader>
<PanelScroll>{gallery}</PanelScroll>
</PanelSection>
</RightPanel>
</Root>
);