feat(studio): ✨ Add and update core studio application components and TypeScript types for new functionality
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
034bc70eb2
commit
a91818ff86
3 changed files with 312 additions and 118 deletions
|
|
@ -6,98 +6,24 @@ import { AdvancedPanel } from './components/AdvancedPanel';
|
|||
import { FilmStrip } from './components/FilmStrip';
|
||||
import { IdentityPanel } from './components/IdentityPanel';
|
||||
import { Lightbox } from './components/Lightbox';
|
||||
import { PromptImproveModal } from './components/PromptImproveModal';
|
||||
import { SceneBuilder } from './components/SceneBuilder';
|
||||
import { StudioLayout } from './components/StudioLayout';
|
||||
import { useGenerate } from './hooks/useGenerate';
|
||||
import { useIdentities } from './hooks/useIdentities';
|
||||
import { useImageLibrary } from './hooks/useImageLibrary';
|
||||
import { useUserData } from './hooks/useUserData';
|
||||
import { usePromptHistory } from './components/SceneBuilder/PromptHistory';
|
||||
import { theme } from './theme';
|
||||
import { computeNextBatchSize } from './lib/generateUntil';
|
||||
import { buildNegativePrompt, buildPrompt } from './lib/prompt';
|
||||
import type { LayoutId, MaturityRating, ModelId, PersonAppearance, SceneState, StudioRequest } from './types';
|
||||
|
||||
// ─── Lighting / Style maps ────────────────────────────────────────────────────
|
||||
|
||||
const LIGHTING_PROMPTS: Record<string, string> = {
|
||||
studio: 'soft studio lighting, even illumination, clean shadows',
|
||||
natural: 'natural daylight, soft diffused light, window light',
|
||||
golden: 'golden hour light, warm backlighting, soft rim light',
|
||||
dramatic: 'dramatic lighting, single key light, deep shadows, chiaroscuro',
|
||||
neon: 'neon lighting, colorful ambient glow, cyberpunk atmosphere',
|
||||
backlit: 'backlit, silhouette lighting, rim light halo',
|
||||
};
|
||||
|
||||
const STYLE_PROMPTS: Record<string, string> = {
|
||||
cinematic: 'cinematic composition, film grain, anamorphic bokeh, movie still',
|
||||
editorial: 'editorial photography, magazine quality, high fashion',
|
||||
intimate: 'intimate atmosphere, soft focus, candlelight warmth',
|
||||
fashion: 'fashion photography, designer aesthetic, haute couture',
|
||||
candid: 'candid moment, natural expression, documentary style',
|
||||
portrait: 'fine art portrait, painter light, museum quality',
|
||||
};
|
||||
|
||||
// ─── Prompt builder ───────────────────────────────────────────────────────────
|
||||
|
||||
function buildPrompt(scene: SceneState, hasIdentity: boolean): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (scene.promptCore.trim()) {
|
||||
parts.push(scene.promptCore.trim());
|
||||
} else if (hasIdentity) {
|
||||
// Without a text subject, InstantID ControlNet produces garbage.
|
||||
// "1woman" anchors the diffusion model to generate a person.
|
||||
parts.push('1woman');
|
||||
}
|
||||
// Body descriptor injected immediately after subject — high conditioning weight
|
||||
// anchors proportions before pose/outfit can pull the model's priors.
|
||||
if (scene.bodyDescriptor.trim()) parts.push(scene.bodyDescriptor.trim());
|
||||
if (scene.outfitDescription.trim()) parts.push(scene.outfitDescription.trim());
|
||||
|
||||
if (scene.selectedPose) {
|
||||
parts.push(...scene.selectedPose.promptKeywords);
|
||||
}
|
||||
|
||||
const shotMap: Record<string, string> = {
|
||||
'full-body': 'full body shot',
|
||||
medium: 'medium shot, waist up',
|
||||
'medium-close': 'medium close-up, chest up',
|
||||
'close-up': 'close-up, head and shoulders',
|
||||
'extreme-close-up': 'extreme close-up, face',
|
||||
};
|
||||
parts.push(shotMap[scene.shotType] ?? scene.shotType);
|
||||
|
||||
const angleMap: Record<string, string> = {
|
||||
'eye-level': 'eye level',
|
||||
high: 'high angle camera',
|
||||
low: 'low angle camera',
|
||||
'over-shoulder': 'over shoulder shot',
|
||||
dutch: 'dutch angle',
|
||||
};
|
||||
parts.push(angleMap[scene.cameraAngle] ?? scene.cameraAngle);
|
||||
|
||||
if (scene.background.trim()) parts.push(scene.background.trim());
|
||||
if (scene.expressionMood.trim()) parts.push(scene.expressionMood.trim());
|
||||
|
||||
if (scene.lighting && LIGHTING_PROMPTS[scene.lighting]) {
|
||||
parts.push(LIGHTING_PROMPTS[scene.lighting]);
|
||||
}
|
||||
if (scene.stylePreset && STYLE_PROMPTS[scene.stylePreset]) {
|
||||
parts.push(STYLE_PROMPTS[scene.stylePreset]);
|
||||
}
|
||||
|
||||
// Grounding: prevent floating/clipping artifacts when complex backgrounds are present
|
||||
if (scene.background.trim() && scene.background !== 'white backdrop, studio' && scene.background !== 'professional studio, clean background') {
|
||||
parts.push('subject standing naturally, physically grounded, coherent spatial relationship with background');
|
||||
}
|
||||
|
||||
parts.push('photorealistic, high quality, sharp focus, professional photography');
|
||||
return parts.join(', ');
|
||||
}
|
||||
|
||||
// ─── Default state ────────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_SCENE: SceneState = {
|
||||
promptCore: '',
|
||||
bodyDescriptor: 'tall woman 6 feet, hourglass figure, 30 inch waist, 42 inch hips, natural E cup breasts, long blonde hair, green eyes',
|
||||
bodyDescriptor: 'tall woman 6 feet, hourglass figure, narrow waist, wide hips, full E cup bust, large natural breasts, long blonde hair, green eyes, slender waist, curvy hips',
|
||||
background: 'professional studio, clean background',
|
||||
shotType: 'medium-close',
|
||||
cameraAngle: 'eye-level',
|
||||
|
|
@ -109,6 +35,7 @@ const DEFAULT_SCENE: SceneState = {
|
|||
denoisingStrength: 0.6,
|
||||
lighting: '',
|
||||
stylePreset: '',
|
||||
subjectGender: 'female',
|
||||
};
|
||||
|
||||
type AdvancedValues = Pick<
|
||||
|
|
@ -208,6 +135,20 @@ const BatchInput = styled.input`
|
|||
&:focus { outline: none; border-color: ${theme.colors.accent}; }
|
||||
`;
|
||||
|
||||
const ImproveBtn = styled.button`
|
||||
font-size: ${theme.font.size.sm};
|
||||
color: ${theme.colors.textMuted};
|
||||
background: none;
|
||||
border: 1px solid ${theme.colors.border};
|
||||
border-radius: ${theme.radius.md};
|
||||
padding: ${theme.spacing.sm} ${theme.spacing.md};
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: ${theme.transition};
|
||||
|
||||
&:hover { color: ${theme.colors.text}; border-color: ${theme.colors.borderHover}; }
|
||||
`;
|
||||
|
||||
const LibraryLink = styled(Link)`
|
||||
font-size: ${theme.font.size.sm};
|
||||
color: ${theme.colors.textMuted};
|
||||
|
|
@ -221,6 +162,49 @@ const LibraryLink = styled(Link)`
|
|||
&:hover { color: ${theme.colors.text}; border-color: ${theme.colors.borderHover}; }
|
||||
`;
|
||||
|
||||
const UntilRow = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${theme.spacing.sm};
|
||||
`;
|
||||
|
||||
const UntilToggle = styled.button<{ $active: boolean }>`
|
||||
padding: ${theme.spacing.xs} ${theme.spacing.sm};
|
||||
border-radius: ${theme.radius.md};
|
||||
border: 1px solid ${({ $active }) => $active ? theme.colors.success : theme.colors.border};
|
||||
background: ${({ $active }) => $active ? `${theme.colors.success}15` : 'transparent'};
|
||||
color: ${({ $active }) => $active ? theme.colors.success : theme.colors.textMuted};
|
||||
font-size: ${theme.font.size.xs};
|
||||
cursor: pointer;
|
||||
transition: ${theme.transition};
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover { border-color: ${theme.colors.success}; color: ${theme.colors.success}; }
|
||||
`;
|
||||
|
||||
const ThresholdInput = styled.input`
|
||||
width: 52px;
|
||||
padding: ${theme.spacing.xs} ${theme.spacing.sm};
|
||||
background: ${theme.colors.bgCard};
|
||||
border: 1px solid ${theme.colors.border};
|
||||
border-radius: ${theme.radius.md};
|
||||
color: ${theme.colors.text};
|
||||
font-size: ${theme.font.size.xs};
|
||||
text-align: center;
|
||||
appearance: textfield;
|
||||
-moz-appearance: textfield;
|
||||
|
||||
&::-webkit-inner-spin-button, &::-webkit-outer-spin-button { appearance: none; }
|
||||
&:focus { outline: none; border-color: ${theme.colors.success}; }
|
||||
`;
|
||||
|
||||
const UntilStatus = styled.div`
|
||||
font-size: ${theme.font.size.xs};
|
||||
color: ${theme.colors.textMuted};
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
|
||||
// ─── App ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function App(): ReactElement {
|
||||
|
|
@ -232,16 +216,32 @@ export function App(): ReactElement {
|
|||
const [scene, setScene] = usePersistedState<SceneState>('imajin:scene', DEFAULT_SCENE);
|
||||
const [advancedValues, setAdvancedValues] = usePersistedState('imajin:advanced', DEFAULT_ADVANCED);
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||
const [showImproveModal, setShowImproveModal] = useState(false);
|
||||
const { images, add: addImage, favorite } = useImageLibrary();
|
||||
const { data: identities } = useIdentities();
|
||||
const promptHistory = usePromptHistory();
|
||||
const { userData } = useUserData();
|
||||
|
||||
// Seed bodyDescriptor from userdata when the stored value is empty
|
||||
useEffect(() => {
|
||||
if (!userData?.bodyDescriptor) return;
|
||||
setScene((prev) => prev.bodyDescriptor.trim() ? prev : { ...prev, bodyDescriptor: userData.bodyDescriptor! });
|
||||
}, [userData]);
|
||||
|
||||
const handleIdentitySelect = useCallback((id: string | undefined) => {
|
||||
setSelectedIdentityId(id);
|
||||
if (!id || !identities) return;
|
||||
const identity = identities.find((i) => i.id === id || i.name === id);
|
||||
if (!identity?.gender) return;
|
||||
setScene((prev) => ({ ...prev, subjectGender: identity.gender === 'M' ? 'male' : 'female' }));
|
||||
}, [identities, setScene]);
|
||||
|
||||
// Auto-select quinn (or first identity) on initial load if nothing is selected
|
||||
useEffect(() => {
|
||||
if (selectedIdentityId !== undefined || !identities || identities.length === 0) return;
|
||||
const quinn = identities.find((id) => id.id === 'quinn') ?? identities[0];
|
||||
setSelectedIdentityId(quinn.id);
|
||||
}, [identities, selectedIdentityId]);
|
||||
handleIdentitySelect(quinn.id);
|
||||
}, [identities, selectedIdentityId, handleIdentitySelect]);
|
||||
|
||||
// Reset expression + body reference when identity changes
|
||||
useEffect(() => {
|
||||
|
|
@ -250,6 +250,53 @@ export function App(): ReactElement {
|
|||
setBodyReferenceB64(null);
|
||||
}, [selectedIdentityId]);
|
||||
|
||||
const [batchCount, setBatchCount] = useState(1);
|
||||
const [batchQueued, setBatchQueued] = useState(0);
|
||||
const pendingBatch = useRef<StudioRequest[]>([]);
|
||||
|
||||
const [generateUntilEnabled, setGenerateUntilEnabled] = useState<boolean>(() => {
|
||||
try {
|
||||
const v = JSON.parse(localStorage.getItem('imajin:gen-until') ?? 'false');
|
||||
return typeof v === 'boolean' ? v : false;
|
||||
} catch { return false; }
|
||||
});
|
||||
const [generateUntilThreshold, setGenerateUntilThreshold] = useState<number>(() => {
|
||||
try {
|
||||
const v = JSON.parse(localStorage.getItem('imajin:gen-until-threshold') ?? '80');
|
||||
return typeof v === 'number' ? v : 80;
|
||||
} catch { return 80; }
|
||||
});
|
||||
const [generateUntilInitialBatch, setGenerateUntilInitialBatch] = useState<number>(() => {
|
||||
try {
|
||||
const v = JSON.parse(localStorage.getItem('imajin:gen-until-initial') ?? '3');
|
||||
return typeof v === 'number' ? v : 3;
|
||||
} catch { return 3; }
|
||||
});
|
||||
const [generateUntilMinAdditional, setGenerateUntilMinAdditional] = useState<number>(() => {
|
||||
try {
|
||||
const v = JSON.parse(localStorage.getItem('imajin:gen-until-min') ?? '2');
|
||||
return typeof v === 'number' ? v : 2;
|
||||
} catch { return 2; }
|
||||
});
|
||||
const [generateUntilMaxAdditional, setGenerateUntilMaxAdditional] = useState<number>(() => {
|
||||
try {
|
||||
const v = JSON.parse(localStorage.getItem('imajin:gen-until-max') ?? '8');
|
||||
return typeof v === 'number' ? v : 8;
|
||||
} catch { return 8; }
|
||||
});
|
||||
useEffect(() => { localStorage.setItem('imajin:gen-until', JSON.stringify(generateUntilEnabled)); }, [generateUntilEnabled]);
|
||||
useEffect(() => { localStorage.setItem('imajin:gen-until-threshold', JSON.stringify(generateUntilThreshold)); }, [generateUntilThreshold]);
|
||||
useEffect(() => { localStorage.setItem('imajin:gen-until-initial', JSON.stringify(generateUntilInitialBatch)); }, [generateUntilInitialBatch]);
|
||||
useEffect(() => { localStorage.setItem('imajin:gen-until-min', JSON.stringify(generateUntilMinAdditional)); }, [generateUntilMinAdditional]);
|
||||
useEffect(() => { localStorage.setItem('imajin:gen-until-max', JSON.stringify(generateUntilMaxAdditional)); }, [generateUntilMaxAdditional]);
|
||||
const [generateUntilBest, setGenerateUntilBest] = useState<number | null>(null);
|
||||
const [generateUntilPassed, setGenerateUntilPassed] = useState(0);
|
||||
const generateUntilPassedRef = useRef(0);
|
||||
const generateUntilStoppedRef = useRef(false);
|
||||
// Phase tracking: how many images were in the active probe/follow-up batch, and their scores
|
||||
const phaseExpectedRef = useRef(0);
|
||||
const phaseScoresRef = useRef<number[]>([]);
|
||||
|
||||
function handleImageReady(img: Parameters<typeof addImage>[0]): void {
|
||||
void addImage(img);
|
||||
promptHistory.push({
|
||||
|
|
@ -257,11 +304,42 @@ export function App(): ReactElement {
|
|||
outfitDescription: scene.outfitDescription,
|
||||
background: scene.background,
|
||||
});
|
||||
}
|
||||
if (!generateUntilEnabled || generateUntilStoppedRef.current) {
|
||||
generateUntilStoppedRef.current = false;
|
||||
return;
|
||||
}
|
||||
const score = img.qualityScore ?? 0;
|
||||
phaseScoresRef.current.push(score);
|
||||
setGenerateUntilBest((prev) => Math.max(prev ?? 0, score));
|
||||
|
||||
const [batchCount, setBatchCount] = useState(1);
|
||||
const [batchQueued, setBatchQueued] = useState(0);
|
||||
const pendingBatch = useRef<StudioRequest[]>([]);
|
||||
const passed = score >= generateUntilThreshold / 100;
|
||||
const newPassed = generateUntilPassedRef.current + (passed ? 1 : 0);
|
||||
generateUntilPassedRef.current = newPassed;
|
||||
setGenerateUntilPassed(newPassed);
|
||||
|
||||
if (newPassed >= batchCount) {
|
||||
// Target reached mid-phase — clear anything still queued
|
||||
pendingBatch.current = [];
|
||||
setBatchQueued(0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase complete when we've collected all expected scores and the queue is drained
|
||||
if (phaseScoresRef.current.length >= phaseExpectedRef.current && pendingBatch.current.length === 0) {
|
||||
const nextSize = computeNextBatchSize(
|
||||
phaseScoresRef.current,
|
||||
generateUntilThreshold / 100,
|
||||
generateUntilMinAdditional,
|
||||
generateUntilMaxAdditional,
|
||||
);
|
||||
phaseScoresRef.current = [];
|
||||
phaseExpectedRef.current = nextSize;
|
||||
const batch = Array.from({ length: nextSize }, () => ({ ...buildRequest(), seed: undefined }));
|
||||
pendingBatch.current = batch;
|
||||
setBatchQueued(batch.length);
|
||||
// useEffect will drain the queue sequentially
|
||||
}
|
||||
}
|
||||
|
||||
const { isPending: isGenerating, attempt, totalAttempts, lastScore, exhausted, isError, error, generate } = useGenerate(handleImageReady);
|
||||
|
||||
|
|
@ -274,7 +352,8 @@ export function App(): ReactElement {
|
|||
}, [isGenerating, isError, generate]);
|
||||
|
||||
function buildRequest(): StudioRequest {
|
||||
const prompt = buildPrompt(scene, !!selectedIdentityId);
|
||||
const prompt = buildPrompt(scene, !!selectedIdentityId, advancedValues.maturity_rating);
|
||||
const negativePrompt = buildNegativePrompt(advancedValues.negative_prompt ?? '', advancedValues.maturity_rating);
|
||||
|
||||
// Only pose types with preset skeletons can drive ControlNet
|
||||
const PRESET_POSE_TYPES = new Set(['standing', 'sitting', 'walking', 'running']);
|
||||
|
|
@ -290,6 +369,7 @@ export function App(): ReactElement {
|
|||
return {
|
||||
...advancedValues,
|
||||
prompt,
|
||||
negative_prompt: negativePrompt,
|
||||
identity_id: selectedIdentityId,
|
||||
person_appearance: hasPoseOrOutfit
|
||||
? {
|
||||
|
|
@ -308,7 +388,23 @@ export function App(): ReactElement {
|
|||
function handleGenerate(): void {
|
||||
pendingBatch.current = [];
|
||||
setBatchQueued(0);
|
||||
setGenerateUntilBest(null);
|
||||
setGenerateUntilPassed(0);
|
||||
generateUntilPassedRef.current = 0;
|
||||
generateUntilStoppedRef.current = false;
|
||||
phaseScoresRef.current = [];
|
||||
phaseExpectedRef.current = 0;
|
||||
const req = buildRequest();
|
||||
if (generateUntilEnabled) {
|
||||
// Enqueue the initial probe batch; handleImageReady will enqueue adaptive follow-up phases
|
||||
const initialSize = Math.max(1, generateUntilInitialBatch);
|
||||
phaseExpectedRef.current = initialSize;
|
||||
const rest = Array.from({ length: initialSize - 1 }, () => ({ ...req, seed: undefined }));
|
||||
pendingBatch.current = rest;
|
||||
setBatchQueued(rest.length);
|
||||
generate({ ...req, seed: undefined });
|
||||
return;
|
||||
}
|
||||
if (batchCount <= 1) {
|
||||
generate(req);
|
||||
return;
|
||||
|
|
@ -319,6 +415,17 @@ export function App(): ReactElement {
|
|||
generate({ ...req, seed: undefined });
|
||||
}
|
||||
|
||||
function stopQueue(): void {
|
||||
generateUntilStoppedRef.current = true;
|
||||
pendingBatch.current = [];
|
||||
setBatchQueued(0);
|
||||
setGenerateUntilBest(null);
|
||||
setGenerateUntilPassed(0);
|
||||
generateUntilPassedRef.current = 0;
|
||||
phaseScoresRef.current = [];
|
||||
phaseExpectedRef.current = 0;
|
||||
}
|
||||
|
||||
function handleAdvancedChange(patch: Partial<typeof advancedValues>): void {
|
||||
setAdvancedValues((prev) => ({ ...prev, ...patch }));
|
||||
}
|
||||
|
|
@ -333,7 +440,7 @@ export function App(): ReactElement {
|
|||
const sidebar = (
|
||||
<IdentityPanel
|
||||
selectedIdentityId={selectedIdentityId}
|
||||
onSelect={setSelectedIdentityId}
|
||||
onSelect={handleIdentitySelect}
|
||||
selectedExpressionId={selectedExpressionId}
|
||||
selectedExpressionB64={selectedExpressionB64}
|
||||
onExpressionSelect={(id, b64) => {
|
||||
|
|
@ -356,37 +463,110 @@ export function App(): ReactElement {
|
|||
const queuedLabel = batchQueued > 0 ? ` (${batchQueued} queued)` : '';
|
||||
|
||||
const generateBar = (
|
||||
<GenerateRow>
|
||||
<BatchInput
|
||||
type="number"
|
||||
min={1}
|
||||
max={20}
|
||||
value={batchCount}
|
||||
disabled={isGenerating}
|
||||
onChange={(e) => setBatchCount(Math.max(1, Math.min(20, Number(e.target.value) || 1)))}
|
||||
title="Batch size"
|
||||
/>
|
||||
<GenerateBtn
|
||||
$loading={isGenerating}
|
||||
disabled={isGenerating}
|
||||
onClick={handleGenerate}
|
||||
>
|
||||
{isGenerating ? `${attemptLabel}${queuedLabel}` : batchCount > 1 ? `Generate ×${batchCount}` : 'Generate'}
|
||||
</GenerateBtn>
|
||||
{isError && error && (
|
||||
<GenerateStatus style={{ color: theme.colors.error }}>
|
||||
{error.message}
|
||||
</GenerateStatus>
|
||||
)}
|
||||
{!isGenerating && exhausted && (
|
||||
<GenerateStatus style={{ color: theme.colors.textMuted }}>
|
||||
All {totalAttempts} attempts used · score {lastScore?.toFixed(2) ?? '—'}
|
||||
</GenerateStatus>
|
||||
)}
|
||||
<LibraryLink to="/library">
|
||||
Library {images.length > 0 ? `(${images.length})` : ''}
|
||||
</LibraryLink>
|
||||
</GenerateRow>
|
||||
<>
|
||||
<GenerateRow>
|
||||
<BatchInput
|
||||
type="number"
|
||||
min={1}
|
||||
max={20}
|
||||
value={batchCount}
|
||||
disabled={isGenerating}
|
||||
onChange={(e) => setBatchCount(Math.max(1, Math.min(20, Number(e.target.value) || 1)))}
|
||||
title="Batch size"
|
||||
/>
|
||||
<GenerateBtn
|
||||
$loading={isGenerating}
|
||||
disabled={isGenerating}
|
||||
onClick={handleGenerate}
|
||||
>
|
||||
{isGenerating
|
||||
? `${attemptLabel}${queuedLabel}`
|
||||
: generateUntilEnabled
|
||||
? `Generate until ${generateUntilThreshold}%${batchCount > 1 ? ` ×${batchCount}` : ''}`
|
||||
: batchCount > 1
|
||||
? `Generate ×${batchCount}`
|
||||
: 'Generate'}
|
||||
</GenerateBtn>
|
||||
{isError && error && (
|
||||
<GenerateStatus style={{ color: theme.colors.error }}>
|
||||
{error.message}
|
||||
</GenerateStatus>
|
||||
)}
|
||||
{!isGenerating && exhausted && (
|
||||
<GenerateStatus style={{ color: theme.colors.textMuted }}>
|
||||
All {totalAttempts} attempts used · score {lastScore?.toFixed(2) ?? '—'}
|
||||
</GenerateStatus>
|
||||
)}
|
||||
<ImproveBtn onClick={() => setShowImproveModal(true)} title="Improve prompt with Qwen3">
|
||||
Improve
|
||||
</ImproveBtn>
|
||||
<LibraryLink to="/repaint">Repaint</LibraryLink>
|
||||
<LibraryLink to="/library">
|
||||
Library {images.length > 0 ? `(${images.length})` : ''}
|
||||
</LibraryLink>
|
||||
</GenerateRow>
|
||||
<UntilRow>
|
||||
<UntilToggle
|
||||
$active={generateUntilEnabled}
|
||||
onClick={() => setGenerateUntilEnabled((v) => !v)}
|
||||
>
|
||||
{generateUntilEnabled ? '✓' : '○'} Until quality
|
||||
</UntilToggle>
|
||||
{generateUntilEnabled && (
|
||||
<>
|
||||
<ThresholdInput
|
||||
type="number"
|
||||
min={10}
|
||||
max={99}
|
||||
step={5}
|
||||
value={generateUntilThreshold}
|
||||
onChange={(e) => setGenerateUntilThreshold(Math.max(10, Math.min(99, Number(e.target.value) || 80)))}
|
||||
title="Minimum quality score (%)"
|
||||
/>
|
||||
<UntilStatus>%</UntilStatus>
|
||||
<ThresholdInput
|
||||
type="number"
|
||||
min={1}
|
||||
max={8}
|
||||
step={1}
|
||||
value={generateUntilInitialBatch}
|
||||
onChange={(e) => setGenerateUntilInitialBatch(Math.max(1, Math.min(8, Number(e.target.value) || 3)))}
|
||||
title="Initial probe batch — how many images to generate first"
|
||||
/>
|
||||
<UntilStatus>⟳</UntilStatus>
|
||||
<ThresholdInput
|
||||
type="number"
|
||||
min={1}
|
||||
max={16}
|
||||
step={1}
|
||||
value={generateUntilMinAdditional}
|
||||
onChange={(e) => setGenerateUntilMinAdditional(Math.max(1, Math.min(generateUntilMaxAdditional, Number(e.target.value) || 2)))}
|
||||
title="Min follow-up batch (when pass rate is high)"
|
||||
/>
|
||||
<UntilStatus>–</UntilStatus>
|
||||
<ThresholdInput
|
||||
type="number"
|
||||
min={1}
|
||||
max={16}
|
||||
step={1}
|
||||
value={generateUntilMaxAdditional}
|
||||
onChange={(e) => setGenerateUntilMaxAdditional(Math.max(generateUntilMinAdditional, Math.min(16, Number(e.target.value) || 8)))}
|
||||
title="Max follow-up batch (when pass rate is low)"
|
||||
/>
|
||||
{generateUntilBest !== null && (
|
||||
<UntilStatus style={{ color: generateUntilBest * 100 >= generateUntilThreshold ? theme.colors.success : theme.colors.textMuted }}>
|
||||
best {(generateUntilBest * 100).toFixed(0)}% · {generateUntilPassed}/{batchCount} passed
|
||||
</UntilStatus>
|
||||
)}
|
||||
{(isGenerating || batchQueued > 0) && (
|
||||
<UntilToggle $active={false} onClick={stopQueue} style={{ marginLeft: 'auto' }}>
|
||||
Stop
|
||||
</UntilToggle>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</UntilRow>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
@ -399,6 +579,7 @@ export function App(): ReactElement {
|
|||
maturityRating={advancedValues.maturity_rating}
|
||||
onChange={setScene}
|
||||
onRestoreHistory={handleRestoreHistory}
|
||||
userBodyDescriptor={userData?.bodyDescriptor}
|
||||
/>
|
||||
}
|
||||
generateBar={generateBar}
|
||||
|
|
@ -426,6 +607,13 @@ export function App(): ReactElement {
|
|||
onClose={() => setLightboxIndex(null)}
|
||||
/>
|
||||
)}
|
||||
{showImproveModal && (
|
||||
<PromptImproveModal
|
||||
sceneDescription={scene.promptCore || buildPrompt(scene, !!selectedIdentityId, advancedValues.maturity_rating)}
|
||||
onApply={(improved) => setScene((prev) => ({ ...prev, promptCore: improved }))}
|
||||
onClose={() => setShowImproveModal(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
|||
import { App } from './App';
|
||||
import { ImageLibraryContext, useImageLibraryState } from './hooks/useImageLibrary';
|
||||
import { Library } from './pages/Library';
|
||||
import { Repaint } from './pages/Repaint';
|
||||
import { Services } from './pages/Services';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
|
|
@ -36,6 +37,7 @@ createRoot(rootEl).render(
|
|||
<Routes>
|
||||
<Route path="/" element={<App />} />
|
||||
<Route path="/library" element={<Library />} />
|
||||
<Route path="/repaint" element={<Repaint />} />
|
||||
<Route path="/services" element={<Services />} />
|
||||
</Routes>
|
||||
</ImageLibraryProvider>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ export interface Identity {
|
|||
name: string;
|
||||
image_count: number;
|
||||
created_at: string;
|
||||
gender?: 'M' | 'F'; // InsightFace majority-vote — undefined for legacy identities
|
||||
}
|
||||
|
||||
export interface CreateIdentityPayload {
|
||||
|
|
@ -120,6 +121,8 @@ export interface StudioRequest {
|
|||
|
||||
// ─── Scene Builder state (frontend only) ────────────────────────────────────
|
||||
|
||||
export type SubjectGender = 'female' | 'male';
|
||||
|
||||
export interface SceneState {
|
||||
promptCore: string;
|
||||
bodyDescriptor: string; // injected early in prompt to anchor proportions
|
||||
|
|
@ -134,6 +137,7 @@ export interface SceneState {
|
|||
denoisingStrength: number; // 0–1, how much to transform the reference
|
||||
lighting: string; // '' = none, or preset key
|
||||
stylePreset: string; // '' = none, or preset key
|
||||
subjectGender: SubjectGender; // injected as subject anchor when promptCore has no explicit gender
|
||||
}
|
||||
|
||||
// ─── Image analysis (from imajin-classifier enrichment) ──────────────────────
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue