Compare commits

..

10 commits

Author SHA1 Message Date
Schmidt Till (CSS TO PME DSI PAO MUC)
36200662d5 docs(map): record resolution of End-to-end verification and documentation update 2026-09-10 23:37:42 +02:00
Schmidt Till (CSS TO PME DSI PAO MUC)
a763181b74 refactor: parameterize e2e tests and add CLI stdout assertions 2026-09-10 23:37:23 +02:00
Schmidt Till (CSS TO PME DSI PAO MUC)
4ff83c36ef feat: add end-to-end verification and update documentation (#5) 2026-09-10 23:35:52 +02:00
Schmidt Till (CSS TO PME DSI PAO MUC)
91b23e3fa6 docs(map): record resolution of Build single-page web UI in static/index.html 2026-09-10 23:32:58 +02:00
Schmidt Till (CSS TO PME DSI PAO MUC)
6650298c3d refactor: address review feedback on multipart field compatibility and form serialization 2026-09-10 23:32:40 +02:00
Schmidt Till (CSS TO PME DSI PAO MUC)
505ded0493 feat: build single-page web UI in static/index.html (#4) 2026-09-10 23:28:59 +02:00
Schmidt Till (CSS TO PME DSI PAO MUC)
8fd0f764f1 docs(map): record resolution of Implement FastAPI backend and /api/clean endpoint in app.py 2026-09-10 23:26:55 +02:00
Schmidt Till (CSS TO PME DSI PAO MUC)
19a9894dea refactor: address code review feedback on public image info, API key resolution, and serialization 2026-09-10 23:26:36 +02:00
Schmidt Till (CSS TO PME DSI PAO MUC)
d7c43f7f2f feat: implement FastAPI backend and /api/clean endpoint (#3) 2026-09-10 23:24:36 +02:00
Schmidt Till (CSS TO PME DSI PAO MUC)
932352d85c docs(map): record resolution of Decouple core photo cleaning logic for CLI and API reuse 2026-09-10 23:21:16 +02:00
10 changed files with 1672 additions and 12 deletions

View file

@ -1,6 +1,6 @@
# Garment photo cleaner
# Garment photo cleaner (unwrap-clothes)
CLI tool that cleans up second-hand clothing photos for marketplace listings (Vinted, eBay, Depop). It removes fabric wrinkles, puts the garment on a studio backdrop (warm beige or bright white), and preserves shapes, colors, prints, buttons, and defects.
Web application and CLI tool that cleans up second-hand clothing photos for marketplace listings (Vinted, eBay, Depop). It removes fabric wrinkles, places garments on studio backdrops (warm beige or bright white), and preserves garment shapes, colors, prints, buttons, and defects.
Uses OpenRouter's Image API with `meta/muse-image`.
@ -15,13 +15,35 @@ Install dependencies:
pip install -r requirements.txt
```
Set your API key:
Set your API key (optional if provided in the Web UI or via `--api-key`):
```bash
export OPENROUTER_API_KEY="sk-or-v1-..."
```
## Usage
## Web UI
Start the local server:
```bash
python3 app.py
```
The application runs at [http://127.0.0.1:8000](http://127.0.0.1:8000).
### Web UI capabilities
- **Drag-and-drop photo upload**: Drop an image onto the upload zone or click to select from your filesystem.
- **Preset selector buttons**: Switch between *Warm beige* and *Bright white* studio backdrop presets with a single click.
- **Editable prompt**: Customize the full prompt text directly before sending.
- **OpenRouter model selection**: Defaults to `meta/muse-image`, with full support for any OpenRouter image model.
- **API key management**: Pass an API key directly in the UI or let the server fall back to the `OPENROUTER_API_KEY` environment variable.
- **Lanczos restoration toggle and badge**: Enable or disable automatic upscaling back to source photo dimensions; inspect original and model resolution badges on results.
- **Side-by-side comparison**: View the original photo and the cleaned result side-by-side.
- **Generation cost display**: Displays the exact API cost reported by OpenRouter (e.g. `$0.01`).
- **One-click download**: Download the cleaned photo with `<original_name>_clean.<ext>` naming.
## CLI usage
Basic run with default soft beige background:
@ -31,7 +53,7 @@ python3 unwrap_clothes.py shirt.jpg
Output saves next to the source photo as `shirt_clean.jpg`.
### Options
### CLI options
Select a white background:
@ -63,6 +85,34 @@ Override the model:
python3 unwrap_clothes.py shirt.jpg --model meta/muse-image
```
Pass API key explicitly:
```bash
python3 unwrap_clothes.py shirt.jpg --api-key "sk-or-v1-..."
```
## Project architecture
- `cleaner.py`: Core image processing module. Handles background preset prompts, image dimension and format detection via Pillow (`get_image_info`), Lanczos resolution restoration (`restore_resolution_bytes`), OpenRouter API calls (`call_openrouter_images`), and the complete cleaning workflow (`clean_garment`).
- `app.py`: FastAPI application providing HTTP REST endpoints (`POST /api/clean`, `GET /api/health`) and serving the static single-page web UI from `static/`.
- `unwrap_clothes.py`: Command-line interface wrapping `clean_garment` for standalone terminal execution and batch processing.
- `static/index.html`: Responsive single-page application frontend featuring drag-and-drop photo upload, preset selectors, side-by-side comparison, and one-click downloads without external framework dependencies.
## Testing
Run the test suite with `pytest`:
```bash
pytest
```
The suite covers:
- `tests/test_cleaner.py`: Unit tests for resolution restoration, image dimension detection, base64 encoding/decoding, and OpenRouter API error handling.
- `tests/test_app.py`: Backend tests for FastAPI routes, input validation, environment key fallback, and response payloads.
- `tests/test_ui.py`: Frontend verification checking DOM structure, controls, and script logic in `static/index.html`.
- `tests/test_unwrap_clothes.py`: CLI argument parsing, backwards-compatible exports, and exit codes.
- `tests/test_e2e.py`: End-to-end integration tests verifying FastAPI `POST /api/clean` (including Lanczos restoration to source dimensions) and CLI subprocess invocation against a mocked OpenRouter service.
## Why meta/muse-image is the default
Other image models on OpenRouter run into policy or cost issues on second-hand clothing photos:
@ -75,7 +125,7 @@ Other image models on OpenRouter run into policy or cost issues on second-hand c
`meta/muse-image` caps output at roughly 1.3 to 1.8 MP (for example, 1376x1824) regardless of the size parameters passed to the API.
To avoid downsized uploads on marketplaces that expect high-resolution smartphone photos (such as 3024x4032), the script automatically resizes the generated image back to the source file's exact dimensions using Lanczos interpolation. This preserves the aspect ratio and frame size of the original photo.
To avoid downsized uploads on marketplaces that expect high-resolution smartphone photos (such as 3024x4032), the tool automatically resizes the generated image back to the source file's exact dimensions using Lanczos interpolation. This preserves the aspect ratio and frame size of the original photo.
## Known limitations

112
app.py Normal file
View file

@ -0,0 +1,112 @@
"""FastAPI backend for garment photo cleaning."""
import os
from pathlib import Path
from fastapi import FastAPI, File, Form, HTTPException, UploadFile, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from cleaner import (
DEFAULT_MODEL,
CleanerError,
build_prompt,
clean_garment,
get_image_info,
resolve_api_key,
)
DEFAULT_PROMPT = build_prompt("beige")
STATIC_DIR = Path(__file__).resolve().parent / "static"
STATIC_DIR.mkdir(parents=True, exist_ok=True)
app = FastAPI(title="unwrap-clothes")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/api/health")
def health_check():
return {"status": "ok"}
@app.get("/")
def read_root():
index_file = STATIC_DIR / "index.html"
if index_file.is_file():
return FileResponse(index_file)
return JSONResponse({"status": "ok", "message": "unwrap-clothes backend is running"})
@app.post("/api/clean")
async def clean_endpoint(
file: UploadFile | None = File(default=None),
image: UploadFile | None = File(default=None),
prompt: str = Form(default=DEFAULT_PROMPT),
model: str = Form(default=DEFAULT_MODEL),
api_key: str | None = Form(default=None),
restore_res: bool = Form(default=True),
restore_resolution: bool | None = Form(default=None),
):
upload_file = file or image
if upload_file is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Image file is required (field 'file' or 'image').",
)
effective_restore = restore_resolution if restore_resolution is not None else restore_res
effective_api_key = resolve_api_key(api_key)
if not effective_api_key:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="API key is required. Provide it in the form or set OPENROUTER_API_KEY.",
)
try:
file_bytes = await upload_file.read()
finally:
await upload_file.close()
if not file_bytes:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Uploaded file is empty.",
)
orig_dims, _ = get_image_info(file_bytes)
if orig_dims is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid image file. Could not decode image.",
)
try:
clean_result = clean_garment(
image_data=file_bytes,
prompt=prompt,
api_key=effective_api_key,
model=model,
restore_res=effective_restore,
)
except CleanerError as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=str(exc),
)
return clean_result.to_payload()
if STATIC_DIR.is_dir():
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
if __name__ == "__main__":
import uvicorn
uvicorn.run("app:app", host="127.0.0.1", port=8000, reload=True)

