sivaratrisrinivas commited on
Commit
f84aefe
·
1 Parent(s): 8210a81

feat: polish Catbox observation flow

Browse files

Load local .env files before SD Turbo model construction in both Browser UI startup and manual validation so authenticated Hugging Face downloads work consistently without committing secrets.

Restore the Living-Cat Outcome to the validated 4-step SD Turbo setting so the cat remains visually recognizable while preserving the faster absent outcome path.

Refresh the Browser UI with a more intentional Catbox stage, responsive press and hover states, reduced-motion handling, progressive observation noise, and a generated-image reveal that waits for the returned file to load before switching state.

Update README and manual GPU validation docs with uv-based commands, .env setup, outcome-specific generation settings, and the polished Browser UI behavior. Add focused tests for env loading, living-step defaults, validation startup, and UI interaction contracts.

.env.example ADDED
@@ -0,0 +1 @@
 
 
1
+ HF_TOKEN=
.gitignore CHANGED
@@ -1,4 +1,5 @@
1
  .venv/
 
2
  .uv-cache/
3
  .hf-cache/
4
  .scratch/
 
1
  .venv/
2
+ .env
3
  .uv-cache/
4
  .hf-cache/
5
  .scratch/
README.md CHANGED
@@ -58,11 +58,14 @@ be checked quickly without CUDA, model downloads, or slow image generation.
58
 
59
  The local Browser UI is served by `python -m catbox.browser_ui`. It starts from
60
  a sealed box, sends a normal observation request without choosing an outcome,
61
- waits while the Model Backend runs, reveals the generated image from the
62
- backend-provided local file reference, shows the Reveal Note, and supports Reset
63
- back to the sealed box. If generation fails, the Browser UI shows Generation
64
- Failure with Retry and Reset instead of registering or serving a fake generated
65
- image.
 
 
 
66
 
67
  ## How the project fits together
68
 
@@ -85,16 +88,32 @@ image.
85
  Run the contract tests:
86
 
87
  ```bash
88
- python -m unittest discover -s tests
89
  ```
90
 
91
  These tests use fakes and stubs around the expensive model path. They do not
92
  need GPU access or model downloads.
93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  Run the local Browser UI:
95
 
96
  ```bash
97
- python -m catbox.browser_ui
98
  ```
99
 
100
  Then open `http://127.0.0.1:8765`. The page starts from the sealed box, sends a
@@ -111,7 +130,7 @@ completes.
111
  Manual GPU validation for the preferred local machine:
112
 
113
  ```bash
114
- python -m catbox.validate_sd_turbo_runner --outcome all --seed 41100
115
  ```
116
 
117
  That command preloads SD Turbo once, forces both Catbox outcomes through the
@@ -119,7 +138,7 @@ development-only path, writes ephemeral generated images under `.runtime/`, and
119
  prints the same response shape the Browser UI will use.
120
 
121
  The current preferred settings were manually validated on the local CUDA path:
122
- the Living-Cat Outcome uses 384px image-to-image generation with 2 steps, and
123
  the Absent-Cat Outcome uses 512px image-to-image generation with 2 steps.
124
 
125
  For the complete first-observation GPU validation checklist, including Browser
 
58
 
59
  The local Browser UI is served by `python -m catbox.browser_ui`. It starts from
60
  a sealed box, sends a normal observation request without choosing an outcome,
61
+ waits while the Model Backend runs, and reveals the generated image only after
62
+ the backend-provided local file reference has loaded. The Browser UI uses
63
+ theatrical Observation Noise, Progressive Waiting, responsive press/hover
64
+ states, reduced-motion handling, and a polished generated-image reveal while
65
+ keeping the Model Backend authoritative. It shows the Reveal Note and supports
66
+ Reset back to the sealed box. If generation fails, the Browser UI shows
67
+ Generation Failure with Retry and Reset instead of registering or serving a fake
68
+ generated image.
69
 
70
  ## How the project fits together
71
 
 
88
  Run the contract tests:
89
 
90
  ```bash
91
+ uv run python -m unittest discover -s tests
92
  ```
93
 
94
  These tests use fakes and stubs around the expensive model path. They do not
95
  need GPU access or model downloads.
96
 
97
+ If you want Hugging Face authenticated downloads, create a local `.env` file:
98
+
99
+ ```bash
100
+ cp .env.example .env
101
+ ```
102
+
103
+ Then edit `.env`:
104
+
105
+ ```text
106
+ HF_TOKEN=hf_your_read_token_here
107
+ ```
108
+
109
+ The Browser UI startup command and manual validation command load `.env` before
110
+ the SD Turbo runner requests model files. `.env` is ignored by git and should
111
+ not be committed.
112
+
113
  Run the local Browser UI:
114
 
115
  ```bash
116
+ uv run python -m catbox.browser_ui
117
  ```
118
 
119
  Then open `http://127.0.0.1:8765`. The page starts from the sealed box, sends a
 
130
  Manual GPU validation for the preferred local machine:
131
 
132
  ```bash
133
+ uv run python -m catbox.validate_sd_turbo_runner --outcome all --seed 41100
134
  ```
135
 
136
  That command preloads SD Turbo once, forces both Catbox outcomes through the
 
138
  prints the same response shape the Browser UI will use.
139
 
140
  The current preferred settings were manually validated on the local CUDA path:
