Spaces:
Paused
feat: polish Catbox observation flow
Browse filesLoad 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 +1 -0
- .gitignore +1 -0
- README.md +28 -9
- catbox/browser_ui.py +334 -77
- catbox/env_loader.py +36 -0
- catbox/sd_turbo_runner.py +1 -1
- catbox/validate_sd_turbo_runner.py +3 -0
- docs/manual-gpu-validation.md +4 -0
- tests/test_browser_ui.py +40 -1
- tests/test_model_backend.py +1 -1
- tests/test_validate_sd_turbo_runner.py +49 -0
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
HF_TOKEN=
|
|
@@ -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/
|
|
@@ -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
|
| 62 |
-
backend-provided local file reference
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
|
@@ -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:
|
| 168 |
-
color:
|
| 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
|
| 182 |
-
|
|
|
|
| 183 |
}
|
| 184 |
|
| 185 |
main {
|
| 186 |
-
width: min(
|
| 187 |
display: grid;
|
| 188 |
-
gap:
|
| 189 |
justify-items: center;
|
| 190 |
text-align: center;
|
| 191 |
}
|
| 192 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
.stage {
|
| 194 |
-
width: min(88vw,
|
| 195 |
-
aspect-ratio: 1.
|
| 196 |
display: grid;
|
| 197 |
place-items: center;
|
| 198 |
-
|
| 199 |
-
|
|
|
|
|
|
|
|
|
|
| 200 |
overflow: hidden;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
}
|
| 202 |
|
| 203 |
.box {
|
| 204 |
-
width: min(58vw,
|
| 205 |
aspect-ratio: 1.25;
|
| 206 |
position: relative;
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 210 |
}
|
| 211 |
|
| 212 |
.box::before {
|
| 213 |
content: "";
|
| 214 |
position: absolute;
|
| 215 |
-
inset: -
|
| 216 |
-
height:
|
| 217 |
-
border: 2px solid
|
| 218 |
-
background:
|
| 219 |
-
|
|
|
|
|
|
|
| 220 |
transform-origin: bottom;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
}
|
| 222 |
|
| 223 |
.panel {
|
| 224 |
-
|
|
|
|
| 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:
|
| 237 |
-
border: 1px solid rgba(
|
| 238 |
-
background: #
|
| 239 |
-
color: #
|
| 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(
|
| 259 |
-
aspect-ratio: 1.
|
|
|
|
|
|
|
|
|
|
| 260 |
background:
|
| 261 |
-
|
| 262 |
-
repeating-linear-gradient(
|
| 263 |
-
|
| 264 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
}
|
| 266 |
|
| 267 |
-
@keyframes pulse {
|
| 268 |
from { filter: contrast(1); }
|
| 269 |
-
to { filter: contrast(1.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 270 |
}
|
| 271 |
|
| 272 |
img {
|
| 273 |
-
width: min(88vw,
|
| 274 |
aspect-ratio: 1;
|
| 275 |
object-fit: contain;
|
| 276 |
-
background: #
|
| 277 |
-
border: 1px solid
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
<
|
| 298 |
-
<
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
<
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 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 |
-
|
| 331 |
-
<
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 449 |
outcomeMetadata.textContent = `Outcome: ${observation.outcome}`;
|
| 450 |
revealNote.textContent = observation.revealNote;
|
| 451 |
-
|
| 452 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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);
|
|
@@ -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
|
|
@@ -52,7 +52,7 @@ NEGATIVE_PROMPTS: dict[Outcome, str] = {
|
|
| 52 |
}
|
| 53 |
|
| 54 |
OUTCOME_DEFAULTS: dict[Outcome, dict[str, object]] = {
|
| 55 |
-
"living": {"steps":
|
| 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 |
|
|
@@ -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 |
|
|
@@ -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:
|
|
@@ -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"])
|
|
@@ -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"],
|
| 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])
|
|
@@ -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"])
|