unwrap-clothes/static/index.html

891 lines
25 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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