141
+ the Living-Cat Outcome uses 384px image-to-image generation with 4 steps, and
142
  the Absent-Cat Outcome uses 512px image-to-image generation with 2 steps.
143
 
144
  For the complete first-observation GPU validation checklist, including Browser
catbox/browser_ui.py CHANGED
@@ -8,6 +8,7 @@ from pathlib import Path
8
  from typing import Protocol
9
  from urllib.parse import parse_qs, urlparse
10
 
 
11
  from catbox.model_backend import CatboxModelBackend
12
  from catbox.sd_turbo_runner import DEFAULT_RUNTIME_DIR, SdTurboImageToImageModelRunner
13
 
@@ -127,7 +128,9 @@ def make_browser_ui_server(
127
  def make_default_browser_ui_server(
128
  server_address: tuple[str, int],
129
  runtime_dir: str | Path = DEFAULT_RUNTIME_DIR,
 
130
  ) -> CatboxBrowserUiServer:
 
131
  backend = CatboxModelBackend(
132
  model_runner=SdTurboImageToImageModelRunner(runtime_dir=runtime_dir)
133
  )
@@ -163,9 +166,21 @@ _BROWSER_UI_HTML = """<!doctype html>
163
  <style>
164
  :root {
165
  color-scheme: dark;
 
 
 
 
 
 
 
 
 
 
 
 
166
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
167
- background: #171717;
168
- color: #f5f0e8;
169
  }
170
 
171
  * {
@@ -177,74 +192,195 @@ _BROWSER_UI_HTML = """<!doctype html>
177
  min-height: 100vh;
178
  display: grid;
179
  place-items: center;
 
180
  background:
181
- radial-gradient(circle at 50% 18%, rgba(198, 85, 70, 0.2), transparent 28rem),
182
- linear-gradient(145deg, #161616, #22201d 54%, #101010);
 
183
  }
184
 
185
  main {
186
- width: min(92vw, 760px);
187
  display: grid;
188
- gap: 24px;
189
  justify-items: center;
190
  text-align: center;
191
  }
192
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
193
  .stage {
194
- width: min(88vw, 560px);
195
- aspect-ratio: 1.18;
196
  display: grid;
197
  place-items: center;
198
- border: 1px solid rgba(245, 240, 232, 0.14);
199
- background: rgba(255, 255, 255, 0.035);
 
 
 
200
  overflow: hidden;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201
  }
202
 
203
  .box {
204
- width: min(58vw, 300px);
205
  aspect-ratio: 1.25;
206
  position: relative;
207
- border: 2px solid #d8aa72;
208
- background: linear-gradient(#9f6a38, #6e431f);
209
- box-shadow: 0 26px 60px rgba(0, 0, 0, 0.45);
 
 
 
 
 
 
 
210
  }
211
 
212
  .box::before {
213
  content: "";
214
  position: absolute;
215
- inset: -44px -16px auto;
216
- height: 54px;
217
- border: 2px solid #e0b782;
218
- background: linear-gradient(#bb8447, #7c4d25);
219
- transform: perspective(260px) rotateX(46deg);
 
 
220
  transform-origin: bottom;
 
 
 
 
 
 
 
 
 
 
 
 
221
  }
222
 
223
  .panel {
224
- display: none;
 
225
  width: 100%;
 
 
 
226
  justify-items: center;
227
  gap: 18px;
 
 
 
 
 
 
 
 
228
  }
229
 
230
  .panel.is-active {
 
231
  display: grid;
 
 
 
 
232
  }
233
 
234
  button {
235
  min-width: 148px;
236
- min-height: 44px;
237
- border: 1px solid rgba(245, 240, 232, 0.2);
238
- background: #f5f0e8;
239
- color: #191817;
240
  font: inherit;
241
  font-weight: 700;
 
242
  cursor: pointer;
 
 
 
 
 
 
 
 
 
 
 
 
243
  }
244
 
245
  button:disabled {
246
  opacity: 0.55;
247
  cursor: progress;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
248
  }
249
 
250
  .actions {
@@ -255,26 +391,65 @@ _BROWSER_UI_HTML = """<!doctype html>
255
  }
256
 
257
  .noise {
258
- width: min(72vw, 420px);
259
- aspect-ratio: 1.4;
 
 
 
260
  background:
261
- repeating-radial-gradient(circle at 20% 30%, rgba(255, 255, 255, 0.18) 0 1px, transparent 1px 5px),
262
- repeating-linear-gradient(112deg, rgba(237, 191, 115, 0.2) 0 2px, transparent 2px 8px),
263
- #202020;
264
- animation: pulse 900ms steps(2, end) infinite;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
265
  }
266
 
267
- @keyframes pulse {
268
  from { filter: contrast(1); }
269
- to { filter: contrast(1.8) brightness(1.08); }
 
 
 
 
 
270
  }
271
 
272
  img {
273
- width: min(88vw, 560px);
274
  aspect-ratio: 1;
275
  object-fit: contain;
276
- background: #101010;
277
- border: 1px solid rgba(245, 240, 232, 0.16);
 
 
 
 
 
 
 
 
 
 
 
 
 
278
  }
279
 
280
  .meta,
@@ -282,7 +457,23 @@ _BROWSER_UI_HTML = """<!doctype html>
282
  margin: 0;
283
  max-width: 54ch;
284
  line-height: 1.55;
285
- color: rgba(245, 240, 232, 0.76);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
286
  }
287
 
288
  .failure-title {
@@ -290,49 +481,103 @@ _BROWSER_UI_HTML = """<!doctype html>
290
  font-size: clamp(1.35rem, 5vw, 2rem);
291
  line-height: 1.1;
292
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
293
  </style>
294
  </head>
295
  <body>
296
  <main>
297
- <section class="panel is-active" data-state="starting" id="starting-panel" aria-live="polite">
298
- <div class="stage" aria-label="Catbox model backend startup">
299
- <div class="box"></div>
300
- </div>
301
- <p class="note">Preparing the model backend</p>
302
- </section>
303
-
304
- <section class="panel" data-state="sealed" id="sealed-panel" aria-live="polite">
305
- <div class="stage" aria-label="A sealed Catbox">
306
- <div class="box"></div>
307
- </div>
308
- <button id="observe-button" type="button">Observe</button>
309
- </section>
310
-
311
- <section class="panel" data-state="waiting" id="waiting-panel" aria-live="polite">
312
- <div class="noise" aria-hidden="true"></div>
313
- <p class="note">Observation noise</p>
314
- <p class="note" id="progressive-waiting-status" hidden>The model backend is still generating this observation.</p>
315
- </section>
316
-
317
- <section class="panel" data-state="revealed" id="revealed-panel" aria-live="polite">
318
- <img id="generated-outcome" alt="Generated Outcome">
319
- <p class="meta" id="outcome-metadata"></p>
320
- <p class="note" id="reveal-note"></p>
321
- <button id="reset-button" type="button">Reset</button>
322
- </section>
323
-
324
- <section class="panel" data-state="generation-failure" id="generation-failure-panel" aria-live="assertive">
325
- <div class="stage" aria-label="Generation Failure">
326
- <div>
327
- <h1 class="failure-title">Generation Failure</h1>
328
- <p class="note" id="generation-failure-message"></p>
329
  </div>
330
- </div>
331
- <div class="actions">
332
- <button id="retry-button" type="button">Retry</button>
333
- <button id="failure-reset-button" type="button">Reset</button>
334
- </div>
335
- </section>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
336
  </main>
337
 
338
  <script>
@@ -396,6 +641,7 @@ _BROWSER_UI_HTML = """<!doctype html>
396
  function resetToSealed() {
397
  clearProgressiveWaiting();
398
  generatedOutcome.removeAttribute("src");
 
399
  outcomeMetadata.textContent = "";
400
  revealNote.textContent = "";
401
  generationFailureMessage.textContent = "";
@@ -445,11 +691,22 @@ _BROWSER_UI_HTML = """<!doctype html>
445
  return;
446
  }
447
 
448
- generatedOutcome.src = `/api/generated-outcome?imageRef=${encodeURIComponent(observation.imageRef)}`;
449
  outcomeMetadata.textContent = `Outcome: ${observation.outcome}`;
450
  revealNote.textContent = observation.revealNote;
451
- clearProgressiveWaiting();
452
- show(revealedPanel);
 
 
 
 
 
 
 
 
 
 
 
453
  }
454
 
455
  observeButton.addEventListener("click", observe);
 
8
  from typing import Protocol
9
  from urllib.parse import parse_qs, urlparse
10
 
11
+ from catbox.env_loader import DEFAULT_ENV_FILE, load_env_file
12
  from catbox.model_backend import CatboxModelBackend
13
  from catbox.sd_turbo_runner import DEFAULT_RUNTIME_DIR, SdTurboImageToImageModelRunner
14
 
 
128
  def make_default_browser_ui_server(
129
  server_address: tuple[str, int],
130
  runtime_dir: str | Path = DEFAULT_RUNTIME_DIR,
131
+ env_file: str | Path = DEFAULT_ENV_FILE,
132
  ) -> CatboxBrowserUiServer:
133
+ load_env_file(env_file)
134
  backend = CatboxModelBackend(
135
  model_runner=SdTurboImageToImageModelRunner(runtime_dir=runtime_dir)
136
  )
 
166
  <style>
167
  :root {
168
  color-scheme: dark;
169
+ --bg: #131313;
170
+ --surface: #1d1b19;
171
+ --surface-soft: rgba(255, 255, 255, 0.055);
172
+ --text: #f6efe4;
173
+ --muted: rgba(246, 239, 228, 0.66);
174
+ --border: rgba(246, 239, 228, 0.16);
175
+ --amber: #d8a05f;
176
+ --amber-strong: #f1bc74;
177
+ --teal: #6dc9bd;
178
+ --danger: #e46f5f;
179
+ --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
180
+ --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
181
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
182
+ background: var(--bg);
183
+ color: var(--text);
184
  }
185
 
186
  * {
 
192
  min-height: 100vh;
193
  display: grid;
194
  place-items: center;
195
+ padding: 28px 18px;
196
  background:
197
+ radial-gradient(circle at 22% 12%, rgba(109, 201, 189, 0.18), transparent 24rem),
198
+ radial-gradient(circle at 78% 16%, rgba(228, 111, 95, 0.16), transparent 28rem),
199
+ linear-gradient(145deg, #121212 0%, #211c18 58%, #101010 100%);
200
  }
201
 
202
  main {
203
+ width: min(94vw, 780px);
204
  display: grid;
205
+ gap: 18px;
206
  justify-items: center;
207
  text-align: center;
208
  }
209
 
210
+ .brand {
211
+ display: grid;
212
+ gap: 6px;
213
+ justify-items: center;
214
+ }
215
+
216
+ h1 {
217
+ margin: 0;
218
+ font-size: clamp(2.35rem, 10vw, 5.5rem);
219
+ font-weight: 850;
220
+ line-height: 0.88;
221
+ letter-spacing: 0;
222
+ }
223
+
224
+ .tagline {
225
+ margin: 0;
226
+ max-width: 36rem;
227
+ color: var(--muted);
228
+ font-size: clamp(0.95rem, 2.6vw, 1.08rem);
229
+ line-height: 1.45;
230
+ }
231
+
232
+ .viewport {
233
+ width: 100%;
234
+ min-height: min(86vh, 690px);
235
+ display: grid;
236
+ place-items: center;
237
+ position: relative;
238
+ }
239
+
240
  .stage {
241
+ width: min(88vw, 620px);
242
+ aspect-ratio: 1.12;
243
  display: grid;
244
  place-items: center;
245
+ position: relative;
246
+ border: 1px solid var(--border);
247
+ background:
248
+ radial-gradient(circle at 50% 32%, rgba(246, 239, 228, 0.09), transparent 18rem),
249
+ linear-gradient(180deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.025));
250
  overflow: hidden;
251
+ box-shadow: 0 34px 90px rgba(0, 0, 0, 0.34);
252
+ }
253
+
254
+ .stage::before {
255
+ content: "";
256
+ position: absolute;
257
+ inset: auto 0 0;
258
+ height: 34%;
259
+ background: linear-gradient(180deg, transparent, rgba(216, 160, 95, 0.14));
260
+ pointer-events: none;
261
+ }
262
+
263
+ .stage::after {
264
+ content: "";
265
+ position: absolute;
266
+ width: 70%;
267
+ height: 1px;
268
+ bottom: 28%;
269
+ background: linear-gradient(90deg, transparent, rgba(246, 239, 228, 0.2), transparent);
270
+ pointer-events: none;
271
  }
272
 
273
  .box {
274
+ width: min(58vw, 330px);
275
  aspect-ratio: 1.25;
276
  position: relative;
277
+ transform-style: preserve-3d;
278
+ transform: translateY(12px) perspective(760px) rotateX(2deg);
279
+ border: 2px solid rgba(241, 188, 116, 0.78);
280
+ background:
281
+ linear-gradient(115deg, rgba(255, 255, 255, 0.11), transparent 34%),
282
+ linear-gradient(#9f6a38, #6d421f);
283
+ box-shadow:
284
+ 0 34px 70px rgba(0, 0, 0, 0.44),
285
+ inset 0 1px 0 rgba(255, 255, 255, 0.16);
286
+ transition: transform 240ms var(--ease-out);
287
  }
288
 
289
  .box::before {
290
  content: "";
291
  position: absolute;
292
+ inset: -52px -18px auto;
293
+ height: 62px;
294
+ border: 2px solid rgba(241, 188, 116, 0.88);
295
+ background:
296
+ linear-gradient(110deg, rgba(255, 255, 255, 0.14), transparent 42%),
297
+ linear-gradient(#bf884c, #7d4d25);
298
+ transform: perspective(280px) rotateX(48deg);
299
  transform-origin: bottom;
300
+ box-shadow: 0 18px 24px rgba(0, 0, 0, 0.18);
301
+ transition: transform 320ms var(--ease-in-out), inset 320ms var(--ease-in-out);
302
+ }
303
+
304
+ .box::after {
305
+ content: "";
306
+ position: absolute;
307
+ inset: 22% 18% auto;
308
+ height: 26%;
309
+ background: radial-gradient(ellipse, rgba(0, 0, 0, 0.22), transparent 66%);
310
+ transform: translateY(16px);
311
+ opacity: 0.64;
312
  }
313
 
314
  .panel {
315
+ position: absolute;
316
+ inset: 0;
317
  width: 100%;
318
+ min-height: 100%;
319
+ display: grid;
320
+ align-content: center;
321
  justify-items: center;
322
  gap: 18px;
323
+ opacity: 0;
324
+ pointer-events: none;
325
+ transform: translateY(10px) scale(0.985);
326
+ filter: blur(2px);
327
+ transition:
328
+ opacity 190ms var(--ease-out),
329
+ transform 220ms var(--ease-out),
330
+ filter 220ms var(--ease-out);
331
  }
332
 
333
  .panel.is-active {
334
+ position: relative;
335
  display: grid;
336
+ opacity: 1;
337
+ pointer-events: auto;
338
+ transform: translateY(0) scale(1);
339
+ filter: blur(0);
340
  }
341
 
342
  button {
343
  min-width: 148px;
344
+ min-height: 46px;
345
+ border: 1px solid rgba(246, 239, 228, 0.28);
346
+ background: linear-gradient(180deg, #fff6e7, #e6c99f);
347
+ color: #1b1714;
348
  font: inherit;
349
  font-weight: 700;
350
+ border-radius: 8px;
351
  cursor: pointer;
352
+ box-shadow:
353
+ 0 14px 34px rgba(0, 0, 0, 0.28),
354
+ inset 0 1px 0 rgba(255, 255, 255, 0.5);
355
+ transition:
356
+ transform 140ms var(--ease-out),
357
+ box-shadow 180ms var(--ease-out),
358
+ border-color 180ms ease,
359
+ opacity 180ms ease;
360
+ }
361
+
362
+ button:active {
363
+ transform: scale(0.97);
364
  }
365
 
366
  button:disabled {
367
  opacity: 0.55;
368
  cursor: progress;
369
+ box-shadow: none;
370
+ }
371
+
372
+ @media (hover: hover) and (pointer: fine) {
373
+ button:not(:disabled):hover {
374
+ transform: translateY(-1px);
375
+ border-color: rgba(246, 239, 228, 0.48);
376
+ box-shadow:
377
+ 0 18px 40px rgba(0, 0, 0, 0.32),
378
+ inset 0 1px 0 rgba(255, 255, 255, 0.58);
379
+ }
380
+
381
+ button:not(:disabled):hover:active {
382
+ transform: translateY(0) scale(0.97);
383
+ }
384
  }
385
 
386
  .actions {
 
391
  }
392
 
393
  .noise {
394
+ width: min(78vw, 520px);
395
+ aspect-ratio: 1.18;
396
+ position: relative;
397
+ overflow: hidden;
398
+ border: 1px solid var(--border);
399
  background:
400
+ radial-gradient(circle at 50% 46%, rgba(109, 201, 189, 0.18), transparent 15rem),
401
+ repeating-linear-gradient(102deg, rgba(241, 188, 116, 0.18) 0 1px, transparent 1px 8px),
402
+ repeating-radial-gradient(circle at 26% 24%, rgba(246, 239, 228, 0.16) 0 1px, transparent 1px 5px),
403
+ #171717;
404
+ box-shadow: 0 30px 78px rgba(0, 0, 0, 0.36);
405
+ animation: observation-pulse 760ms steps(2, end) infinite;
406
+ }
407
+
408
+ .noise::before,
409
+ .noise::after {
410
+ content: "";
411
+ position: absolute;
412
+ inset: 18%;
413
+ border: 1px solid rgba(246, 239, 228, 0.22);
414
+ transform: rotate(45deg) scale(0.84);
415
+ animation: aperture 1600ms var(--ease-in-out) infinite alternate;
416
+ }
417
+
418
+ .noise::after {
419
+ inset: 29%;
420
+ border-color: rgba(109, 201, 189, 0.34);
421
+ animation-delay: 180ms;
422
  }
423
 
424
+ @keyframes observation-pulse {
425
  from { filter: contrast(1); }
426
+ to { filter: contrast(1.75) brightness(1.08); }
427
+ }
428
+
429
+ @keyframes aperture {
430
+ from { transform: rotate(45deg) scale(0.78); opacity: 0.38; }
431
+ to { transform: rotate(45deg) scale(1.04); opacity: 0.74; }
432
  }
433
 
434
  img {
435
+ width: min(88vw, 620px);
436
  aspect-ratio: 1;
437
  object-fit: contain;
438
+ background: #111;
439
+ border: 1px solid var(--border);
440
+ box-shadow: 0 32px 86px rgba(0, 0, 0, 0.38);
441
+ opacity: 1;
442
+ transform: scale(1);
443
+ transition:
444
+ opacity 240ms var(--ease-out),
445
+ transform 280ms var(--ease-out),
446
+ filter 280ms var(--ease-out);
447
+ }
448
+
449
+ img[data-loading="true"] {
450
+ opacity: 0;
451
+ transform: scale(0.975);
452
+ filter: blur(4px);
453
  }
454
 
455
  .meta,
 
457
  margin: 0;
458
  max-width: 54ch;
459
  line-height: 1.55;
460
+ color: var(--muted);
461
+ }
462
+
463
+ .meta {
464
+ display: inline-flex;
465
+ align-items: center;
466
+ min-height: 28px;
467
+ padding: 5px 10px;
468
+ border: 1px solid var(--border);
469
+ border-radius: 999px;
470
+ background: var(--surface-soft);
471
+ color: rgba(246, 239, 228, 0.78);
472
+ font-size: 0.88rem;
473
+ }
474
+
475
+ [hidden] {
476
+ display: none !important;
477
  }
478
 
479
  .failure-title {
 
481
  font-size: clamp(1.35rem, 5vw, 2rem);
482
  line-height: 1.1;
483
  }
484
+
485
+ #generation-failure-panel .stage {
486
+ border-color: rgba(228, 111, 95, 0.34);
487
+ background:
488
+ radial-gradient(circle at 50% 38%, rgba(228, 111, 95, 0.17), transparent 16rem),
489
+ linear-gradient(180deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.025));
490
+ }
491
+
492
+ @media (max-width: 560px) {
493
+ body {
494
+ padding: 20px 12px;
495
+ }
496
+
497
+ main {
498
+ gap: 14px;
499
+ }
500
+
501
+ .viewport {
502
+ min-height: min(82vh, 610px);
503
+ }
504
+
505
+ .stage,
506
+ img {
507
+ width: min(94vw, 620px);
508
+ }
509
+
510
+ .box {
511
+ width: min(66vw, 300px);
512
+ }
513
+ }
514
+
515
+ @media (prefers-reduced-motion: reduce) {
516
+ *,
517
+ *::before,
518
+ *::after {
519
+ animation-duration: 1ms !important;
520
+ animation-iteration-count: 1 !important;
521
+ scroll-behavior: auto !important;
522
+ transition-duration: 1ms !important;
523
+ }
524
+
525
+ .panel,
526
+ img[data-loading="true"] {
527
+ transform: none;
528
+ filter: none;
529
+ }
530
+ }
531
  </style>
532
  </head>
533
  <body>
534
  <main>
535
+ <header class="brand">
536
+ <h1>Catbox</h1>
537
+ <p class="tagline">Observe the sealed box. The local model resolves one generated outcome.</p>
538
+ </header>
539
+
540
+ <div class="viewport">
541
+ <section class="panel is-active" data-state="starting" id="starting-panel" aria-live="polite">
542
+ <div class="stage" aria-label="Catbox model backend startup">
543
+ <div class="box"></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
544
  </div>
545
+ <p class="note">Preparing the model backend</p>
546
+ </section>
547
+
548
+ <section class="panel" data-state="sealed" id="sealed-panel" aria-live="polite">
549
+ <div class="stage" aria-label="A sealed Catbox">
550
+ <div class="box"></div>
551
+ </div>
552
+ <button id="observe-button" type="button">Observe</button>
553
+ </section>
554
+
555
+ <section class="panel" data-state="waiting" id="waiting-panel" aria-live="polite">
556
+ <div class="noise" aria-hidden="true"></div>
557
+ <p class="note">Observation noise</p>
558
+ <p class="note" id="progressive-waiting-status" hidden>The model backend is still generating this observation.</p>
559
+ </section>
560
+
561
+ <section class="panel" data-state="revealed" id="revealed-panel" aria-live="polite">
562
+ <img id="generated-outcome" alt="Generated Outcome" data-loading="true">
563
+ <p class="meta" id="outcome-metadata"></p>
564
+ <p class="note" id="reveal-note"></p>
565
+ <button id="reset-button" type="button">Reset</button>
566
+ </section>
567
+
568
+ <section class="panel" data-state="generation-failure" id="generation-failure-panel" aria-live="assertive">
569
+ <div class="stage" aria-label="Generation Failure">
570
+ <div>
571
+ <h1 class="failure-title">Generation Failure</h1>
572
+ <p class="note" id="generation-failure-message"></p>
573
+ </div>
574
+ </div>
575
+ <div class="actions">
576
+ <button id="retry-button" type="button">Retry</button>
577
+ <button id="failure-reset-button" type="button">Reset</button>
578
+ </div>
579
+ </section>
580
+ </div>
581
  </main>
582
 
583
  <script>
 
641
  function resetToSealed() {
642
  clearProgressiveWaiting();
643
  generatedOutcome.removeAttribute("src");
644
+ generatedOutcome.dataset.loading = "true";
645
  outcomeMetadata.textContent = "";
646
  revealNote.textContent = "";
647
  generationFailureMessage.textContent = "";
 
691
  return;
692
  }
693
 
694
+ generatedOutcome.dataset.loading = "true";
695
  outcomeMetadata.textContent = `Outcome: ${observation.outcome}`;
696
  revealNote.textContent = observation.revealNote;
697
+ generatedOutcome.onload = () => {
698
+ generatedOutcome.dataset.loading = "false";
699
+ clearProgressiveWaiting();
700
+ show(revealedPanel);
701
+ };
702
+ generatedOutcome.onerror = () => {
703
+ showGenerationFailure({
704
+ error: {
705
+ message: "The generated outcome file could not be displayed.",
706
+ },
707
+ });
708
+ };
709
+ generatedOutcome.src = `/api/generated-outcome?imageRef=${encodeURIComponent(observation.imageRef)}`;
710
  }
711
 
712
  observeButton.addEventListener("click", observe);
catbox/env_loader.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+
7
+ ROOT = Path(__file__).resolve().parent.parent
8
+ DEFAULT_ENV_FILE = ROOT / ".env"
9
+
10
+
11
+ def load_env_file(env_file: str | Path = DEFAULT_ENV_FILE) -> None:
12
+ path = Path(env_file)
13
+ if not path.is_file():
14
+ return
15
+
16
+ for raw_line in path.read_text(encoding="utf-8").splitlines():
17
+ line = raw_line.strip()
18
+ if not line or line.startswith("#"):
19
+ continue
20
+ if line.startswith("export "):
21
+ line = line[len("export ") :].strip()
22
+ key, separator, value = line.partition("=")
23
+ if not separator:
24
+ continue
25
+
26
+ key = key.strip()
27
+ if not key or key in os.environ:
28
+ continue
29
+
30
+ os.environ[key] = _unquote_env_value(value.strip())
31
+
32
+
33
+ def _unquote_env_value(value: str) -> str:
34
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
35
+ return value[1:-1]
36
+ return value
catbox/sd_turbo_runner.py CHANGED
@@ -52,7 +52,7 @@ NEGATIVE_PROMPTS: dict[Outcome, str] = {
52
  }
53
 
54
  OUTCOME_DEFAULTS: dict[Outcome, dict[str, object]] = {
55
- "living": {"steps": 2, "strength": 0.8, "width": 384, "height": 384},
56
  "absent": {"steps": 2, "strength": 0.55},
57
  }
58
 
 
52
  }
53
 
54
  OUTCOME_DEFAULTS: dict[Outcome, dict[str, object]] = {
55
+ "living": {"steps": 4, "strength": 0.8, "width": 384, "height": 384},
56
  "absent": {"steps": 2, "strength": 0.55},
57
  }
58
 
catbox/validate_sd_turbo_runner.py CHANGED
@@ -4,6 +4,7 @@ import argparse
4
  import json
5
  from pathlib import Path
6
 
 
7
  from catbox.model_backend import CatboxModelBackend, Outcome
8
  from catbox.sd_turbo_runner import DEFAULT_RUNTIME_DIR, SdTurboImageToImageModelRunner
9
 
@@ -15,11 +16,13 @@ def parse_args() -> argparse.Namespace:
15
  parser.add_argument("--seed", type=int, default=41100)
16
  parser.add_argument("--outcome", choices=["living", "absent", "all"], default="all")
17
  parser.add_argument("--runtime-dir", default=str(DEFAULT_RUNTIME_DIR))
 
18
  return parser.parse_args()
19
 
20
 
21
  def main() -> None:
22
  args = parse_args()
 
23
  runner = SdTurboImageToImageModelRunner(runtime_dir=Path(args.runtime_dir))
24
  backend = CatboxModelBackend(model_runner=runner)
25
 
 
4
  import json
5
  from pathlib import Path
6
 
7
+ from catbox.env_loader import DEFAULT_ENV_FILE, load_env_file
8
  from catbox.model_backend import CatboxModelBackend, Outcome
9
  from catbox.sd_turbo_runner import DEFAULT_RUNTIME_DIR, SdTurboImageToImageModelRunner
10
 
 
16
  parser.add_argument("--seed", type=int, default=41100)
17
  parser.add_argument("--outcome", choices=["living", "absent", "all"], default="all")
18
  parser.add_argument("--runtime-dir", default=str(DEFAULT_RUNTIME_DIR))
19
+ parser.add_argument("--env-file", default=str(DEFAULT_ENV_FILE))
20
  return parser.parse_args()
21
 
22
 
23
  def main() -> None:
24
  args = parse_args()
25
+ load_env_file(args.env_file)
26
  runner = SdTurboImageToImageModelRunner(runtime_dir=Path(args.runtime_dir))
27
  backend = CatboxModelBackend(model_runner=runner)
28
 
docs/manual-gpu-validation.md CHANGED
@@ -88,6 +88,10 @@ Expected response:
88
  Compare each forced outcome's `metadata.generationSeconds` against the under 23
89
  seconds Primary Runtime Target after readiness.
90
 
 
 
 
 
91
  ## Ephemeral Generated Outcomes
92
 
93
  Generated files are written under:
 
88
  Compare each forced outcome's `metadata.generationSeconds` against the under 23
89
  seconds Primary Runtime Target after readiness.
90
 
91
+ The current preferred SD Turbo settings are outcome-specific: the Living-Cat
92
+ Outcome should use 384px image-to-image generation with 4 steps, and the
93
+ Absent-Cat Outcome should use 512px image-to-image generation with 2 steps.
94
+
95
  ## Ephemeral Generated Outcomes
96
 
97
  Generated files are written under:
tests/test_browser_ui.py CHANGED
@@ -1,10 +1,12 @@
1
  import json
 
2
  from pathlib import Path
3
  from tempfile import TemporaryDirectory
4
  from unittest import TestCase
 
5
  from urllib.parse import quote
6
 
7
- from catbox.browser_ui import BrowserUiApp
8
 
9
 
10
  class RecordingBackend:
@@ -77,6 +79,9 @@ class BrowserUiServerTests(TestCase):
77
  self.assertIn("PROGRESSIVE_WAITING_DELAY_MS", html)
78
  self.assertIn("showProgressiveWaiting", html)
79
  self.assertIn("id=\"generated-outcome\"", html)
 
 
 
80
  self.assertIn("id=\"reveal-note\"", html)
81
  self.assertIn("id=\"reset-button\"", html)
82
  self.assertIn("data-state=\"generation-failure\"", html)
@@ -86,6 +91,9 @@ class BrowserUiServerTests(TestCase):
86
  self.assertIn("retryButton.addEventListener(\"click\", observe)", html)
87
  self.assertIn("failureResetButton.addEventListener(\"click\", resetToSealed)", html)
88
  self.assertIn("generationFailureMessage.textContent = \"\"", html)
 
 
 
89
  self.assertNotIn("name=\"outcome\"", html)
90
 
91
  def test_readiness_endpoint_returns_backend_startup_state(self):
@@ -146,3 +154,34 @@ class BrowserUiServerTests(TestCase):
146
  "/api/generated-outcome?imageRef=/tmp/not-generated.png",
147
  )
148
  self.assertEqual(image_response.status, 404)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import json
2
+ import os
3
  from pathlib import Path
4
  from tempfile import TemporaryDirectory
5
  from unittest import TestCase
6
+ from unittest.mock import patch
7
  from urllib.parse import quote
8
 
9
+ from catbox.browser_ui import BrowserUiApp, make_default_browser_ui_server
10
 
11
 
12
  class RecordingBackend:
 
79
  self.assertIn("PROGRESSIVE_WAITING_DELAY_MS", html)
80
  self.assertIn("showProgressiveWaiting", html)
81
  self.assertIn("id=\"generated-outcome\"", html)
82
+ self.assertIn("data-loading=\"true\"", html)
83
+ self.assertIn("generatedOutcome.onload", html)
84
+ self.assertIn("generatedOutcome.onerror", html)
85
  self.assertIn("id=\"reveal-note\"", html)
86
  self.assertIn("id=\"reset-button\"", html)
87
  self.assertIn("data-state=\"generation-failure\"", html)
 
91
  self.assertIn("retryButton.addEventListener(\"click\", observe)", html)
92
  self.assertIn("failureResetButton.addEventListener(\"click\", resetToSealed)", html)
93
  self.assertIn("generationFailureMessage.textContent = \"\"", html)
94
+ self.assertIn("transform: scale(0.97)", html)
95
+ self.assertIn("@media (prefers-reduced-motion: reduce)", html)
96
+ self.assertIn("@media (hover: hover) and (pointer: fine)", html)
97
  self.assertNotIn("name=\"outcome\"", html)
98
 
99
  def test_readiness_endpoint_returns_backend_startup_state(self):
 
154
  "/api/generated-outcome?imageRef=/tmp/not-generated.png",
155
  )
156
  self.assertEqual(image_response.status, 404)
157
+
158
+ def test_default_server_loads_hf_token_from_env_file_before_model_runner(self):
159
+ with TemporaryDirectory() as project_dir:
160
+ env_file = Path(project_dir) / ".env"
161
+ env_file.write_text("HF_TOKEN=hf_test_token\n", encoding="utf-8")
162
+ observed_token = []
163
+
164
+ class TokenAwareRunner:
165
+ def __init__(self, runtime_dir):
166
+ observed_token.append(os.environ.get("HF_TOKEN"))
167
+
168
+ def is_ready(self):
169
+ return True
170
+
171
+ def generate(self, outcome, seed, config=None):
172
+ return {
173
+ "image_ref": "/tmp/generated.png",
174
+ "generation_seconds": 0.1,
175
+ "metadata": {},
176
+ }
177
+
178
+ with patch.dict(os.environ, {}, clear=True):
179
+ with patch("catbox.browser_ui.SdTurboImageToImageModelRunner", TokenAwareRunner):
180
+ with patch("catbox.browser_ui.CatboxBrowserUiServer") as server_class:
181
+ make_default_browser_ui_server(
182
+ ("127.0.0.1", 0),
183
+ env_file=env_file,
184
+ )
185
+ self.assertEqual(server_class.call_count, 1)
186
+
187
+ self.assertEqual(observed_token, ["hf_test_token"])
tests/test_model_backend.py CHANGED
@@ -290,7 +290,7 @@ class SdTurboRunnerTests(TestCase):
290
  self.assertEqual(generated["generation_seconds"], 0.25)
291
  self.assertTrue(Path(generated["image_ref"]).exists())
292
  self.assertIn("living cat", pipeline.calls[0]["prompt"])
293
- self.assertEqual(pipeline.calls[0]["num_inference_steps"], 2)
294
  self.assertEqual(pipeline.calls[0]["strength"], 0.8)
295
  self.assertEqual(box_image_loads, [{"width": 384, "height": 384}])
296
  self.assertNotIn("negative_prompt", pipeline.calls[0])
 
290
  self.assertEqual(generated["generation_seconds"], 0.25)
291
  self.assertTrue(Path(generated["image_ref"]).exists())
292
  self.assertIn("living cat", pipeline.calls[0]["prompt"])
293
+ self.assertEqual(pipeline.calls[0]["num_inference_steps"], 4)
294
  self.assertEqual(pipeline.calls[0]["strength"], 0.8)
295
  self.assertEqual(box_image_loads, [{"width": 384, "height": 384}])
296
  self.assertNotIn("negative_prompt", pipeline.calls[0])
tests/test_validate_sd_turbo_runner.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from io import StringIO
3
+ from pathlib import Path
4
+ from tempfile import TemporaryDirectory
5
+ from unittest import TestCase
6
+ from unittest.mock import patch
7
+
8
+ from catbox import validate_sd_turbo_runner
9
+
10
+
11
+ class ValidateSdTurboRunnerTests(TestCase):
12
+ def test_validation_command_loads_hf_token_before_model_runner(self):
13
+ with TemporaryDirectory() as project_dir:
14
+ env_file = Path(project_dir) / ".env"
15
+ env_file.write_text("HF_TOKEN=hf_test_token\n", encoding="utf-8")
16
+ observed_token = []
17
+
18
+ class TokenAwareRunner:
19
+ def __init__(self, runtime_dir):
20
+ observed_token.append(os.environ.get("HF_TOKEN"))
21
+
22
+ def is_ready(self):
23
+ return True
24
+
25
+ def generate(self, outcome, seed, config=None):
26
+ return {
27
+ "image_ref": "/tmp/generated.png",
28
+ "generation_seconds": 0.1,
29
+ "metadata": {},
30
+ }
31
+
32
+ argv = [
33
+ "validate_sd_turbo_runner",
34
+ "--outcome",
35
+ "living",
36
+ "--env-file",
37
+ str(env_file),
38
+ ]
39
+
40
+ with patch.dict(os.environ, {}, clear=True):
41
+ with patch("sys.argv", argv):
42
+ with patch(
43
+ "catbox.validate_sd_turbo_runner.SdTurboImageToImageModelRunner",
44
+ TokenAwareRunner,
45
+ ):
46
+ with patch("sys.stdout", StringIO()):
47
+ validate_sd_turbo_runner.main()
48
+
49
+ self.assertEqual(observed_token, ["hf_test_token"])