unwrap-clothes/static/index.html

893 lines
25 KiB
HTML
Raw Normal View History

<!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("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>