From 8f011cf82e0ad1f8404f9c9ba9e75ea6e65cec4b Mon Sep 17 00:00:00 2001 From: Claude Code Date: Mon, 30 Mar 2026 08:50:21 -0700 Subject: [PATCH] =?UTF-8?q?chore(pages):=20=F0=9F=94=A7=20Update=20build?= =?UTF-8?q?=20script=20for=20failed=20page=20deployment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- tools/sprite-generation/engine/registry.py | 45 +++- tools/sprite-generation/gui/src/App.tsx | 2 + tools/sprite-generation/gui/src/api.ts | 8 + .../gui/src/pages/DashboardPage.tsx | 24 +- .../gui/src/pages/VariantBrowsePage.tsx | 207 ++++++++++++++++++ tools/sprite-generation/server.py | 10 + 6 files changed, 282 insertions(+), 14 deletions(-) create mode 100644 tools/sprite-generation/gui/src/pages/VariantBrowsePage.tsx diff --git a/tools/sprite-generation/engine/registry.py b/tools/sprite-generation/engine/registry.py index 542af1e0..3ec67ea3 100644 --- a/tools/sprite-generation/engine/registry.py +++ b/tools/sprite-generation/engine/registry.py @@ -705,19 +705,48 @@ class SpriteRegistry: limit: int = 50, offset: int = 0, ) -> tuple[list[dict], int]: - """Paginated variant query for the Theater UI. + """Paginated variant query for browse/theater UIs. - mode='all' — all completed variants with images - mode='review' — only variants with rating >= 1 and not yet approved + mode='all' — all completed variants with images + mode='completed' — same as 'all' + mode='review' — rating >= 1 and not yet approved + mode='processed' — background-removed variants + mode='approved' — approved variants + mode='installed' — installed into game assets + mode='scored_' — passed a specific scorer's gate (e.g. 'scored_qwen3') Returns (items, total_count). """ - base_where = "v.job_status = 'completed' AND v.raw_path IS NOT NULL" - if mode == "review": - base_where += " AND v.rating >= 1 AND v.is_approved = 0" + extra_params: list = [] + + if mode in ("all", "completed"): + base_where = "v.job_status = 'completed' AND v.raw_path IS NOT NULL" + elif mode == "review": + base_where = ( + "v.job_status = 'completed' AND v.raw_path IS NOT NULL" + " AND v.rating >= 1 AND v.is_approved = 0" + ) + elif mode == "processed": + base_where = "v.processed_path IS NOT NULL" + elif mode == "approved": + base_where = "v.is_approved = 1" + elif mode == "installed": + base_where = "v.job_status = 'installed'" + elif mode.startswith("scored_"): + scorer = mode[len("scored_"):] + base_where = ( + "EXISTS (" + " SELECT 1 FROM latest_scores ls" + " WHERE ls.variant_id = v.id AND ls.scorer_name = ? AND ls.gate_passed = 1" + ")" + ) + extra_params.append(scorer) + else: + base_where = "v.job_status = 'completed' AND v.raw_path IS NOT NULL" total = self.conn.execute( - f"SELECT COUNT(*) FROM variants v WHERE {base_where}" + f"SELECT COUNT(*) FROM variants v WHERE {base_where}", + extra_params, ).fetchone()[0] rows = self.conn.execute( @@ -743,7 +772,7 @@ class SpriteRegistry: WHERE {base_where} ORDER BY v.id DESC LIMIT ? OFFSET ?""", - (limit, offset), + extra_params + [limit, offset], ).fetchall() return [dict(r) for r in rows], total diff --git a/tools/sprite-generation/gui/src/App.tsx b/tools/sprite-generation/gui/src/App.tsx index ea206d3c..9694f784 100644 --- a/tools/sprite-generation/gui/src/App.tsx +++ b/tools/sprite-generation/gui/src/App.tsx @@ -8,6 +8,7 @@ import SpriteTheaterPage from './pages/SpriteTheaterPage' import TerrainGridPage from './pages/TerrainGridPage' import VariantPage from './pages/VariantPage' import { SpriteCoveragePage } from './pages/SpriteCoveragePage' +import { VariantBrowsePage } from './pages/VariantBrowsePage' import { SpriteStream } from './SpriteStream' import { colors } from './pages/theme' @@ -85,6 +86,7 @@ export function App(): ReactElement { } /> } /> } /> + } /> } /> diff --git a/tools/sprite-generation/gui/src/api.ts b/tools/sprite-generation/gui/src/api.ts index db4f6f11..223f33db 100644 --- a/tools/sprite-generation/gui/src/api.ts +++ b/tools/sprite-generation/gui/src/api.ts @@ -270,3 +270,11 @@ export async function fetchPipeline(): Promise { export async function fetchTerrainGrid(elevation: TerrainElevation): Promise { return request(buildUrl('/terrain-grid', { elevation })) } + +export async function fetchVariantBrowse(params: { + stage: string + limit?: number + offset?: number +}): Promise<{ items: RecentVariant[]; total: number }> { + return request<{ items: RecentVariant[]; total: number }>(buildUrl('/variants/browse', params)) +} diff --git a/tools/sprite-generation/gui/src/pages/DashboardPage.tsx b/tools/sprite-generation/gui/src/pages/DashboardPage.tsx index 98b21c67..28de02fd 100644 --- a/tools/sprite-generation/gui/src/pages/DashboardPage.tsx +++ b/tools/sprite-generation/gui/src/pages/DashboardPage.tsx @@ -85,6 +85,7 @@ interface FlowStage { count: number // done queued: number // waiting at this step (stacked on top) color: string + stage: string // browse URL stage param } function PipelineFlow({ @@ -99,12 +100,14 @@ function PipelineFlow({ const stages: FlowStage[] = [ { label: 'Completed', + stage: 'completed', count: funnel.total_completed, queued: Math.max(0, totalPlanned - funnel.total_completed), color: colors.muted, }, { label: 'Processed', + stage: 'processed', count: funnel.total_processed, queued: Math.max(0, funnel.total_completed - funnel.total_processed), color: '#3b82f6', @@ -113,6 +116,7 @@ function PipelineFlow({ .filter((s) => funnel.scoring[s] !== undefined) .map((s, i) => ({ label: s.charAt(0).toUpperCase() + s.slice(1), + stage: `scored_${s}`, count: funnel.scoring[s].passed, queued: i === 0 ? funnel.unscored @@ -121,12 +125,14 @@ function PipelineFlow({ }))), { label: 'Approved', + stage: 'approved', count: funnel.approved, queued: Math.max(0, (funnel.scoring[SCORER_ORDER[SCORER_ORDER.length - 1]]?.passed ?? 0) - funnel.approved), color: '#22c55e', }, { label: 'Installed', + stage: 'installed', count: funnel.installed, queued: Math.max(0, funnel.approved - funnel.installed), color: '#a855f7', @@ -192,12 +198,18 @@ function PipelineFlow({ /> )} - {/* done count */} - + {/* done count — links to browse page */} + + + {/* queued count */} {stage.queued > 0 && ( diff --git a/tools/sprite-generation/gui/src/pages/VariantBrowsePage.tsx b/tools/sprite-generation/gui/src/pages/VariantBrowsePage.tsx new file mode 100644 index 00000000..7fc6da4c --- /dev/null +++ b/tools/sprite-generation/gui/src/pages/VariantBrowsePage.tsx @@ -0,0 +1,207 @@ +import { useEffect, useState, useCallback, type CSSProperties, type ReactNode } from 'react' +import { useSearchParams, useNavigate, Link } from 'react-router-dom' +import { fetchVariantBrowse, bestImageUrl } from '../api' +import type { RecentVariant } from '../types' +import { colors } from './theme' + +const PAGE_SIZE = 120 + +const STAGE_LABELS: Record = { + completed: 'Completed', + processed: 'Processed', + approved: 'Approved', + installed: 'Installed', + scored_qwen3: 'Scored — Qwen3', + scored_haiku: 'Scored — Haiku', + scored_sonnet: 'Scored — Sonnet', + scored_opus: 'Scored — Opus', +} + +function VariantTile({ variant }: { variant: RecentVariant }): ReactNode { + const navigate = useNavigate() + const imgSrc = bestImageUrl(variant.processed_path, variant.raw_path) + const [hovered, setHovered] = useState(false) + + return ( +
navigate(`/variant/${variant.variant_id}`)} + onMouseEnter={(): void => setHovered(true)} + onMouseLeave={(): void => setHovered(false)} + style={{ + position: 'relative', + aspectRatio: '1', + background: colors.surface, + borderRadius: 8, + border: `1px solid ${hovered ? colors.highlight : colors.accent}`, + overflow: 'hidden', + cursor: 'pointer', + transition: 'border-color 0.12s', + }} + > + {imgSrc ? ( + {variant.sprite_id} + ) : ( +
+ No image +
+ )} + + {/* overlay on hover */} + {hovered && ( +
+
+ {variant.sprite_id.split('/').pop()} +
+ {variant.rating !== null && ( +
= 70 ? '#10b981' : variant.rating >= 50 ? '#f59e0b' : '#ef4444' }}> + {variant.rating.toFixed(0)} +
+ )} +
+ )} + + {/* approved badge */} + {variant.is_approved && ( +
+ )} +
+ ) +} + +export function VariantBrowsePage(): ReactNode { + const [searchParams, setSearchParams] = useSearchParams() + const stage = searchParams.get('stage') ?? 'completed' + const page = parseInt(searchParams.get('page') ?? '0', 10) + + const [items, setItems] = useState([]) + const [total, setTotal] = useState(0) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const load = useCallback(async () => { + setLoading(true) + setError(null) + try { + const result = await fetchVariantBrowse({ stage, limit: PAGE_SIZE, offset: page * PAGE_SIZE }) + setItems(result.items) + setTotal(result.total) + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setLoading(false) + } + }, [stage, page]) + + useEffect(() => { void load() }, [load]) + + const totalPages = Math.ceil(total / PAGE_SIZE) + + const navBtn: CSSProperties = { + background: colors.surface, + border: `1px solid ${colors.accent}`, + borderRadius: 6, + color: colors.text, + fontSize: 13, + fontWeight: 600, + padding: '6px 16px', + cursor: 'pointer', + } + + const stageLabel = STAGE_LABELS[stage] ?? stage + + return ( +
+ {/* header */} +
+ + ← Dashboard + + +

+ {stageLabel} +

+ + {loading ? '…' : `${total.toLocaleString()} variants`} + +
+ + {error && ( +
+ {error} +
+ )} + + {/* grid */} + {!loading && items.length === 0 && !error && ( +
+ No variants in this stage. +
+ )} + +
+ {items.map((v) => ( + + ))} +
+ + {/* pagination */} + {totalPages > 1 && ( +
+ + + Page {page + 1} of {totalPages} + + +
+ )} +
+ ) +} diff --git a/tools/sprite-generation/server.py b/tools/sprite-generation/server.py index 86344e26..d92915cf 100644 --- a/tools/sprite-generation/server.py +++ b/tools/sprite-generation/server.py @@ -307,6 +307,16 @@ def create_app( items, total = registry.query_variants(mode=mode, limit=limit, offset=offset) return {"items": items, "total": total} + @app.get("/api/variants/browse") + def browse_variants( + stage: Annotated[str, Query(pattern=r"^(all|completed|review|processed|approved|installed|scored_[a-z0-9_]+)$")] = "completed", + limit: Annotated[int, Query(ge=1, le=500)] = 120, + offset: Annotated[int, Query(ge=0)] = 0, + ) -> dict: + """Paginated variant browse by funnel stage — used by the dashboard funnel click-throughs.""" + items, total = registry.query_variants(mode=stage, limit=limit, offset=offset) + return {"items": items, "total": total} + @app.get("/api/stream/variants") async def stream_variants() -> StreamingResponse: async def event_generator():