893 lines
25 KiB
HTML
893 lines
25 KiB
HTML
|
|
<!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>
|