View file

@ -11,7 +11,7 @@ from typing import Any
import requests
API_URL = "https://openrouter.ai/api/v1/images"
API_URL = os.environ.get("OPENROUTER_API_URL", "https://openrouter.ai/api/v1/images")
DEFAULT_MODEL = os.environ.get("MODEL", "meta/muse-image")
BACKGROUND_PRESETS = {
@ -26,7 +26,14 @@ EXTENSIONS = {
}
def _get_image_info(data_or_path: bytes | str) -> tuple[tuple[int, int] | None, str | None]:
def resolve_api_key(api_key: str | None = None) -> str | None:
"""Resolve OpenRouter API key from parameter or OPENROUTER_API_KEY environment variable."""
if api_key and api_key.strip():
return api_key.strip()
return os.environ.get("OPENROUTER_API_KEY")
def get_image_info(data_or_path: bytes | str) -> tuple[tuple[int, int] | None, str | None]:
"""Extract (width, height) dimensions and mime type from bytes, file path, or data URI."""
try:
from PIL import Image
@ -34,7 +41,6 @@ def _get_image_info(data_or_path: bytes | str) -> tuple[tuple[int, int] | None,
if isinstance(data_or_path, bytes):
raw_bytes = data_or_path
elif isinstance(data_or_path, str) and data_or_path.startswith("data:"):
# Parse data URI: data:<mime>;base64,<payload>
comma_idx = data_or_path.find(",")
if comma_idx != -1:
raw_bytes = base64.b64decode(data_or_path[comma_idx + 1:])
@ -50,6 +56,9 @@ def _get_image_info(data_or_path: bytes | str) -> tuple[tuple[int, int] | None,
pass
return None, None
_get_image_info = get_image_info
def build_prompt(bg_choice: str) -> str:
bg_description = BACKGROUND_PRESETS.get(bg_choice.lower(), bg_choice)
return (
@ -132,6 +141,22 @@ class CleanResult:
return self.model_dimensions != self.original_dimensions
return False
def to_payload(self) -> dict[str, Any]:
"""Serialize result to a dictionary suitable for API responses."""
return {
"image": encode_image_bytes(self.image_bytes, mime=self.media_type),
"cost": self.cost,
"width": self.width,
"height": self.height,
"original_dimensions": list(self.original_dimensions)
if self.original_dimensions
else None,
"model_dimensions": list(self.model_dimensions)
if self.model_dimensions
else None,
"was_rescaled": self.was_rescaled,
"media_type": self.media_type,
}
def call_openrouter_images(
image_url_or_data_uri: str,

View file

@ -13,12 +13,14 @@ A fast, single-user, locally hosted web app (`app.py` + vanilla HTML/CSS/JS in `
## Decisions so far
<!-- the index: one line per closed ticket, enough to judge relevance, then zoom the link for the detail the ticket holds -->
- [Decouple core photo cleaning logic for CLI and API reuse](https://code.tilltheend.de/tilltheend/unwrap-clothes/issues/2): Extracted encoding, OpenRouter API client, and in-memory Lanczos resolution restoration into `cleaner.py`, supporting both raw bytes and paths for CLI and web API reuse.
- [Implement FastAPI backend and /api/clean endpoint in app.py](https://code.tilltheend.de/tilltheend/unwrap-clothes/issues/3): Built FastAPI service in `app.py` exposing `POST /api/clean` with multipart upload, base64 data URI response, Lanczos toggle, and static file hosting.
- [Build single-page web UI in static/index.html](https://code.tilltheend.de/tilltheend/unwrap-clothes/issues/4): Created vanilla single-page UI in `static/index.html` with drag-and-drop upload, preset selectors, elapsed timer, side-by-side comparison, and clean image download.
- [End-to-end verification and documentation update](https://code.tilltheend.de/tilltheend/unwrap-clothes/issues/5): Added end-to-end integration test suite in `tests/test_e2e.py` and expanded `README.md` covering Web UI, CLI, architecture, and testing.
## Not yet specified
<!-- see "Fog of war": in-scope fog you can't ticket yet; graduates as the frontier advances -->
- Error handling and recovery UI (rate limit 429, invalid API key 401, timeout, or moderation errors displayed gracefully in the web UI)
- Batch upload or multi-image queue (if single-image workflow demands scaling)
## Out of scope

View file

@ -1,2 +1,5 @@
requests>=2.28.0
Pillow>=9.0.0
fastapi>=0.100.0
uvicorn>=0.20.0
python-multipart>=0.0.6

891
static/index.html Normal file
View file

@ -0,0 +1,891 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>unwrap-clothes | Garment Photo Cleaner</title>
<style>
:root {
--bg-base: #0a0d14;
--bg-surface: #121722;
--bg-surface-elevated: #182030;
--bg-input: #0e131d;
--border-subtle: #202b3f;
--border-focus: #3b82f6;
--text-main: #f1f5f9;
--text-muted: #94a3b8;
--text-dim: #64748b;
--accent-primary: #2563eb;
--accent-primary-hover: #1d4ed8;
--accent-success: #059669;
--accent-success-hover: #047857;
--accent-preset-active: #1e3a8a;
--danger-bg: #450a0a;
--danger-border: #7f1d1d;
--danger-text: #fecaca;
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background-color: var(--bg-base);
color: var(--text-main);
font-family: var(--font-sans);
line-height: 1.5;
min-height: 100vh;
padding: 2rem 1rem;
}
.container {
max-width: 1120px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1.75rem;
}
header {
display: flex;
flex-direction: column;
gap: 0.25rem;
border-bottom: 1px solid var(--border-subtle);
padding-bottom: 1.25rem;
}
.brand-title {
font-size: 1.75rem;
font-weight: 700;
letter-spacing: -0.025em;
color: #ffffff;
display: flex;
align-items: center;
gap: 0.5rem;
}
.brand-tag {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
padding: 0.15rem 0.5rem;
border-radius: var(--radius-sm);
background: #1e293b;
color: #93c5fd;
border: 1px solid #334155;
}
.brand-subtitle {
font-size: 0.95rem;
color: var(--text-muted);
}
.card {
background-color: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
padding: 1.5rem;
}
.grid-form {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
@media (max-width: 840px) {
.grid-form {
grid-template-columns: 1fr;
}
}
.section-title {
font-size: 1rem;
font-weight: 600;
margin-bottom: 0.75rem;
color: #e2e8f0;
display: flex;
align-items: center;
justify-content: space-between;
}
/* Drop Zone */
.drop-zone {
border: 2px dashed var(--border-subtle);
background: var(--bg-input);
border-radius: var(--radius-md);
padding: 2rem 1.25rem;
text-align: center;
cursor: pointer;
transition: all 0.2s ease-in-out;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 260px;
position: relative;
}
.drop-zone:hover,
.drop-zone.dragover {
border-color: var(--border-focus);
background: #131c2d;
}
.drop-zone-icon {
font-size: 2.25rem;
margin-bottom: 0.75rem;
color: var(--text-dim);
}
.drop-zone-prompt h4 {
font-size: 1rem;
font-weight: 600;
color: var(--text-main);
margin-bottom: 0.25rem;
}
.drop-zone-prompt p {
font-size: 0.85rem;
color: var(--text-muted);
}
.file-input {
display: none;
}
.before-preview-thumb {
max-width: 100%;
max-height: 220px;
object-fit: contain;
border-radius: var(--radius-sm);
margin-bottom: 0.75rem;
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
}
.file-info {
font-size: 0.85rem;
color: #38bdf8;
font-family: var(--font-mono);
background: #0f172a;
padding: 0.35rem 0.75rem;
border-radius: var(--radius-sm);
border: 1px solid #1e293b;
margin-top: 0.5rem;
word-break: break-all;
}
/* Form Controls */
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
.form-group:last-child {
margin-bottom: 0;
}
label {
font-size: 0.85rem;
font-weight: 500;
color: var(--text-muted);
}
.preset-group {
display: flex;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.preset-btn {
flex: 1;
padding: 0.45rem 0.75rem;
font-size: 0.825rem;
font-weight: 500;
background: var(--bg-surface-elevated);
color: var(--text-muted);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
cursor: pointer;
transition: all 0.15s ease;
}
.preset-btn:hover {
border-color: #3b82f6;
color: #ffffff;
}
.preset-btn.active {
background: var(--accent-preset-active);
border-color: #3b82f6;
color: #93c5fd;
font-weight: 600;
}
textarea, input[type="text"], input[type="password"] {
width: 100%;
background: var(--bg-input);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
color: var(--text-main);
padding: 0.65rem 0.85rem;
font-size: 0.9rem;
font-family: inherit;
outline: none;
transition: border-color 0.2s;
}
textarea:focus, input[type="text"]:focus, input[type="password"]:focus {
border-color: var(--border-focus);
}
textarea {
resize: vertical;
min-height: 120px;
line-height: 1.45;
}
.checkbox-group {
display: flex;
align-items: center;
gap: 0.6rem;
cursor: pointer;
user-select: none;
margin-top: 0.25rem;
}
.checkbox-group input[type="checkbox"] {
width: 1.1rem;
height: 1.1rem;
accent-color: var(--accent-primary);
cursor: pointer;
}
.checkbox-label {
font-size: 0.875rem;
color: var(--text-main);
}
.checkbox-hint {
font-size: 0.75rem;
color: var(--text-dim);
margin-left: 1.7rem;
}
/* Actions & Feedback */
.action-panel {
display: flex;
flex-direction: column;
gap: 1rem;
margin-top: 0.5rem;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.8rem 1.5rem;
font-size: 0.95rem;
font-weight: 600;
border-radius: var(--radius-sm);
border: none;
cursor: pointer;
transition: all 0.15s ease;
}
.btn-primary {
background: var(--accent-primary);
color: #ffffff;
width: 100%;
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-primary-hover);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-download {
background: var(--accent-success);
color: #ffffff;
}
.btn-download:hover {
background: var(--accent-success-hover);
}
.error-banner {
background-color: var(--danger-bg);
border: 1px solid var(--danger-border);
color: var(--danger-text);
padding: 0.85rem 1.15rem;
border-radius: var(--radius-sm);
font-size: 0.9rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 1.5rem;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
gap: 0.75rem;
}
.spinner {
width: 28px;
height: 28px;
border: 3px solid #1e293b;
border-top-color: #38bdf8;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-text {
font-size: 0.9rem;
color: #93c5fd;
font-weight: 500;
}
.elapsed-timer {
font-family: var(--font-mono);
font-size: 0.8rem;
color: var(--text-dim);
}
/* Results section */
.results-container {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.metrics-bar {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
justify-content: space-between;
background: var(--bg-input);
padding: 0.75rem 1rem;
border-radius: var(--radius-md);
border: 1px solid var(--border-subtle);
}
.metrics-left {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
}
.metric-badge {
font-family: var(--font-mono);
font-size: 0.825rem;
padding: 0.25rem 0.6rem;
border-radius: var(--radius-sm);
background: #1e293b;
color: #e2e8f0;
border: 1px solid #334155;
}
.comparison-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
@media (max-width: 768px) {
.comparison-grid {
grid-template-columns: 1fr;
}
}
.comparison-card {
background: var(--bg-surface-elevated);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
overflow: hidden;
display: flex;
flex-direction: column;
}
.comparison-header {
padding: 0.65rem 1rem;
background: #0f1420;
border-bottom: 1px solid var(--border-subtle);
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
display: flex;
justify-content: space-between;
align-items: center;
}
.comparison-header .tag {
font-size: 0.7rem;
padding: 0.1rem 0.4rem;
border-radius: 4px;
background: #1e293b;
color: #94a3b8;
}
.comparison-view {
padding: 1rem;
min-height: 380px;
display: flex;
align-items: center;
justify-content: center;
background: radial-gradient(circle, #1a2233 10%, #0e131d 90%);
}
.comparison-img {
max-width: 100%;
max-height: 480px;
object-fit: contain;
border-radius: var(--radius-sm);
box-shadow: 0 4px 20px rgba(0,0,0,0.6);
}
</style>
</head>
<body>
<div class="container">
<header>
<div class="brand-title">
<span>unwrap-clothes</span>
<span class="brand-tag">Studio v1.0</span>
</div>
<p class="brand-subtitle">Wrinkle-free garment photo cleaning and catalog backdrop standardization</p>
</header>
<form id="clean-form">
<div class="grid-form">
<!-- Upload Card -->
<div class="card">
<div class="section-title">
<span>Garment Photo</span>
</div>
<div id="drop-zone" class="drop-zone">
<input
type="file"
id="file-input"
name="image"
class="file-input"
accept="image/png,image/jpeg,image/webp"
/>
<img
id="before-preview"
class="before-preview-thumb"
alt="Selected garment thumbnail"
style="display: none;"
/>
<div class="drop-zone-prompt">
<div class="drop-zone-icon">📷</div>
<h4>Drag &amp; drop garment photo here</h4>
<p>or click to browse from your computer (PNG, JPEG, WebP)</p>
</div>
<div id="file-info" class="file-info" style="display: none;"></div>
</div>
</div>
<!-- Prompt & Options Card -->
<div class="card">
<div class="section-title">
<span>Editing Prompt &amp; Model Options</span>
</div>
<div class="form-group">
<label>Backdrop Presets</label>
<div class="preset-group">
<button type="button" id="preset-beige" class="preset-btn active" data-preset="beige">
Warm beige
</button>
<button type="button" id="preset-white" class="preset-btn" data-preset="white">
Bright white
</button>
</div>
</div>
<div class="form-group">
<label for="prompt">Prompt</label>
<textarea id="prompt" name="prompt" rows="5">Product photography edit of this garment photo. Keep the garments pixel-faithful: identical shape, colors, prints, labels, buttons, and every existing flaw unchanged. Remove ALL wrinkles and creases completely: the fabric must look perfectly smooth and freshly ironed, flat like a new catalog product photo. Soft warm beige studio background, evenly lit, no shadows, no props.</textarea>
</div>
<div class="form-group">
<label for="model">Model</label>
<input type="text" id="model" name="model" value="meta/muse-image" />
</div>
<div class="form-group">
<label for="api-key">OpenRouter API Key (Optional)</label>
<input
type="password"
id="api-key"
name="api_key"
placeholder="Using server OPENROUTER_API_KEY if unset"
/>
</div>
<div class="form-group">
<label class="checkbox-group">
<input type="checkbox" id="restore-resolution" name="restore_resolution" checked />
<span class="checkbox-label">Restore original resolution</span>
</label>
<span class="checkbox-hint">Upscales Muse output back to original dimensions using Pillow Lanczos resampling</span>
</div>
</div>
</div>
<div class="action-panel">
<button type="submit" id="submit-btn" class="btn btn-primary">
Clean Garment
</button>
<div id="error-banner" class="error-banner" style="display: none;"></div>
<div id="loading-container" class="loading-container" style="display: none;">
<div class="spinner"></div>
<div class="loading-text">Editing with Muse, usually takes ~15-20s...</div>
<div id="elapsed-timer" class="elapsed-timer">0.0s</div>
</div>
</div>
</form>
<!-- Results Container -->
<div id="results-container" class="results-container" style="display: none;">
<div class="card">
<div class="section-title">
<span>Cleaned Garment Comparison</span>
<button type="button" id="download-btn" class="btn btn-download">
Download Clean Image
</button>
</div>
<div class="metrics-bar">
<div class="metrics-left">
<div id="cost-display" class="metric-badge cost-display">Cost: -</div>
<div id="resolution-display" class="metric-badge resolution-display">Resolution: -</div>
</div>
</div>
<div class="comparison-grid" style="margin-top: 1rem;">
<div class="comparison-card">
<div class="comparison-header">
<span>Original Photo</span>
<span class="tag">Before</span>
</div>
<div class="comparison-view">
<img id="before-image" class="comparison-img" alt="Original garment photo" />
</div>
</div>
<div class="comparison-card">
<div class="comparison-header">
<span>Cleaned Catalog Edit</span>
<span class="tag" style="background:#065f46;color:#a7f3d0;">After</span>
</div>
<div class="comparison-view">
<img id="after-image" class="comparison-img" alt="Cleaned catalog garment" />
</div>
</div>
</div>
</div>
</div>
</div>
<script>
(function () {
const BACKGROUND_PRESETS = {
beige: "Soft warm beige studio background, evenly lit, no shadows, no props.",
white: "Plain bright white studio backdrop, evenly lit, no shadows, no gradients, no props."
};
function buildPrompt(bgChoice) {
const bgDesc = BACKGROUND_PRESETS[bgChoice.toLowerCase()] || bgChoice;
return (
"Product photography edit of this garment photo. Keep the garments pixel-faithful: identical " +
"shape, colors, prints, labels, buttons, and every existing flaw unchanged. Remove ALL wrinkles " +
"and creases completely: the fabric must look perfectly smooth and freshly ironed, flat like a " +
"new catalog product photo. " +
bgDesc
);
}
// Elements
const cleanForm = document.getElementById("clean-form");
const dropZone = document.getElementById("drop-zone");
const fileInput = document.getElementById("file-input");
const beforePreview = document.getElementById("before-preview");
const fileInfo = document.getElementById("file-info");
const promptTextarea = document.getElementById("prompt");
const presetBeige = document.getElementById("preset-beige");
const presetWhite = document.getElementById("preset-white");
const modelInput = document.getElementById("model");
const apiKeyInput = document.getElementById("api-key");
const restoreResolutionCheckbox = document.getElementById("restore-resolution");
const submitBtn = document.getElementById("submit-btn");
const errorBanner = document.getElementById("error-banner");
const loadingContainer = document.getElementById("loading-container");
const elapsedTimer = document.getElementById("elapsed-timer");
const resultsContainer = document.getElementById("results-container");
const beforeImage = document.getElementById("before-image");
const afterImage = document.getElementById("after-image");
const costDisplay = document.getElementById("cost-display");
const resolutionDisplay = document.getElementById("resolution-display");
const downloadBtn = document.getElementById("download-btn");
let selectedFile = null;
let originalFileName = "";
let originalDimensions = null;
let cleanResultData = null;
let timerInterval = null;
let startTime = 0;
function showError(message) {
errorBanner.textContent = message;
errorBanner.style.display = "flex";
}
function hideError() {
errorBanner.textContent = "";
errorBanner.style.display = "none";
}
function startTimer() {
startTime = Date.now();
elapsedTimer.textContent = "0.0s";
timerInterval = setInterval(() => {
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
elapsedTimer.textContent = `${elapsed}s`;
}, 100);
}
function stopTimer() {
if (timerInterval) {
clearInterval(timerInterval);
timerInterval = null;
}
}
function formatBytes(bytes) {
if (!bytes) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
}
// Presets
presetBeige.addEventListener("click", () => {
promptTextarea.value = buildPrompt("beige");
presetBeige.classList.add("active");
presetWhite.classList.remove("active");
});
presetWhite.addEventListener("click", () => {
promptTextarea.value = buildPrompt("white");
presetWhite.classList.add("active");
presetBeige.classList.remove("active");
});
// Drag & Drop
["dragenter", "dragover"].forEach((eventName) => {
dropZone.addEventListener(eventName, (e) => {
e.preventDefault();
e.stopPropagation();
dropZone.classList.add("dragover");
});
});
["dragleave", "drop"].forEach((eventName) => {
dropZone.addEventListener(eventName, (e) => {
e.preventDefault();
e.stopPropagation();
dropZone.classList.remove("dragover");
});
});
dropZone.addEventListener("drop", (e) => {
const dt = e.dataTransfer;
const files = dt.files;
if (files && files.length > 0) {
handleFile(files[0]);
}
});
dropZone.addEventListener("click", (e) => {
if (e.target !== fileInput) {
fileInput.click();
}
});
fileInput.addEventListener("change", () => {
if (fileInput.files && fileInput.files.length > 0) {
handleFile(fileInput.files[0]);
}
});
function handleFile(file) {
if (!file.type.startsWith("image/")) {
showError("Please select a valid image file (PNG, JPEG, or WebP).");
return;
}
selectedFile = file;
originalFileName = file.name;
hideError();
const reader = new FileReader();
reader.onload = (event) => {
const dataUrl = event.target.result;
const img = new Image();
img.onload = () => {
originalDimensions = { width: img.naturalWidth, height: img.naturalHeight };
beforePreview.src = dataUrl;
beforePreview.style.display = "block";
beforeImage.src = dataUrl;
fileInfo.textContent = `${file.name} — ${img.naturalWidth} × ${img.naturalHeight} px (${formatBytes(file.size)})`;
fileInfo.style.display = "block";
const promptEl = dropZone.querySelector(".drop-zone-prompt");
if (promptEl) {
promptEl.style.display = "none";
}
};
img.src = dataUrl;
};
reader.readAsDataURL(file);
}
// Form Submit
cleanForm.addEventListener("submit", async (e) => {
e.preventDefault();
if (!selectedFile) {
showError("Please select or drop a garment photo first.");
return;
}
hideError();
submitBtn.disabled = true;
submitBtn.textContent = "Processing with Muse...";
loadingContainer.style.display = "flex";
startTimer();
try {
const formData = new FormData();
formData.append("file", selectedFile);
formData.append("image", selectedFile);
formData.append("prompt", promptTextarea.value);
formData.append("model", modelInput.value.trim() || "meta/muse-image");
const apiKey = apiKeyInput.value.trim();
if (apiKey) {
formData.append("api_key", apiKey);
}
const shouldRestore = restoreResolutionCheckbox.checked ? "true" : "false";
formData.append("restore_res", shouldRestore);
formData.append("restore_resolution", shouldRestore);
const response = await fetch("/api/clean", {
method: "POST",
body: formData,
});
if (!response.ok) {
let errorMsg = `Server error (${response.status})`;
try {
const errJson = await response.json();
if (typeof errJson.detail === "string") {
errorMsg = errJson.detail;
} else if (Array.isArray(errJson.detail)) {
errorMsg = errJson.detail.map((d) => d.msg || JSON.stringify(d)).join("; ");
} else if (errJson.message) {
errorMsg = errJson.message;
}
} catch (_) {
const text = await response.text();
if (text) errorMsg = text;
}
throw new Error(errorMsg);
}
const data = await response.json();
cleanResultData = data;
displayResults(data);
} catch (err) {
showError(err.message || "Failed to clean garment image.");
} finally {
stopTimer();
loadingContainer.style.display = "none";
submitBtn.disabled = false;
submitBtn.textContent = "Clean Garment";
}
});
function displayResults(data) {
afterImage.src = data.image;
if (data.cost !== null && data.cost !== undefined) {
costDisplay.textContent = `Cost: $${Number(data.cost).toFixed(4)}`;
} else {
costDisplay.textContent = "Cost: Included / Free";
}
const origDimsStr = data.original_dimensions
? `${data.original_dimensions[0]}×${data.original_dimensions[1]}`
: originalDimensions
? `${originalDimensions.width}×${originalDimensions.height}`
: "N/A";
const outDimsStr = `${data.width}×${data.height}`;
const rescaledNote = data.was_rescaled ? " (Restored via Lanczos)" : "";
resolutionDisplay.textContent = `Resolution: ${origDimsStr} → ${outDimsStr}${rescaledNote}`;
resultsContainer.style.display = "flex";
resultsContainer.scrollIntoView({ behavior: "smooth" });
}
// Download
downloadBtn.addEventListener("click", () => {
if (!cleanResultData || !cleanResultData.image) return;
const baseName = (originalFileName || "garment").replace(/\.[^/.]+$/, "");
const ext = cleanResultData.image.startsWith("data:image/png") ? ".png" : ".jpg";
const downloadName = `${baseName}_clean${ext}`;
const link = document.createElement("a");
link.href = cleanResultData.image;
link.download = downloadName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
});
})();
</script>
</body>
</html>

217
tests/test_app.py Normal file
View file

@ -0,0 +1,217 @@
"""Tests for FastAPI backend in app.py."""
import base64
import io
from unittest.mock import patch
from fastapi.testclient import TestClient
from PIL import Image
from cleaner import CleanerError, CleanResult
def test_health_check():
"""GET /api/health returns 200 OK with status ok."""
from app import app
client = TestClient(app)
response = client.get("/api/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
def test_root_fallback_when_no_index_html(monkeypatch, tmp_path):
"""GET / returns a graceful fallback when static/index.html does not exist."""
import app as app_module
# Point STATIC_DIR to a temporary directory without index.html
monkeypatch.setattr(app_module, "STATIC_DIR", tmp_path)
client = TestClient(app_module.app)
response = client.get("/")
assert response.status_code == 200
assert response.json()["status"] == "ok"
def test_root_serves_index_html_when_present(monkeypatch, tmp_path):
"""GET / serves static/index.html when it exists."""
import app as app_module
index_file = tmp_path / "index.html"
index_file.write_text("<!DOCTYPE html><html><body>Unwrap Clothes</body></html>")
monkeypatch.setattr(app_module, "STATIC_DIR", tmp_path)
client = TestClient(app_module.app)
response = client.get("/")
assert response.status_code == 200
assert "Unwrap Clothes" in response.text
assert "text/html" in response.headers.get("content-type", "")
def _create_test_image_bytes() -> bytes:
buf = io.BytesIO()
Image.new("RGB", (50, 50), color="blue").save(buf, format="JPEG")
return buf.getvalue()
def test_clean_missing_api_key_returns_400(monkeypatch):
"""POST /api/clean without API key when OPENROUTER_API_KEY is unset returns 400."""
from app import app
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
client = TestClient(app)
image_bytes = _create_test_image_bytes()
files = {"file": ("test.jpg", image_bytes, "image/jpeg")}
response = client.post("/api/clean", files=files)
assert response.status_code == 400
assert "API key" in response.json()["detail"]
def test_clean_uses_env_api_key_when_form_omitted(monkeypatch):
"""POST /api/clean uses OPENROUTER_API_KEY from environment if api_key form field is omitted."""
from app import app
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-v1-env-key")
client = TestClient(app)
image_bytes = _create_test_image_bytes()
files = {"file": ("test.jpg", image_bytes, "image/jpeg")}
mock_result = CleanResult(
image_bytes=image_bytes,
media_type="image/jpeg",
cost=0.005,
width=50,
height=50,
original_dimensions=(50, 50),
model_dimensions=(50, 50),
)
with patch("app.clean_garment", return_value=mock_result) as mock_clean:
response = client.post("/api/clean", files=files)
assert response.status_code == 200
mock_clean.assert_called_once()
_, kwargs = mock_clean.call_args
assert kwargs["api_key"] == "sk-or-v1-env-key"
def test_clean_empty_file_returns_400(monkeypatch):
"""POST /api/clean with empty file returns 400 Bad Request."""
from app import app
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-v1-env-key")
client = TestClient(app)
files = {"file": ("empty.jpg", b"", "image/jpeg")}
response = client.post("/api/clean", files=files)
assert response.status_code == 400
assert "empty" in response.json()["detail"].lower()
def test_clean_invalid_image_file_returns_400(monkeypatch):
"""POST /api/clean with invalid image content returns 400 Bad Request."""
from app import app
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-v1-env-key")
client = TestClient(app)
files = {"file": ("corrupt.jpg", b"not-a-valid-image-content", "image/jpeg")}
response = client.post("/api/clean", files=files)
assert response.status_code == 400
assert "invalid" in response.json()["detail"].lower()
def test_clean_cleaner_error_returns_502(monkeypatch):
"""POST /api/clean when clean_garment raises CleanerError returns 502 Bad Gateway."""
from app import app
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-v1-env-key")
client = TestClient(app)
image_bytes = _create_test_image_bytes()
files = {"file": ("test.jpg", image_bytes, "image/jpeg")}
with patch("app.clean_garment", side_effect=CleanerError("OpenRouter upstream failure")):
response = client.post("/api/clean", files=files)
assert response.status_code == 502
assert "OpenRouter upstream failure" in response.json()["detail"]
def test_clean_success_happy_path():
"""POST /api/clean with valid inputs returns full JSON result with image data URI."""
from app import app
client = TestClient(app)
image_bytes = _create_test_image_bytes()
output_bytes = b"cleaned-image-data-bytes"
mock_result = CleanResult(
image_bytes=output_bytes,
media_type="image/png",
cost=0.0125,
width=1000,
height=1500,
original_dimensions=(1000, 1500),
model_dimensions=(800, 1200),
)
files = {"file": ("garment.jpg", image_bytes, "image/jpeg")}
data = {
"prompt": "Custom studio prompt with white background.",
"model": "custom/test-model",
"api_key": "sk-or-v1-custom-form-key",
"restore_res": "true",
}
with patch("app.clean_garment", return_value=mock_result) as mock_clean:
response = client.post("/api/clean", files=files, data=data)
assert response.status_code == 200
payload = response.json()
# Assert response keys and values
expected_data_uri = f"data:image/png;base64,{base64.b64encode(output_bytes).decode()}"
assert payload["image"] == expected_data_uri
assert payload["cost"] == 0.0125
assert payload["width"] == 1000
assert payload["height"] == 1500
assert payload["original_dimensions"] == [1000, 1500]
assert payload["model_dimensions"] == [800, 1200]
assert payload["was_rescaled"] is True
assert payload["media_type"] == "image/png"
# Assert arguments passed to clean_garment
mock_clean.assert_called_once_with(
image_data=image_bytes,
prompt="Custom studio prompt with white background.",
api_key="sk-or-v1-custom-form-key",
model="custom/test-model",
restore_res=True,
)
def test_clean_default_parameters():
"""POST /api/clean uses default prompt, model, and restore_res if omitted in form."""
from app import app, DEFAULT_PROMPT
from cleaner import DEFAULT_MODEL
client = TestClient(app)
image_bytes = _create_test_image_bytes()
mock_result = CleanResult(
image_bytes=image_bytes,
media_type="image/jpeg",
cost=None,
width=50,
height=50,
original_dimensions=(50, 50),
model_dimensions=(50, 50),
)
files = {"file": ("garment.jpg", image_bytes, "image/jpeg")}
data = {"api_key": "sk-or-v1-key"}
with patch("app.clean_garment", return_value=mock_result) as mock_clean:
response = client.post("/api/clean", files=files, data=data)
assert response.status_code == 200
mock_clean.assert_called_once_with(
image_data=image_bytes,
prompt=DEFAULT_PROMPT,
api_key="sk-or-v1-key",
model=DEFAULT_MODEL,
restore_res=True,
)

View file

@ -285,3 +285,35 @@ def test_clean_garment_file(tmp_path, monkeypatch):
assert res.width == 250
assert res.height == 350
assert res.cost == 0.03
def test_resolve_api_key(monkeypatch):
import cleaner
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
assert cleaner.resolve_api_key(None) is None
assert cleaner.resolve_api_key(" ") is None
assert cleaner.resolve_api_key("custom-key") == "custom-key"
monkeypatch.setenv("OPENROUTER_API_KEY", "env-key")
assert cleaner.resolve_api_key(None) == "env-key"
assert cleaner.resolve_api_key("custom-key") == "custom-key"
def test_clean_result_to_payload():
raw = b"img-data"
res = CleanResult(
image_bytes=raw,
media_type="image/jpeg",
cost=0.015,
width=100,
height=200,
original_dimensions=(100, 200),
model_dimensions=(50, 100),
)
payload = res.to_payload()
assert payload["cost"] == 0.015
assert payload["width"] == 100
assert payload["height"] == 200
assert payload["original_dimensions"] == [100, 200]
assert payload["model_dimensions"] == [50, 100]
assert payload["was_rescaled"] is True
assert payload["media_type"] == "image/jpeg"
assert payload["image"].startswith("data:image/jpeg;base64,")

173
tests/test_e2e.py Normal file
View file

@ -0,0 +1,173 @@
"""End-to-end verification tests for unwrap-clothes."""
import http.server
import json
import os
from pathlib import Path
import subprocess
import sys
import threading
import base64
import io
from unittest.mock import patch
from fastapi.testclient import TestClient
from PIL import Image
from app import app
def _create_synthetic_image(width: int, height: int, color: str = "red") -> bytes:
"""Helper to generate a JPEG image with exact dimensions."""
buf = io.BytesIO()
img = Image.new("RGB", (width, height), color=color)
img.save(buf, format="JPEG")
return buf.getvalue()
def test_e2e_root_serves_html_with_title_and_dropzone():
"""GET / serves HTML containing web application title and dropzone."""
client = TestClient(app)
response = client.get("/")
assert response.status_code == 200
assert "text/html" in response.headers.get("content-type", "")
html_lower = response.text.lower()
# Verify web application title
assert "unwrap-clothes" in html_lower
# Verify dropzone element exists in HTML
assert "drop-zone" in html_lower or "dropzone" in html_lower
import pytest
@pytest.mark.parametrize(
"orig_dims,model_dims,prompt,cost,color",
[
((1200, 1600), (600, 800), "Soft warm beige studio background, evenly lit, no shadows, no props.", 0.0125, "navy"),
((1500, 2000), (750, 1000), "Minimalist bright studio setting with directional softbox lighting.", 0.015, "darkgreen"),
],
)
def test_e2e_api_clean_end_to_end(orig_dims, model_dims, prompt, cost, color):
"""Test FastAPI POST /api/clean end-to-end with high-res images, Lanczos restoration, and custom prompts."""
client = TestClient(app)
orig_width, orig_height = orig_dims
model_width, model_height = model_dims
high_res_bytes = _create_synthetic_image(orig_width, orig_height, color=color)
low_res_bytes = _create_synthetic_image(model_width, model_height, color="beige")
b64_low_res = base64.b64encode(low_res_bytes).decode()
mock_openrouter_response = {
"data": [{"b64_json": b64_low_res, "media_type": "image/jpeg"}],
"usage": {"cost": cost},
}
class MockResponse:
status_code = 200
def json(self):
return mock_openrouter_response
files = {"file": ("garment.jpg", high_res_bytes, "image/jpeg")}
form_data = {
"prompt": prompt,
"api_key": "sk-or-v1-fake-e2e-key",
"restore_res": "true",
}
with patch("cleaner.requests.post", return_value=MockResponse()) as mock_post:
response = client.post("/api/clean", files=files, data=form_data)
assert response.status_code == 200
mock_post.assert_called_once()
payload = response.json()
assert payload["was_rescaled"] is True
assert payload["original_dimensions"] == [orig_width, orig_height]
assert payload["model_dimensions"] == [model_width, model_height]
assert payload["width"] == orig_width
assert payload["height"] == orig_height
assert payload["cost"] == cost
encoded_data = payload["image"].split(",", 1)[1]
decoded_bytes = base64.b64decode(encoded_data)
with Image.open(io.BytesIO(decoded_bytes)) as result_img:
assert result_img.size == (orig_width, orig_height)
def test_e2e_cli_subprocess_end_to_end(tmp_path):
"""Test CLI unwrap_clothes.py end-to-end via subprocess with mocked OpenRouter."""
orig_width, orig_height = 1200, 1600
model_width, model_height = 600, 800
# Create source image in temporary directory
src_image_path = tmp_path / "garment.jpg"
src_bytes = _create_synthetic_image(orig_width, orig_height, color="purple")
src_image_path.write_bytes(src_bytes)
# Expected output path: <image_path>_clean.jpg
expected_clean_path = tmp_path / "garment_clean.jpg"
# Create mock lower-resolution image
low_res_bytes = _create_synthetic_image(model_width, model_height, color="white")
b64_low_res = base64.b64encode(low_res_bytes).decode()
mock_payload = {
"data": [{"b64_json": b64_low_res, "media_type": "image/jpeg"}],
"usage": {"cost": 0.02},
}
class MockOpenRouterHandler(http.server.BaseHTTPRequestHandler):
def do_POST(self):
length = int(self.headers.get("Content-Length", 0))
_ = self.rfile.read(length)
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(mock_payload).encode())
def log_message(self, format, *args):
pass
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), MockOpenRouterHandler)
server_thread = threading.Thread(target=server.serve_forever, daemon=True)
server_thread.start()
repo_root = Path(__file__).resolve().parent.parent
env = {
**os.environ,
"OPENROUTER_API_URL": f"http://127.0.0.1:{server.server_port}/api/v1/images",
}
try:
cmd = [
sys.executable,
"unwrap_clothes.py",
str(src_image_path),
"--bg",
"white",
"--api-key",
"fake-key",
]
proc = subprocess.run(
cmd,
cwd=str(repo_root),
env=env,
capture_output=True,
text=True,
timeout=15,
)
# Verify exit code 0
assert proc.returncode == 0, f"CLI failed with stderr: {proc.stderr}\nstdout: {proc.stdout}"
assert "Restored resolution" in proc.stdout
assert "cost $0.02" in proc.stdout
assert "Saved:" in proc.stdout
assert expected_clean_path.is_file(), f"Output file {expected_clean_path} was not created"
with Image.open(expected_clean_path) as out_img:
assert out_img.size == (orig_width, orig_height)
finally:
server.shutdown()
server.server_close()

155
tests/test_ui.py Normal file
View file

@ -0,0 +1,155 @@
"""Tests for single-page web UI in static/index.html."""
from bs4 import BeautifulSoup
from fastapi.testclient import TestClient
from app import app
from cleaner import BACKGROUND_PRESETS, DEFAULT_MODEL, build_prompt
def test_get_root_serves_html():
"""GET / returns 200 OK and text/html."""
client = TestClient(app)
response = client.get("/")
assert response.status_code == 200
assert "text/html" in response.headers.get("content-type", "")
def test_get_static_index_html_serves_html():
"""GET /static/index.html returns 200 OK and text/html."""
client = TestClient(app)
response = client.get("/static/index.html")
assert response.status_code == 200
assert "text/html" in response.headers.get("content-type", "")
def test_html_contains_upload_elements():
"""HTML contains file input, drop zone, and preview image element."""
client = TestClient(app)
response = client.get("/")
soup = BeautifulSoup(response.text, "html.parser")
file_input = soup.find("input", {"type": "file"})
assert file_input is not None, "File input element missing"
assert file_input.get("id") or file_input.get("name")
drop_zone = soup.find(id="drop-zone") or soup.find(class_="drop-zone")
assert drop_zone is not None, "Drop zone container missing"
# Preview elements for original image, name, and dimensions
before_preview = soup.find(id="before-image") or soup.find(id="preview-img") or soup.find(id="before-preview")
assert before_preview is not None, "Before preview image element missing"
file_info = soup.find(id="file-info") or soup.find(class_="file-info")
assert file_info is not None, "File info container for name and dimensions missing"
def test_html_contains_prompt_controls():
"""HTML contains textarea prefilled with beige prompt and preset buttons."""
client = TestClient(app)
response = client.get("/")
soup = BeautifulSoup(response.text, "html.parser")
prompt_textarea = soup.find("textarea")
assert prompt_textarea is not None, "Prompt textarea missing"
expected_default = build_prompt("beige")
assert expected_default in prompt_textarea.text or expected_default in prompt_textarea.get("value", "")
# Preset buttons
preset_beige = soup.find(id="preset-beige") or soup.find("button", {"data-preset": "beige"})
assert preset_beige is not None, "Warm beige preset button missing"
preset_white = soup.find(id="preset-white") or soup.find("button", {"data-preset": "white"})
assert preset_white is not None, "Bright white preset button missing"
def test_html_contains_model_and_option_inputs():
"""HTML contains model input, API key input, and restore_resolution checkbox."""
client = TestClient(app)
response = client.get("/")
soup = BeautifulSoup(response.text, "html.parser")
model_input = soup.find("input", {"name": "model"}) or soup.find(id="model")
assert model_input is not None, "Model input missing"
assert model_input.get("value") == DEFAULT_MODEL or model_input.get("placeholder") == DEFAULT_MODEL
api_key_input = (
soup.find("input", {"type": "password", "name": "api_key"})
or soup.find("input", {"name": "api_key"})
or soup.find(id="api-key")
)
assert api_key_input is not None, "API key input missing"
placeholder = api_key_input.get("placeholder", "")
assert "OPENROUTER_API_KEY" in placeholder
restore_checkbox = (
soup.find("input", {"type": "checkbox", "name": "restore_resolution"})
or soup.find("input", {"type": "checkbox", "id": "restore-resolution"})
)
assert restore_checkbox is not None, "Restore resolution checkbox missing"
assert restore_checkbox.has_attr("checked"), "Restore resolution should be checked by default"
def test_html_contains_processing_and_error_elements():
"""HTML contains submit button, loading spinner/message with timer, and error banner."""
client = TestClient(app)
response = client.get("/")
soup = BeautifulSoup(response.text, "html.parser")
submit_btn = soup.find("button", {"type": "submit"}) or soup.find(id="submit-btn")
assert submit_btn is not None, "Submit button missing"
loading_container = soup.find(id="loading-container") or soup.find(id="loading") or soup.find(class_="loading")
assert loading_container is not None, "Loading container missing"
loading_text = loading_container.get_text()
assert "Editing with Muse, usually takes ~15-20s" in loading_text or "15-20s" in loading_text
timer = soup.find(id="elapsed-timer") or soup.find(class_="elapsed-timer")
assert timer is not None, "Elapsed timer display missing"
error_banner = soup.find(id="error-banner") or soup.find(class_="error-banner") or soup.find(id="error-container")
assert error_banner is not None, "Error banner missing"
def test_html_contains_results_and_comparison_elements():
"""HTML contains before/after comparison, metrics (cost, dimensions), and download button."""
client = TestClient(app)
response = client.get("/")
soup = BeautifulSoup(response.text, "html.parser")
results_container = soup.find(id="results-container") or soup.find(id="results") or soup.find(class_="results")
assert results_container is not None, "Results container missing"
after_image = soup.find(id="after-image") or soup.find(id="after-preview")
assert after_image is not None, "After image display element missing"
cost_display = soup.find(id="cost-display") or soup.find(class_="cost-display")
assert cost_display is not None, "Cost display missing"
res_display = soup.find(id="resolution-display") or soup.find(class_="resolution-display")
assert res_display is not None, "Resolution display missing"
download_btn = soup.find(id="download-btn") or soup.find(id="download-link")
assert download_btn is not None, "Download button missing"
def test_html_javascript_logic():
"""HTML script contains drag-drop, FileReader, presets, /api/clean call, and download handler."""
client = TestClient(app)
response = client.get("/")
html = response.text
# Script tag present
assert "<script" in html
# Drag and drop events
assert "dragover" in html
assert "drop" in html
# FileReader
assert "readAsDataURL" in html or "FileReader" in html
# API fetch
assert "/api/clean" in html
# Background presets
assert BACKGROUND_PRESETS["beige"] in html
assert BACKGROUND_PRESETS["white"] in html
# Clean download name suffix
assert "_clean." in html or "_clean" in html