chore(pages): 🔧 Update build script for failed page deployment
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
4d40e70214
commit
8f011cf82e
6 changed files with 282 additions and 14 deletions
|
|
@ -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_<name>' — 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
|
||||
|
|
|
|||
|
|
@ -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 {
|
|||
<Route path="/variant/:id" element={<VariantPage />} />
|
||||
<Route path="/coverage" element={<SpriteCoveragePage />} />
|
||||
<Route path="/terrain-grid" element={<TerrainGridPage />} />
|
||||
<Route path="/browse" element={<VariantBrowsePage />} />
|
||||
<Route path="/sprite/*" element={<SpritePage />} />
|
||||
</Routes>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -270,3 +270,11 @@ export async function fetchPipeline(): Promise<PipelineState> {
|
|||
export async function fetchTerrainGrid(elevation: TerrainElevation): Promise<TerrainGrid> {
|
||||
return request<TerrainGrid>(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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* done count */}
|
||||
<FlashNumber
|
||||
value={stage.count}
|
||||
color={stage.color}
|
||||
style={{ color: stage.color, fontWeight: 700, fontSize: 16, lineHeight: 1 }}
|
||||
/>
|
||||
{/* done count — links to browse page */}
|
||||
<Link
|
||||
to={`/browse?stage=${stage.stage}`}
|
||||
style={{ textDecoration: 'none', lineHeight: 1 }}
|
||||
title={`Browse ${stage.label} variants`}
|
||||
>
|
||||
<FlashNumber
|
||||
value={stage.count}
|
||||
color={stage.color}
|
||||
style={{ color: stage.color, fontWeight: 700, fontSize: 16, lineHeight: 1 }}
|
||||
/>
|
||||
</Link>
|
||||
{/* queued count */}
|
||||
{stage.queued > 0 && (
|
||||
<span style={{ color: stage.color, fontWeight: 500, fontSize: 11, opacity: 0.5, lineHeight: 1, marginTop: -4 }}>
|
||||
|
|
|
|||
207
tools/sprite-generation/gui/src/pages/VariantBrowsePage.tsx
Normal file
207
tools/sprite-generation/gui/src/pages/VariantBrowsePage.tsx
Normal file
|
|
@ -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<string, string> = {
|
||||
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 (
|
||||
<div
|
||||
onClick={(): void => 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 ? (
|
||||
<img
|
||||
src={imgSrc}
|
||||
alt={variant.sprite_id}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain', display: 'block' }}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<span style={{ color: colors.muted, fontSize: 11 }}>No image</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* overlay on hover */}
|
||||
{hovered && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
padding: '6px 8px',
|
||||
background: 'rgba(0,0,0,0.75)',
|
||||
fontSize: 10,
|
||||
color: colors.muted,
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
<div style={{ color: colors.text, fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{variant.sprite_id.split('/').pop()}
|
||||
</div>
|
||||
{variant.rating !== null && (
|
||||
<div style={{ color: variant.rating >= 70 ? '#10b981' : variant.rating >= 50 ? '#f59e0b' : '#ef4444' }}>
|
||||
{variant.rating.toFixed(0)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* approved badge */}
|
||||
{variant.is_approved && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
right: 4,
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
background: '#10b981',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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<RecentVariant[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div style={{ padding: '20px 24px', maxWidth: 1600, margin: '0 auto' }}>
|
||||
{/* header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 20 }}>
|
||||
<Link
|
||||
to="/"
|
||||
style={{ color: colors.muted, fontSize: 13, textDecoration: 'none' }}
|
||||
>
|
||||
← Dashboard
|
||||
</Link>
|
||||
<span style={{ color: colors.accent }}>›</span>
|
||||
<h1 style={{ margin: 0, color: colors.text, fontSize: 18, fontWeight: 700 }}>
|
||||
{stageLabel}
|
||||
</h1>
|
||||
<span style={{ color: colors.muted, fontSize: 14 }}>
|
||||
{loading ? '…' : `${total.toLocaleString()} variants`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{ color: '#f87171', background: '#4a1a1a', border: '1px solid #f8717144', borderRadius: 8, padding: '10px 16px', marginBottom: 16 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* grid */}
|
||||
{!loading && items.length === 0 && !error && (
|
||||
<div style={{ color: colors.muted, fontSize: 14, textAlign: 'center', padding: '60px 0' }}>
|
||||
No variants in this stage.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{items.map((v) => (
|
||||
<VariantTile key={v.variant_id} variant={v} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 12, marginTop: 24 }}>
|
||||
<button
|
||||
style={{ ...navBtn, opacity: page === 0 ? 0.4 : 1 }}
|
||||
disabled={page === 0}
|
||||
onClick={(): void => setSearchParams({ stage, page: String(page - 1) })}
|
||||
>
|
||||
← Prev
|
||||
</button>
|
||||
<span style={{ color: colors.muted, fontSize: 13 }}>
|
||||
Page {page + 1} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
style={{ ...navBtn, opacity: page >= totalPages - 1 ? 0.4 : 1 }}
|
||||
disabled={page >= totalPages - 1}
|
||||
onClick={(): void => setSearchParams({ stage, page: String(page + 1) })}
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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():
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue