feat(imajin-classifier): Add Library page with gallery enrichment scoring integration

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-30 13:37:36 -07:00
parent 6e9e735348
commit c2a2c01481
3 changed files with 108 additions and 4 deletions

View file

@ -286,6 +286,8 @@ async def enrich_all(
Summary dict with processed, skipped, failed counts.
"""
if not GALLERY_DIR.exists():
if status is not None:
status["running"] = False
return {"processed": 0, "skipped": 0, "failed": 0, "total": 0}
all_ids = [p.stem for p in GALLERY_DIR.glob("*.json")]

View file

@ -0,0 +1,21 @@
#!/bin/bash
# Start the imajin-classifier service.
# Sources the venv and preloads the correct nvJitLink library to resolve
# CUDA cusparse symbol mismatch when running outside the identity venv.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VENV_PATH="$SCRIPT_DIR/.venv"
# nvJitLink library from the identity venv (has the correct 12_8 symbol)
IDENT_VENV="/var/home/lilith/Code/@applications/@imajin/services/imajin-identity/service/.venv"
NVJITLINK="$IDENT_VENV/lib/python3.12/site-packages/nvidia/nvjitlink/lib/libnvJitLink.so.12"
source "$VENV_PATH/bin/activate"
export LD_PRELOAD="$NVJITLINK"
export PORT="${PORT:-8012}"
exec uvicorn src.api.main:app \
--host 0.0.0.0 \
--port "$PORT" \
--log-level info

View file

@ -6,6 +6,10 @@ import { theme } from '../theme';
import { useImageLibrary } from '../hooks/useImageLibrary';
import type { GeneratedImage, MaturityRating } from '../types';
type FramingFilter = 'all' | 'headshot' | 'medium-shot' | 'full-body';
type StyleFilter = 'all' | 'photorealistic' | 'anime' | 'illustrated';
type GazeFilter = 'all' | 'at-camera' | 'looking-away';
// ─── Debounce hook ────────────────────────────────────────────────────────────
function useDebounce<T>(value: T, delay: number): T {
@ -216,6 +220,25 @@ const PromptText = styled.div`
-webkit-box-orient: vertical;
`;
const WarningBadge = styled.span`
font-size: ${theme.font.size.xs};
padding: 2px ${theme.spacing.xs};
border-radius: ${theme.radius.sm};
background: ${theme.colors.warning}20;
color: ${theme.colors.warning};
font-weight: ${theme.font.weight.medium};
white-space: nowrap;
cursor: default;
`;
const FilterSep = styled.span`
width: 1px;
height: 16px;
background: ${theme.colors.border};
margin: 0 ${theme.spacing.xs};
align-self: center;
`;
const CardActions = styled.div`
position: absolute;
top: ${theme.spacing.sm};
@ -281,7 +304,7 @@ const MATURITY_COLORS: Record<MaturityRating, string> = {
explicit: theme.colors.error,
};
type SortKey = 'newest' | 'oldest' | 'score';
type SortKey = 'newest' | 'oldest' | 'score' | 'sharpness';
// ─── Component ───────────────────────────────────────────────────────────────
@ -289,6 +312,9 @@ export function Library(): ReactElement {
const { images, isLoading, refresh, remove, clear, favorite } = useImageLibrary();
const [maturityFilter, setMaturityFilter] = useState<MaturityRating | 'all'>('all');
const [identityFilter, setIdentityFilter] = useState<string | 'all'>('all');
const [framingFilter, setFramingFilter] = useState<FramingFilter>('all');
const [styleFilter, setStyleFilter] = useState<StyleFilter>('all');
const [gazeFilter, setGazeFilter] = useState<GazeFilter>('all');
const [showStarredOnly, setShowStarredOnly] = useState(false);
const [sort, setSort] = useState<SortKey>('newest');
const [searchQuery, setSearchQuery] = useState('');
@ -304,6 +330,21 @@ export function Library(): ReactElement {
.filter((img) => maturityFilter === 'all' || img.maturityRating === maturityFilter)
.filter((img) => identityFilter === 'all' || img.identityId === identityFilter)
.filter((img) => !showStarredOnly || img.isFavorited === true)
.filter((img) => {
if (framingFilter === 'all') return true;
const f = img.analysis?.framing ?? '';
return f === framingFilter;
})
.filter((img) => {
if (styleFilter === 'all') return true;
const s = img.analysis?.style ?? '';
return s === styleFilter;
})
.filter((img) => {
if (gazeFilter === 'all') return true;
const g = img.analysis?.gaze ?? '';
return g === gazeFilter;
})
.filter((img) =>
debouncedSearch
? img.prompt.toLowerCase().includes(debouncedSearch.toLowerCase())
@ -312,6 +353,7 @@ export function Library(): ReactElement {
.sort((a, b) => {
if (sort === 'newest') return b.createdAt.localeCompare(a.createdAt);
if (sort === 'oldest') return a.createdAt.localeCompare(b.createdAt);
if (sort === 'sharpness') return (b.analysis?.sharpness ?? 0) - (a.analysis?.sharpness ?? 0);
return (b.qualityScore ?? 0) - (a.qualityScore ?? 0);
});
@ -362,7 +404,32 @@ export function Library(): ReactElement {
</>
)}
<FilterLabel style={{ marginLeft: theme.spacing.md }}>Starred</FilterLabel>
<FilterSep />
<FilterLabel>Framing</FilterLabel>
{(['all', 'headshot', 'medium-shot', 'full-body'] as const).map((f) => (
<FilterChip key={f} $active={framingFilter === f} onClick={() => setFramingFilter(f)}>
{f === 'all' ? 'All' : f}
</FilterChip>
))}
<FilterSep />
<FilterLabel>Style</FilterLabel>
{(['all', 'photorealistic', 'anime', 'illustrated'] as const).map((s) => (
<FilterChip key={s} $active={styleFilter === s} onClick={() => setStyleFilter(s)}>
{s === 'all' ? 'All' : s}
</FilterChip>
))}
<FilterSep />
<FilterLabel>Gaze</FilterLabel>
{(['all', 'at-camera', 'looking-away'] as const).map((g) => (
<FilterChip key={g} $active={gazeFilter === g} onClick={() => setGazeFilter(g)}>
{g === 'all' ? 'All' : g}
</FilterChip>
))}
<FilterSep />
<FilterLabel>Starred</FilterLabel>
<FilterChip $active={showStarredOnly} onClick={() => setShowStarredOnly((v) => !v)}>
Starred
</FilterChip>
@ -379,9 +446,9 @@ export function Library(): ReactElement {
)}
</SearchWrapper>
<FilterLabel>Sort</FilterLabel>
{(['newest', 'oldest', 'score'] as const).map((s) => (
{(['newest', 'oldest', 'score', 'sharpness'] as const).map((s) => (
<FilterChip key={s} $active={sort === s} onClick={() => setSort(s)}>
{s === 'score' ? 'Top score' : s.charAt(0).toUpperCase() + s.slice(1)}
{s === 'score' ? 'Quality' : s === 'sharpness' ? 'Sharpness' : s.charAt(0).toUpperCase() + s.slice(1)}
</FilterChip>
))}
</Filters>
@ -439,6 +506,20 @@ export function Library(): ReactElement {
{img.durationMs !== undefined && (
<Badge>{img.durationMs < 1000 ? `${img.durationMs}ms` : `${(img.durationMs / 1000).toFixed(1)}s`}</Badge>
)}
{img.analysis?.framing && img.analysis.framing !== 'unknown' && (
<Badge $color={theme.colors.textDim}>{img.analysis.framing}</Badge>
)}
{img.analysis?.style && img.analysis.style !== 'unknown' && (
<Badge $color={theme.colors.textDim}>{img.analysis.style}</Badge>
)}
{img.analysis?.gaze === 'at-camera' && (
<Badge $color={theme.colors.success}>eye contact</Badge>
)}
{(img.analysis?.vlm?.anatomyIssues?.length ?? 0) > 0 && (
<WarningBadge title={img.analysis!.vlm!.anatomyIssues.join(', ')}>
anatomy
</WarningBadge>
)}
</CardMeta>
<PromptText>{img.prompt}</PromptText>
</ImageCard>