From 076fd98e79bbb6eb6a799d3dfbdaba5f4a92f43d Mon Sep 17 00:00:00 2001 From: Claude Code Date: Sun, 29 Mar 2026 15:03:05 -0700 Subject: [PATCH] =?UTF-8?q?refactor(studio):=20=E2=99=BB=EF=B8=8F=20Implem?= =?UTF-8?q?ent=20modular=20studio=20logic=20in=20App,=20hooks,=20and=20UI?= =?UTF-8?q?=20components=20to=20improve=20state=20management=20and=20perfo?= =?UTF-8?q?rmance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- studio/src/App.tsx | 229 ++++++++++++ studio/src/components/AdvancedPanel/index.tsx | 346 ++++++++++++++++++ studio/src/components/IdentityPanel/index.tsx | 265 ++++++++++++++ .../src/components/ResultsGallery/index.tsx | 171 +++++++++ .../components/SceneBuilder/PoseGallery.tsx | 145 ++++++++ studio/src/components/SceneBuilder/index.tsx | 240 ++++++++++++ studio/src/components/StudioLayout/index.tsx | 128 +++++++ studio/src/hooks/useGenerate.ts | 28 ++ studio/src/hooks/useIdentities.ts | 37 ++ studio/src/main.tsx | 24 ++ 10 files changed, 1613 insertions(+) create mode 100644 studio/src/App.tsx create mode 100644 studio/src/components/AdvancedPanel/index.tsx create mode 100644 studio/src/components/IdentityPanel/index.tsx create mode 100644 studio/src/components/ResultsGallery/index.tsx create mode 100644 studio/src/components/SceneBuilder/PoseGallery.tsx create mode 100644 studio/src/components/SceneBuilder/index.tsx create mode 100644 studio/src/components/StudioLayout/index.tsx create mode 100644 studio/src/hooks/useGenerate.ts create mode 100644 studio/src/hooks/useIdentities.ts create mode 100644 studio/src/main.tsx diff --git a/studio/src/App.tsx b/studio/src/App.tsx new file mode 100644 index 00000000..5f3691e9 --- /dev/null +++ b/studio/src/App.tsx @@ -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 = { + 'full-body': 'full body shot', + medium: 'medium shot, waist up', + 'medium-close': 'medium close-up, chest up', + 'close-up': 'close-up, head and shoulders', + 'extreme-close-up': 'extreme close-up, face', + }; + parts.push(shotMap[scene.shotType] ?? scene.shotType); + + const angleMap: Record = { + 'eye-level': 'eye level', + high: 'high angle camera', + low: 'low angle camera', + 'over-shoulder': 'over shoulder shot', + dutch: 'dutch angle', + }; + parts.push(angleMap[scene.cameraAngle] ?? scene.cameraAngle); + + if (scene.background.trim()) parts.push(scene.background.trim()); + if (scene.expressionMood.trim()) parts.push(scene.expressionMood.trim()); + + 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(undefined); + const [scene, setScene] = useState(DEFAULT_SCENE); + const [advancedValues, setAdvancedValues] = useState(DEFAULT_REQUEST); + const [images, setImages] = useState([]); + + 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): void { + setAdvancedValues((prev) => ({ ...prev, ...patch })); + } + + const isGenerating = generateMutation.isPending; + + const sidebar = ( + + + + + + ); + + const generateBar = ( + + + {isGenerating ? 'Generating…' : 'Generate'} + + {generateMutation.isError && ( + + {generateMutation.error.message} + + )} + {isGenerating && ( + Running pipeline… + )} + + ); + + return ( + } + generateBar={generateBar} + gallery={} + imageCount={images.length} + /> + ); +} diff --git a/studio/src/components/AdvancedPanel/index.tsx b/studio/src/components/AdvancedPanel/index.tsx new file mode 100644 index 00000000..d34e2a5d --- /dev/null +++ b/studio/src/components/AdvancedPanel/index.tsx @@ -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) => void; + hasIdentity: boolean; +} + +export function AdvancedPanel({ values, onChange, hasIdentity }: AdvancedPanelProps): JSX.Element { + function handleModelChange(e: ChangeEvent): 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): void { + onChange({ [field]: parseFloat(e.target.value) } as Partial); + } + + function handleIntSlider(field: keyof AdvancedFields, e: ChangeEvent): void { + onChange({ [field]: parseInt(e.target.value, 10) } as Partial); + } + + return ( + + Content rating + + + {(['sfw', 'nsfw', 'explicit'] as MaturityRating[]).map((r) => ( + onChange({ maturity_rating: r })} + > + {r} + + ))} + + + + + + Model + + + {MODELS.map(({ value, label, tag }) => ( + + ))} + + + + + + Generation + + + Steps + {values.steps} + + handleIntSlider('steps', e)} + /> + + + + Guidance scale + {values.guidance_scale.toFixed(1)} + + handleSlider('guidance_scale', e)} + /> + + + + Candidates (keep best) + {values.num_candidates} + + handleIntSlider('num_candidates', e)} + /> + + + + Anatomy correction + onChange({ enable_anatomy_fix: e.target.checked })} + /> + + + + {hasIdentity && ( + <> + + Identity preservation + + + Identity strength + {values.identity_strength.toFixed(2)} + + handleSlider('identity_strength', e)} + /> + + + {values.use_flux_pulid ? ( + + + PuLID weight + {values.pulid_weight.toFixed(2)} + + handleSlider('pulid_weight', e)} + /> + + ) : ( + <> + + + InstantID (face keypoints) + onChange({ enable_instantid: e.target.checked })} + /> + + + + + IP-Adapter scale + {values.ip_adapter_scale.toFixed(2)} + + handleSlider('ip_adapter_scale', e)} + /> + + + )} + + )} + + ); +} diff --git a/studio/src/components/IdentityPanel/index.tsx b/studio/src/components/IdentityPanel/index.tsx new file mode 100644 index 00000000..26ea77eb --- /dev/null +++ b/studio/src/components/IdentityPanel/index.tsx @@ -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): void { + if (e.key === 'Enter') handleCreate(); + } + + return ( + + Identity + + {isLoading && Loading identities…} + {error && Failed to load identities} + + + onSelect(undefined)}> + ? + + No identity + Text-to-image only + + + + {identities && identities.length > 0 ? ( + identities.map((identity) => ( + onSelect(identity.name)} + > + {identity.name.charAt(0).toUpperCase()} + + {identity.name} + {identity.photo_count} photos + + handleDelete(identity.name, e)} + title="Remove identity" + > + × + + + )) + ) : ( + !isLoading && No identities yet + )} + + + + + Add identity + + setNewName(e.target.value)} + onKeyDown={handleKeyDown} + /> + setFolderPath(e.target.value)} + onKeyDown={handleKeyDown} + /> + + {createMutation.isPending ? 'Building…' : 'Build identity'} + + {createMutation.isError && ( + + {createMutation.error.message} + + )} + + + ); +} diff --git a/studio/src/components/ResultsGallery/index.tsx b/studio/src/components/ResultsGallery/index.tsx new file mode 100644 index 00000000..f4c4db67 --- /dev/null +++ b/studio/src/components/ResultsGallery/index.tsx @@ -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 = { + 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 ( + + + + Generated images appear here.
Configure and hit Generate.
+
+
+ ); + } + + return ( + + {[...images].reverse().map((img) => ( + + + ↓ Save + + window.open(`data:image/png;base64,${img.imageBase64}`, '_blank')} + /> + + {img.maturityRating.toUpperCase()} + {img.model} + {img.identityId && {img.identityId}} + {img.qualityScore !== undefined && ( + Q {img.qualityScore.toFixed(2)} + )} + {img.durationMs !== undefined && ( + {formatMs(img.durationMs)} + )} + + {img.prompt} + + ))} + + ); +} diff --git a/studio/src/components/SceneBuilder/PoseGallery.tsx b/studio/src/components/SceneBuilder/PoseGallery.tsx new file mode 100644 index 00000000..6cad8d66 --- /dev/null +++ b/studio/src/components/SceneBuilder/PoseGallery.tsx @@ -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 = { + sexy: 'Sexy', + casual: 'Casual', + elegant: 'Elegant', + playful: 'Playful', + confident: 'Confident', + intimate: 'Intimate', + action: 'Action', +}; + +const CATEGORY_COLORS: Record = { + 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('sexy'); + + const poses = PoseLib.helpers.getPosesByCategory(activeCategory); + const color = CATEGORY_COLORS[activeCategory]; + + return ( + + + {CATEGORIES.map((cat) => ( + setActiveCategory(cat)} + > + {CATEGORY_LABELS[cat]} + + ))} + + + + onSelect(null)}> + Auto + Let the prompt decide + + + {poses.map((pose) => ( + onSelect(pose)} + style={{ + borderColor: selected?.id === pose.id ? color : undefined, + background: selected?.id === pose.id ? `${color}18` : undefined, + }} + > + {pose.name} + {pose.description} + + ))} + + + ); +} diff --git a/studio/src/components/SceneBuilder/index.tsx b/studio/src/components/SceneBuilder/index.tsx new file mode 100644 index 00000000..0363ad23 --- /dev/null +++ b/studio/src/components/SceneBuilder/index.tsx @@ -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): void { + onChange({ ...scene, ...patch }); + } + + function handlePoseSelect(pose: PoseDefinition | null): void { + update({ selectedPose: pose }); + } + + function handleShotChange(e: ChangeEvent): void { + update({ shotType: e.target.value as ShotType }); + } + + function handleAngleChange(e: ChangeEvent): void { + update({ cameraAngle: e.target.value as CameraAngle }); + } + + return ( + +
+ Scene description +