feat: build single-page web UI in static/index.html (#4)
This commit is contained in:
parent
8fd0f764f1
commit
505ded0493
2 changed files with 1047 additions and 0 deletions
892
static/index.html
Normal file
892
static/index.html
Normal file
|
|
@ -0,0 +1,892 @@
|
|||
<!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 & 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 & 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("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);
|
||||
}
|
||||
formData.append(
|
||||
"restore_resolution",
|
||||
restoreResolutionCheckbox.checked ? "true" : "false"
|
||||
);
|
||||
|
||||
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>
|
||||
155
tests/test_ui.py
Normal file
155
tests/test_ui.py
Normal 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
|
||||
Loading…
Add table
Reference in a new issue