Spaces:
Sleeping
Sleeping
Move segmentation to cloud
Browse files- js/app.js +6 -1
- js/auto-segment.js +38 -24
- js/diag.js +64 -0
- js/dressup.js +317 -129
- js/manage.js +31 -33
- js/ml-client.js +110 -0
- js/ml-worker.js +36 -0
- js/models.js +13 -27
- js/segmentation.js +134 -82
- js/store.js +63 -34
- js/upload.js +162 -127
- style.css +98 -21
js/app.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
import { initUpload } from './upload.js';
|
| 2 |
import { initDressUp } from './dressup.js';
|
| 3 |
import { initManage } from './manage.js';
|
|
|
|
| 4 |
|
| 5 |
const screens = ['dressup-screen', 'upload-screen', 'manage-screen'];
|
| 6 |
|
|
@@ -12,6 +13,7 @@ function showScreen(id) {
|
|
| 12 |
|
| 13 |
function route() {
|
| 14 |
const hash = location.hash || '#/';
|
|
|
|
| 15 |
|
| 16 |
if (hash.startsWith('#/upload')) {
|
| 17 |
showScreen('upload-screen');
|
|
@@ -30,4 +32,7 @@ export function navigate(hash) {
|
|
| 30 |
}
|
| 31 |
|
| 32 |
window.addEventListener('hashchange', route);
|
| 33 |
-
window.addEventListener('DOMContentLoaded',
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import { initUpload } from './upload.js';
|
| 2 |
import { initDressUp } from './dressup.js';
|
| 3 |
import { initManage } from './manage.js';
|
| 4 |
+
import { initDiag, tagOp } from './diag.js';
|
| 5 |
|
| 6 |
const screens = ['dressup-screen', 'upload-screen', 'manage-screen'];
|
| 7 |
|
|
|
|
| 13 |
|
| 14 |
function route() {
|
| 15 |
const hash = location.hash || '#/';
|
| 16 |
+
tagOp(`route: ${hash}`);
|
| 17 |
|
| 18 |
if (hash.startsWith('#/upload')) {
|
| 19 |
showScreen('upload-screen');
|
|
|
|
| 32 |
}
|
| 33 |
|
| 34 |
window.addEventListener('hashchange', route);
|
| 35 |
+
window.addEventListener('DOMContentLoaded', () => {
|
| 36 |
+
initDiag();
|
| 37 |
+
route();
|
| 38 |
+
});
|
js/auto-segment.js
CHANGED
|
@@ -2,23 +2,15 @@
|
|
| 2 |
// Generates a grid of point prompts, runs mask decoder for each,
|
| 3 |
// filters and deduplicates to find distinct parts of a drawing.
|
| 4 |
|
|
|
|
|
|
|
| 5 |
const SAM_MODEL = 'Xenova/slimsam-77-uniform';
|
| 6 |
-
const SAM_DIM =
|
| 7 |
const GRID_SIZE = 6; // 6x6 = 36 points, filtered to non-transparent
|
| 8 |
const MIN_IOU_SCORE = 0.65;
|
| 9 |
const MIN_AREA_FRAC = 0.005; // minimum mask area as fraction of image
|
| 10 |
const NMS_IOU_THRESHOLD = 0.5;
|
| 11 |
|
| 12 |
-
let transformers = null;
|
| 13 |
-
|
| 14 |
-
async function getTransformers() {
|
| 15 |
-
if (transformers) return transformers;
|
| 16 |
-
transformers = await import(
|
| 17 |
-
'https://cdn.jsdelivr.net/npm/@huggingface/transformers@3'
|
| 18 |
-
);
|
| 19 |
-
return transformers;
|
| 20 |
-
}
|
| 21 |
-
|
| 22 |
/**
|
| 23 |
* Auto-segment an image into distinct parts using SlimSAM.
|
| 24 |
* @param {Blob} imageBlob - background-removed PNG
|
|
@@ -34,20 +26,42 @@ export async function autoSegment(imageBlob, onProgress) {
|
|
| 34 |
|
| 35 |
if (onProgress) onProgress({ message: `Loading segmentation model (${device})...`, progress: 0 });
|
| 36 |
|
| 37 |
-
const
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
-
if (onProgress) onProgress({ message:
|
| 51 |
|
| 52 |
const processor = await AutoProcessor.from_pretrained(SAM_MODEL);
|
| 53 |
|
|
|
|
| 2 |
// Generates a grid of point prompts, runs mask decoder for each,
|
| 3 |
// filters and deduplicates to find distinct parts of a drawing.
|
| 4 |
|
| 5 |
+
import { getTransformers } from './segmentation.js';
|
| 6 |
+
|
| 7 |
const SAM_MODEL = 'Xenova/slimsam-77-uniform';
|
| 8 |
+
const SAM_DIM = 384; // smaller input → quadratically less peak memory
|
| 9 |
const GRID_SIZE = 6; // 6x6 = 36 points, filtered to non-transparent
|
| 10 |
const MIN_IOU_SCORE = 0.65;
|
| 11 |
const MIN_AREA_FRAC = 0.005; // minimum mask area as fraction of image
|
| 12 |
const NMS_IOU_THRESHOLD = 0.5;
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
/**
|
| 15 |
* Auto-segment an image into distinct parts using SlimSAM.
|
| 16 |
* @param {Blob} imageBlob - background-removed PNG
|
|
|
|
| 26 |
|
| 27 |
if (onProgress) onProgress({ message: `Loading segmentation model (${device})...`, progress: 0 });
|
| 28 |
|
| 29 |
+
const dtypeProgress = (p) => {
|
| 30 |
+
if (onProgress && p.progress != null) {
|
| 31 |
+
onProgress({
|
| 32 |
+
message: `Downloading model: ${Math.round(p.progress)}%`,
|
| 33 |
+
progress: p.progress * 0.2, // 0-20% for download
|
| 34 |
+
});
|
| 35 |
+
}
|
| 36 |
+
};
|
| 37 |
+
|
| 38 |
+
// Try the smallest dtype first; fall back step-by-step if the model
|
| 39 |
+
// doesn't ship that variant or the runtime can't load it.
|
| 40 |
+
const dtypePreference = device === 'webgpu'
|
| 41 |
+
? ['fp16', 'q8', 'fp32']
|
| 42 |
+
: ['q8', 'fp16', 'fp32'];
|
| 43 |
+
|
| 44 |
+
let model = null;
|
| 45 |
+
let loadedDtype = null;
|
| 46 |
+
let lastErr = null;
|
| 47 |
+
for (const dtype of dtypePreference) {
|
| 48 |
+
try {
|
| 49 |
+
model = await SamModel.from_pretrained(SAM_MODEL, {
|
| 50 |
+
device,
|
| 51 |
+
dtype,
|
| 52 |
+
progress_callback: dtypeProgress,
|
| 53 |
+
});
|
| 54 |
+
loadedDtype = dtype;
|
| 55 |
+
console.log(`[auto-segment] loaded SAM with dtype=${dtype}, device=${device}`);
|
| 56 |
+
break;
|
| 57 |
+
} catch (err) {
|
| 58 |
+
console.warn(`[auto-segment] dtype=${dtype} failed:`, err && err.message);
|
| 59 |
+
lastErr = err;
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
if (!model) throw lastErr || new Error('Failed to load SAM');
|
| 63 |
|
| 64 |
+
if (onProgress) onProgress({ message: `sam: loaded ${loadedDtype}/${device}`, progress: 22 });
|
| 65 |
|
| 66 |
const processor = await AutoProcessor.from_pretrained(SAM_MODEL);
|
| 67 |
|
js/diag.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Tiny diagnostics: tracks page reloads and "last operation" across sessions.
|
| 2 |
+
// Works in both window and Web Worker contexts.
|
| 3 |
+
|
| 4 |
+
const isWorker = typeof window === 'undefined' && typeof self !== 'undefined';
|
| 5 |
+
|
| 6 |
+
export function tagOp(op) {
|
| 7 |
+
if (isWorker) {
|
| 8 |
+
// Forward to main thread; ml-client will set sessionStorage there.
|
| 9 |
+
try { self.postMessage({ __diag: true, op }); } catch (_) {}
|
| 10 |
+
return;
|
| 11 |
+
}
|
| 12 |
+
if (typeof sessionStorage === 'undefined') return;
|
| 13 |
+
sessionStorage.setItem('lastOp', op);
|
| 14 |
+
updateDisplay();
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
function updateDisplay() {
|
| 18 |
+
const el = document.getElementById('diag');
|
| 19 |
+
if (!el) return;
|
| 20 |
+
const loads = sessionStorage.getItem('loads') || '?';
|
| 21 |
+
const last = sessionStorage.getItem('lastOp') || '-';
|
| 22 |
+
el.textContent = `#${loads} · ${last}`;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
export function initDiag() {
|
| 26 |
+
if (isWorker) return;
|
| 27 |
+
|
| 28 |
+
const loads = parseInt(sessionStorage.getItem('loads') || '0', 10) + 1;
|
| 29 |
+
sessionStorage.setItem('loads', String(loads));
|
| 30 |
+
const last = sessionStorage.getItem('lastOp') || '-';
|
| 31 |
+
console.log(`[diag] boot #${loads}, last op before this load:`, last);
|
| 32 |
+
|
| 33 |
+
// Distinguish bfcache restore (persisted=true) from a fresh load after kill (persisted=false).
|
| 34 |
+
window.addEventListener('pageshow', (e) => {
|
| 35 |
+
console.log(`[diag] pageshow persisted=${e.persisted}`);
|
| 36 |
+
});
|
| 37 |
+
window.addEventListener('pagehide', (e) => {
|
| 38 |
+
console.log(`[diag] pagehide persisted=${e.persisted}`);
|
| 39 |
+
});
|
| 40 |
+
|
| 41 |
+
if (!document.getElementById('diag')) {
|
| 42 |
+
const el = document.createElement('div');
|
| 43 |
+
el.id = 'diag';
|
| 44 |
+
el.style.cssText = [
|
| 45 |
+
'position:fixed',
|
| 46 |
+
'top:env(safe-area-inset-top,0px)',
|
| 47 |
+
'right:env(safe-area-inset-right,0px)',
|
| 48 |
+
'font:10px/1.2 ui-monospace,Menlo,monospace',
|
| 49 |
+
'background:rgba(0,0,0,0.65)',
|
| 50 |
+
'color:#fff',
|
| 51 |
+
'padding:3px 6px',
|
| 52 |
+
'z-index:99999',
|
| 53 |
+
'pointer-events:none',
|
| 54 |
+
'max-width:65vw',
|
| 55 |
+
'text-align:right',
|
| 56 |
+
'border-bottom-left-radius:6px',
|
| 57 |
+
'white-space:nowrap',
|
| 58 |
+
'overflow:hidden',
|
| 59 |
+
'text-overflow:ellipsis',
|
| 60 |
+
].join(';');
|
| 61 |
+
document.body.appendChild(el);
|
| 62 |
+
}
|
| 63 |
+
updateDisplay();
|
| 64 |
+
}
|
js/dressup.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
| 1 |
-
import {
|
| 2 |
-
import { getCategoryInfo,
|
| 3 |
import { STICKERS } from './stickers.js';
|
|
|
|
|
|
|
| 4 |
|
| 5 |
-
let
|
| 6 |
-
let
|
| 7 |
let currentIdx = 0;
|
| 8 |
-
let placedItems = []; // {
|
| 9 |
let objectURLs = [];
|
| 10 |
-
let
|
| 11 |
|
| 12 |
// Single-finger drag state
|
| 13 |
let drag = null;
|
|
@@ -16,66 +18,123 @@ let ghost = null;
|
|
| 16 |
// Pinch-to-resize + rotate state
|
| 17 |
let pinch = null;
|
| 18 |
|
|
|
|
|
|
|
|
|
|
| 19 |
// Double-tap tracking
|
| 20 |
let lastPlacedTap = { idx: -1, time: 0 };
|
| 21 |
|
|
|
|
|
|
|
|
|
|
| 22 |
// Track active pointers on placed items
|
| 23 |
let activePointers = new Map();
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
export async function initDressUp() {
|
|
|
|
| 26 |
const screen = document.getElementById('dressup-screen');
|
| 27 |
|
| 28 |
-
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
objectURLs.forEach(u => URL.revokeObjectURL(u));
|
| 32 |
objectURLs = [];
|
| 33 |
-
|
| 34 |
|
| 35 |
-
|
|
|
|
| 36 |
const url = blobToURL(c.imageBlob);
|
| 37 |
objectURLs.push(url);
|
| 38 |
-
|
|
|
|
| 39 |
});
|
|
|
|
| 40 |
|
| 41 |
-
|
| 42 |
-
screen.innerHTML = `
|
| 43 |
-
<div class="empty-state">
|
| 44 |
-
<p class="empty-icon">\u{1F3A8}</p>
|
| 45 |
-
<h2>No princesses yet!</h2>
|
| 46 |
-
<p>Draw a princess and take a photo!</p>
|
| 47 |
-
<a href="#/upload" class="big-button">Add Photo</a>
|
| 48 |
-
</div>
|
| 49 |
-
`;
|
| 50 |
-
return;
|
| 51 |
-
}
|
| 52 |
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
screen.innerHTML = `
|
| 56 |
<div class="dressup-layout">
|
| 57 |
-
<h1 id="princess-name" class="princess-title"></h1>
|
| 58 |
-
|
| 59 |
<div class="canvas-wrap">
|
| 60 |
<div id="princess-canvas" class="princess-canvas">
|
| 61 |
-
<img id="princess-img" class="princess-img" src="" alt=""/>
|
| 62 |
<div id="placed-layer" class="placed-layer"></div>
|
|
|
|
| 63 |
</div>
|
| 64 |
</div>
|
| 65 |
|
| 66 |
<div class="switcher">
|
| 67 |
<button id="prev-btn" class="switch-btn">\u25C0</button>
|
| 68 |
-
<span id="
|
| 69 |
<button id="next-btn" class="switch-btn">\u25B6</button>
|
|
|
|
| 70 |
</div>
|
| 71 |
|
| 72 |
<div class="tray-tabs" id="tray-tabs">
|
| 73 |
-
${
|
| 74 |
-
<button class="tray-tab ${
|
| 75 |
</div>
|
| 76 |
|
| 77 |
<div id="clothing-tray" class="clothing-tray">
|
| 78 |
-
${!hasItems ? '<p class="tray-empty">
|
| 79 |
</div>
|
| 80 |
|
| 81 |
<div class="bottom-bar">
|
|
@@ -87,63 +146,86 @@ export async function initDressUp() {
|
|
| 87 |
`;
|
| 88 |
|
| 89 |
currentIdx = 0;
|
| 90 |
-
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
bindEvents();
|
|
|
|
| 93 |
}
|
| 94 |
|
| 95 |
// ---- Auto-save ----
|
| 96 |
|
| 97 |
let saveTimer = null;
|
| 98 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
function autoSave() {
|
| 100 |
if (saveTimer) clearTimeout(saveTimer);
|
| 101 |
-
saveTimer = setTimeout(
|
| 102 |
-
const p = princesses[currentIdx];
|
| 103 |
-
if (!p) return;
|
| 104 |
-
const outfit = placedItems.map(({ clothingId, x, y, scale, rotation, zIndex, isSticker }) =>
|
| 105 |
-
({ clothingId, x, y, scale, rotation: rotation || 0, zIndex, isSticker: isSticker || false })
|
| 106 |
-
);
|
| 107 |
-
savePrincessOutfit(p.id, outfit);
|
| 108 |
-
}, 300);
|
| 109 |
}
|
| 110 |
|
| 111 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
|
| 113 |
-
|
| 114 |
-
const p = princesses[currentIdx];
|
| 115 |
-
if (!p) return;
|
| 116 |
|
| 117 |
-
|
| 118 |
-
|
|
|
|
| 119 |
|
| 120 |
-
document.getElementById('
|
| 121 |
-
|
| 122 |
-
document.getElementById('princess-counter').textContent =
|
| 123 |
-
`${currentIdx + 1} / ${princesses.length}`;
|
| 124 |
document.getElementById('prev-btn').disabled = currentIdx === 0;
|
| 125 |
-
document.getElementById('next-btn').disabled = currentIdx ===
|
| 126 |
|
| 127 |
-
// Restore saved
|
| 128 |
placedItems = [];
|
| 129 |
-
if (
|
| 130 |
-
for (const item of
|
|
|
|
| 131 |
let url;
|
| 132 |
if (item.isSticker) {
|
| 133 |
-
const sticker = STICKERS.find(s => s.id ===
|
| 134 |
url = sticker ? sticker.url : null;
|
| 135 |
} else {
|
| 136 |
-
url =
|
| 137 |
}
|
| 138 |
if (url) {
|
| 139 |
-
placedItems.push({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
}
|
| 141 |
}
|
| 142 |
}
|
|
|
|
| 143 |
renderPlaced();
|
| 144 |
}
|
| 145 |
|
| 146 |
-
let currentTab = '
|
| 147 |
|
| 148 |
function renderTray(tab) {
|
| 149 |
currentTab = tab || currentTab;
|
|
@@ -163,18 +245,18 @@ function renderTray(tab) {
|
|
| 163 |
</div>
|
| 164 |
`).join('');
|
| 165 |
} else {
|
| 166 |
-
if (
|
| 167 |
-
tray.innerHTML = '<p class="tray-empty">
|
| 168 |
return;
|
| 169 |
}
|
| 170 |
-
tray.innerHTML =
|
| 171 |
-
const url =
|
| 172 |
const catInfo = getCategoryInfo(c.category);
|
| 173 |
return `
|
| 174 |
<div class="tray-item" data-id="${c.id}"
|
| 175 |
data-scale="${c.scale}" data-z="${c.zIndex}">
|
| 176 |
<img src="${url}" class="tray-thumb" alt="${c.name}" draggable="false"/>
|
| 177 |
-
<span class="tray-label">${catInfo ? catInfo.emoji : ''} ${c.name}</span>
|
| 178 |
</div>
|
| 179 |
`;
|
| 180 |
}).join('');
|
|
@@ -188,7 +270,7 @@ function renderPlaced() {
|
|
| 188 |
.sort((a, b) => a.zIndex - b.zIndex);
|
| 189 |
|
| 190 |
layer.innerHTML = sorted.map(item => `
|
| 191 |
-
<img src="${item.url}" class="placed-item" draggable="false"
|
| 192 |
data-orig-idx="${item.origIdx}"
|
| 193 |
style="
|
| 194 |
position:absolute;
|
|
@@ -200,6 +282,8 @@ function renderPlaced() {
|
|
| 200 |
"
|
| 201 |
alt=""/>
|
| 202 |
`).join('');
|
|
|
|
|
|
|
| 203 |
}
|
| 204 |
|
| 205 |
function updatePlacedStyle(origIdx) {
|
|
@@ -211,6 +295,58 @@ function updatePlacedStyle(origIdx) {
|
|
| 211 |
el.style.top = `${item.y * 100}%`;
|
| 212 |
el.style.width = `${item.scale * 100}%`;
|
| 213 |
el.style.transform = `translate(-50%,-50%) rotate(${item.rotation || 0}deg)`;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
}
|
| 215 |
|
| 216 |
// ---- Placement animation ----
|
|
@@ -258,10 +394,8 @@ function pointerAngle(a, b) {
|
|
| 258 |
|
| 259 |
async function takeScreenshot() {
|
| 260 |
const canvasEl = document.getElementById('princess-canvas');
|
| 261 |
-
const princessImg = document.getElementById('princess-img');
|
| 262 |
const rect = canvasEl.getBoundingClientRect();
|
| 263 |
|
| 264 |
-
// Use a reasonable resolution
|
| 265 |
const w = Math.min(rect.width * 2, 1024);
|
| 266 |
const scale = w / rect.width;
|
| 267 |
const h = rect.height * scale;
|
|
@@ -273,18 +407,6 @@ async function takeScreenshot() {
|
|
| 273 |
ctx.fillStyle = '#fff8f0';
|
| 274 |
ctx.fillRect(0, 0, w, h);
|
| 275 |
|
| 276 |
-
// Draw princess
|
| 277 |
-
const pImg = await loadImage(princessImg.src);
|
| 278 |
-
const pAspect = pImg.width / pImg.height;
|
| 279 |
-
const canvasAspect = w / h;
|
| 280 |
-
let pw, ph, px, py;
|
| 281 |
-
if (pAspect > canvasAspect) {
|
| 282 |
-
pw = w; ph = w / pAspect; px = 0; py = (h - ph) / 2;
|
| 283 |
-
} else {
|
| 284 |
-
ph = h; pw = h * pAspect; px = (w - pw) / 2; py = 0;
|
| 285 |
-
}
|
| 286 |
-
ctx.drawImage(pImg, px, py, pw, ph);
|
| 287 |
-
|
| 288 |
// Draw placed items in z-order
|
| 289 |
const sorted = [...placedItems]
|
| 290 |
.map((item, i) => ({ ...item, i }))
|
|
@@ -294,8 +416,6 @@ async function takeScreenshot() {
|
|
| 294 |
const img = await loadImage(item.url);
|
| 295 |
const itemW = item.scale * w;
|
| 296 |
const itemH = itemW * (img.height / img.width);
|
| 297 |
-
const ix = item.x * w - itemW / 2;
|
| 298 |
-
const iy = item.y * h - itemH / 2;
|
| 299 |
|
| 300 |
ctx.save();
|
| 301 |
ctx.translate(item.x * w, item.y * h);
|
|
@@ -306,24 +426,21 @@ async function takeScreenshot() {
|
|
| 306 |
|
| 307 |
const blob = await canvas.convertToBlob({ type: 'image/png' });
|
| 308 |
|
| 309 |
-
// Try native share, fall back to download
|
| 310 |
if (navigator.share && navigator.canShare) {
|
| 311 |
-
const file = new File([blob], '
|
| 312 |
try {
|
| 313 |
-
await navigator.share({ files: [file], title: 'My
|
| 314 |
return;
|
| 315 |
} catch (_) {}
|
| 316 |
}
|
| 317 |
|
| 318 |
-
// Fallback: download
|
| 319 |
const url = URL.createObjectURL(blob);
|
| 320 |
const a = document.createElement('a');
|
| 321 |
a.href = url;
|
| 322 |
-
a.download = '
|
| 323 |
a.click();
|
| 324 |
URL.revokeObjectURL(url);
|
| 325 |
|
| 326 |
-
// Flash feedback
|
| 327 |
canvasEl.classList.add('screenshot-flash');
|
| 328 |
setTimeout(() => canvasEl.classList.remove('screenshot-flash'), 400);
|
| 329 |
}
|
|
@@ -342,10 +459,17 @@ function loadImage(src) {
|
|
| 342 |
|
| 343 |
function bindEvents() {
|
| 344 |
document.getElementById('prev-btn').addEventListener('click', () => {
|
| 345 |
-
if (currentIdx > 0) { currentIdx--;
|
| 346 |
});
|
| 347 |
document.getElementById('next-btn').addEventListener('click', () => {
|
| 348 |
-
if (currentIdx <
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 349 |
});
|
| 350 |
|
| 351 |
// Tray tabs
|
|
@@ -360,6 +484,41 @@ function bindEvents() {
|
|
| 360 |
const placedLayer = document.getElementById('placed-layer');
|
| 361 |
placedLayer.addEventListener('pointerdown', onPlacedPointerDown, { passive: false });
|
| 362 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 363 |
window.addEventListener('pointermove', onPointerMove, { passive: false });
|
| 364 |
window.addEventListener('pointerup', onPointerUp);
|
| 365 |
window.addEventListener('pointercancel', onPointerUp);
|
|
@@ -396,7 +555,7 @@ function onTrayPointerDown(e) {
|
|
| 396 |
drag = {
|
| 397 |
source: 'tray',
|
| 398 |
id: item.dataset.id,
|
| 399 |
-
url:
|
| 400 |
scale: parseFloat(item.dataset.scale),
|
| 401 |
zIndex: parseInt(item.dataset.z),
|
| 402 |
isSticker: false,
|
|
@@ -415,6 +574,11 @@ function onPlacedPointerDown(e) {
|
|
| 415 |
e.preventDefault();
|
| 416 |
|
| 417 |
const origIdx = parseInt(img.dataset.origIdx);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 418 |
const ptr = { x: e.clientX, y: e.clientY, origIdx };
|
| 419 |
activePointers.set(e.pointerId, ptr);
|
| 420 |
|
|
@@ -447,6 +611,7 @@ function onPlacedPointerDown(e) {
|
|
| 447 |
img.classList.add('poof');
|
| 448 |
setTimeout(() => {
|
| 449 |
placedItems.splice(origIdx, 1);
|
|
|
|
| 450 |
renderPlaced();
|
| 451 |
autoSave();
|
| 452 |
}, 300);
|
|
@@ -456,21 +621,22 @@ function onPlacedPointerDown(e) {
|
|
| 456 |
}
|
| 457 |
lastPlacedTap = { idx: origIdx, time: now };
|
| 458 |
|
| 459 |
-
// Single-finger drag
|
| 460 |
const item = placedItems[origIdx];
|
| 461 |
if (!item) return;
|
| 462 |
|
|
|
|
|
|
|
|
|
|
| 463 |
drag = {
|
| 464 |
source: 'canvas',
|
| 465 |
-
|
| 466 |
-
url: item.url,
|
| 467 |
-
scale: item.scale,
|
| 468 |
-
rotation: item.rotation || 0,
|
| 469 |
-
zIndex: item.zIndex,
|
| 470 |
-
isSticker: item.isSticker || false,
|
| 471 |
startX: e.clientX,
|
| 472 |
startY: e.clientY,
|
| 473 |
-
|
|
|
|
|
|
|
|
|
|
| 474 |
hasMoved: false,
|
| 475 |
};
|
| 476 |
}
|
|
@@ -482,6 +648,17 @@ function onPointerMove(e) {
|
|
| 482 |
ptr.y = e.clientY;
|
| 483 |
}
|
| 484 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 485 |
// Pinch-to-resize + rotate
|
| 486 |
if (pinch) {
|
| 487 |
e.preventDefault();
|
|
@@ -525,20 +702,29 @@ function onPointerMove(e) {
|
|
| 525 |
if (drag.source === 'tray') {
|
| 526 |
createGhost(drag.url);
|
| 527 |
ghost.classList.add('lifting');
|
| 528 |
-
} else if (drag.source === 'canvas') {
|
| 529 |
-
placedItems.splice(drag.canvasIdx, 1);
|
| 530 |
-
renderPlaced();
|
| 531 |
-
createGhost(drag.url);
|
| 532 |
-
ghost.classList.add('lifting');
|
| 533 |
}
|
| 534 |
}
|
| 535 |
|
| 536 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 537 |
}
|
| 538 |
|
| 539 |
function onPointerUp(e) {
|
| 540 |
activePointers.delete(e.pointerId);
|
| 541 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 542 |
if (pinch) {
|
| 543 |
const sameItem = [...activePointers.values()].filter(p => p.origIdx === pinch.idx);
|
| 544 |
if (sameItem.length < 2) {
|
|
@@ -550,32 +736,38 @@ function onPointerUp(e) {
|
|
| 550 |
|
| 551 |
if (!drag) return;
|
| 552 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 553 |
if (!drag.hasMoved) {
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
}
|
| 571 |
drag = null;
|
| 572 |
return;
|
| 573 |
}
|
| 574 |
|
| 575 |
-
// Drop
|
| 576 |
const canvas = document.getElementById('princess-canvas');
|
| 577 |
const rect = canvas.getBoundingClientRect();
|
| 578 |
-
let dropped = false;
|
| 579 |
|
| 580 |
if (
|
| 581 |
e.clientX >= rect.left && e.clientX <= rect.right &&
|
|
@@ -584,23 +776,19 @@ function onPointerUp(e) {
|
|
| 584 |
const nx = (e.clientX - rect.left) / rect.width;
|
| 585 |
const ny = (e.clientY - rect.top) / rect.height;
|
| 586 |
placedItems.push({
|
| 587 |
-
|
| 588 |
x: nx,
|
| 589 |
y: ny,
|
| 590 |
scale: drag.scale,
|
| 591 |
-
rotation:
|
| 592 |
zIndex: drag.zIndex,
|
| 593 |
url: drag.url,
|
| 594 |
isSticker: drag.isSticker,
|
|
|
|
| 595 |
});
|
| 596 |
renderPlaced();
|
| 597 |
animateNewItem();
|
| 598 |
autoSave();
|
| 599 |
-
dropped = true;
|
| 600 |
-
}
|
| 601 |
-
|
| 602 |
-
if (!dropped && drag.source === 'canvas') {
|
| 603 |
-
autoSave();
|
| 604 |
}
|
| 605 |
|
| 606 |
removeGhost();
|
|
|
|
| 1 |
+
import { loadDrawings, loadItems, saveDrawing, updateDrawingItems, deleteDrawing, blobToURL } from './store.js';
|
| 2 |
+
import { getCategoryInfo, DOUBLE_TAP_MS, DRAG_DEAD_ZONE_PX } from './models.js';
|
| 3 |
import { STICKERS } from './stickers.js';
|
| 4 |
+
import { migrateOldData } from './store.js';
|
| 5 |
+
import { tagOp } from './diag.js';
|
| 6 |
|
| 7 |
+
let drawings = [];
|
| 8 |
+
let items = [];
|
| 9 |
let currentIdx = 0;
|
| 10 |
+
let placedItems = []; // { itemId, x, y, scale, rotation, zIndex, url, isSticker, sparkle }
|
| 11 |
let objectURLs = [];
|
| 12 |
+
let itemsURLMap = {}; // id -> objectURL
|
| 13 |
|
| 14 |
// Single-finger drag state
|
| 15 |
let drag = null;
|
|
|
|
| 18 |
// Pinch-to-resize + rotate state
|
| 19 |
let pinch = null;
|
| 20 |
|
| 21 |
+
// Desktop resize handle state
|
| 22 |
+
let resizeHandle = null;
|
| 23 |
+
|
| 24 |
// Double-tap tracking
|
| 25 |
let lastPlacedTap = { idx: -1, time: 0 };
|
| 26 |
|
| 27 |
+
// Selected item for desktop bounding box
|
| 28 |
+
let selectedIdx = -1;
|
| 29 |
+
|
| 30 |
// Track active pointers on placed items
|
| 31 |
let activePointers = new Map();
|
| 32 |
|
| 33 |
+
// Canvas auto-sizing
|
| 34 |
+
let canvasResizeObserver = null;
|
| 35 |
+
|
| 36 |
+
function fitCanvas() {
|
| 37 |
+
const wrap = document.querySelector('.canvas-wrap');
|
| 38 |
+
const canvas = document.getElementById('princess-canvas');
|
| 39 |
+
if (!wrap || !canvas) return;
|
| 40 |
+
|
| 41 |
+
const cw = wrap.clientWidth;
|
| 42 |
+
const ch = wrap.clientHeight;
|
| 43 |
+
if (cw <= 0 || ch <= 0) return;
|
| 44 |
+
|
| 45 |
+
const ratio = 3 / 4; // width / height
|
| 46 |
+
|
| 47 |
+
let maxW;
|
| 48 |
+
if (window.innerWidth >= 1200) maxW = 750;
|
| 49 |
+
else if (window.innerWidth >= 768) maxW = 600;
|
| 50 |
+
else maxW = 480;
|
| 51 |
+
|
| 52 |
+
// Pick largest size that fits both wrap dimensions and the breakpoint cap
|
| 53 |
+
let w, h;
|
| 54 |
+
if (cw / ch > ratio) {
|
| 55 |
+
// wrap is wider than aspect — height-bound
|
| 56 |
+
h = ch;
|
| 57 |
+
w = h * ratio;
|
| 58 |
+
} else {
|
| 59 |
+
// wrap is narrower — width-bound
|
| 60 |
+
w = cw;
|
| 61 |
+
h = w / ratio;
|
| 62 |
+
}
|
| 63 |
+
if (w > maxW) {
|
| 64 |
+
w = maxW;
|
| 65 |
+
h = w / ratio;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
canvas.style.width = `${Math.floor(w)}px`;
|
| 69 |
+
canvas.style.height = `${Math.floor(h)}px`;
|
| 70 |
+
|
| 71 |
+
renderBBox();
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
function setupCanvasResize() {
|
| 75 |
+
const wrap = document.querySelector('.canvas-wrap');
|
| 76 |
+
if (!wrap) return;
|
| 77 |
+
if (canvasResizeObserver) canvasResizeObserver.disconnect();
|
| 78 |
+
canvasResizeObserver = new ResizeObserver(() => fitCanvas());
|
| 79 |
+
canvasResizeObserver.observe(wrap);
|
| 80 |
+
fitCanvas();
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
export async function initDressUp() {
|
| 84 |
+
tagOp('dressup: init');
|
| 85 |
const screen = document.getElementById('dressup-screen');
|
| 86 |
|
| 87 |
+
// Migrate old princess data on first load
|
| 88 |
+
await migrateOldData();
|
| 89 |
+
|
| 90 |
+
drawings = await loadDrawings();
|
| 91 |
+
items = await loadItems();
|
| 92 |
+
tagOp(`dressup: loaded ${items.length} items`);
|
| 93 |
|
| 94 |
objectURLs.forEach(u => URL.revokeObjectURL(u));
|
| 95 |
objectURLs = [];
|
| 96 |
+
itemsURLMap = {};
|
| 97 |
|
| 98 |
+
let totalBytes = 0;
|
| 99 |
+
items.forEach(c => {
|
| 100 |
const url = blobToURL(c.imageBlob);
|
| 101 |
objectURLs.push(url);
|
| 102 |
+
itemsURLMap[c.id] = url;
|
| 103 |
+
totalBytes += c.imageBlob.size || 0;
|
| 104 |
});
|
| 105 |
+
tagOp(`dressup: item blobs total ${Math.round(totalBytes / 1024)}KB`);
|
| 106 |
|
| 107 |
+
const hasItems = items.length > 0 || STICKERS.length > 0;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
|
| 109 |
+
// Ensure at least one drawing exists
|
| 110 |
+
if (drawings.length === 0) {
|
| 111 |
+
const d = await saveDrawing({ name: '', items: [] });
|
| 112 |
+
drawings = [d];
|
| 113 |
+
}
|
| 114 |
|
| 115 |
screen.innerHTML = `
|
| 116 |
<div class="dressup-layout">
|
|
|
|
|
|
|
| 117 |
<div class="canvas-wrap">
|
| 118 |
<div id="princess-canvas" class="princess-canvas">
|
|
|
|
| 119 |
<div id="placed-layer" class="placed-layer"></div>
|
| 120 |
+
<div id="bbox-overlay" class="bbox-overlay"></div>
|
| 121 |
</div>
|
| 122 |
</div>
|
| 123 |
|
| 124 |
<div class="switcher">
|
| 125 |
<button id="prev-btn" class="switch-btn">\u25C0</button>
|
| 126 |
+
<span id="drawing-counter" class="switch-label"></span>
|
| 127 |
<button id="next-btn" class="switch-btn">\u25B6</button>
|
| 128 |
+
<button id="new-drawing-btn" class="switch-btn" title="New drawing">+</button>
|
| 129 |
</div>
|
| 130 |
|
| 131 |
<div class="tray-tabs" id="tray-tabs">
|
| 132 |
+
${items.length > 0 ? '<button class="tray-tab active" data-tab="items">\u{1F3A8} Items</button>' : ''}
|
| 133 |
+
<button class="tray-tab ${items.length === 0 ? 'active' : ''}" data-tab="stickers">\u2728 Stickers</button>
|
| 134 |
</div>
|
| 135 |
|
| 136 |
<div id="clothing-tray" class="clothing-tray">
|
| 137 |
+
${!hasItems ? '<p class="tray-empty">Add some photos to get started!</p>' : ''}
|
| 138 |
</div>
|
| 139 |
|
| 140 |
<div class="bottom-bar">
|
|
|
|
| 146 |
`;
|
| 147 |
|
| 148 |
currentIdx = 0;
|
| 149 |
+
selectedIdx = -1;
|
| 150 |
+
setupCanvasResize();
|
| 151 |
+
tagOp('dressup: renderDrawing');
|
| 152 |
+
renderDrawing();
|
| 153 |
+
tagOp('dressup: renderTray');
|
| 154 |
+
renderTray(items.length > 0 ? 'items' : 'stickers');
|
| 155 |
+
tagOp('dressup: bindEvents');
|
| 156 |
bindEvents();
|
| 157 |
+
tagOp('dressup: ready');
|
| 158 |
}
|
| 159 |
|
| 160 |
// ---- Auto-save ----
|
| 161 |
|
| 162 |
let saveTimer = null;
|
| 163 |
|
| 164 |
+
function saveNow() {
|
| 165 |
+
const d = drawings[currentIdx];
|
| 166 |
+
if (!d) return;
|
| 167 |
+
const saved = placedItems.map(({ itemId, x, y, scale, rotation, zIndex, isSticker, sparkle }) =>
|
| 168 |
+
({ itemId, clothingId: itemId, x, y, scale, rotation: rotation || 0, zIndex, isSticker: isSticker || false, sparkle: sparkle || false })
|
| 169 |
+
);
|
| 170 |
+
d.items = saved; // update in-memory too
|
| 171 |
+
updateDrawingItems(d.id, saved);
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
function autoSave() {
|
| 175 |
if (saveTimer) clearTimeout(saveTimer);
|
| 176 |
+
saveTimer = setTimeout(saveNow, 300);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
}
|
| 178 |
|
| 179 |
+
function flushSave() {
|
| 180 |
+
if (saveTimer) {
|
| 181 |
+
clearTimeout(saveTimer);
|
| 182 |
+
saveTimer = null;
|
| 183 |
+
saveNow();
|
| 184 |
+
}
|
| 185 |
+
}
|
| 186 |
|
| 187 |
+
// ---- Rendering ----
|
|
|
|
|
|
|
| 188 |
|
| 189 |
+
function renderDrawing() {
|
| 190 |
+
const d = drawings[currentIdx];
|
| 191 |
+
if (!d) return;
|
| 192 |
|
| 193 |
+
document.getElementById('drawing-counter').textContent =
|
| 194 |
+
`${currentIdx + 1} / ${drawings.length}`;
|
|
|
|
|
|
|
| 195 |
document.getElementById('prev-btn').disabled = currentIdx === 0;
|
| 196 |
+
document.getElementById('next-btn').disabled = currentIdx === drawings.length - 1;
|
| 197 |
|
| 198 |
+
// Restore saved items
|
| 199 |
placedItems = [];
|
| 200 |
+
if (d.items && Array.isArray(d.items)) {
|
| 201 |
+
for (const item of d.items) {
|
| 202 |
+
const id = item.itemId || item.clothingId;
|
| 203 |
let url;
|
| 204 |
if (item.isSticker) {
|
| 205 |
+
const sticker = STICKERS.find(s => s.id === id);
|
| 206 |
url = sticker ? sticker.url : null;
|
| 207 |
} else {
|
| 208 |
+
url = itemsURLMap[id];
|
| 209 |
}
|
| 210 |
if (url) {
|
| 211 |
+
placedItems.push({
|
| 212 |
+
itemId: id,
|
| 213 |
+
x: item.x, y: item.y,
|
| 214 |
+
scale: item.scale,
|
| 215 |
+
rotation: item.rotation || 0,
|
| 216 |
+
zIndex: item.zIndex,
|
| 217 |
+
url,
|
| 218 |
+
isSticker: item.isSticker || false,
|
| 219 |
+
sparkle: item.sparkle || false,
|
| 220 |
+
});
|
| 221 |
}
|
| 222 |
}
|
| 223 |
}
|
| 224 |
+
selectedIdx = -1;
|
| 225 |
renderPlaced();
|
| 226 |
}
|
| 227 |
|
| 228 |
+
let currentTab = 'items';
|
| 229 |
|
| 230 |
function renderTray(tab) {
|
| 231 |
currentTab = tab || currentTab;
|
|
|
|
| 245 |
</div>
|
| 246 |
`).join('');
|
| 247 |
} else {
|
| 248 |
+
if (items.length === 0) {
|
| 249 |
+
tray.innerHTML = '<p class="tray-empty">Add some photos to get started!</p>';
|
| 250 |
return;
|
| 251 |
}
|
| 252 |
+
tray.innerHTML = items.map(c => {
|
| 253 |
+
const url = itemsURLMap[c.id];
|
| 254 |
const catInfo = getCategoryInfo(c.category);
|
| 255 |
return `
|
| 256 |
<div class="tray-item" data-id="${c.id}"
|
| 257 |
data-scale="${c.scale}" data-z="${c.zIndex}">
|
| 258 |
<img src="${url}" class="tray-thumb" alt="${c.name}" draggable="false"/>
|
| 259 |
+
<span class="tray-label">${catInfo ? catInfo.emoji : ''} ${c.name || (catInfo ? catInfo.label : '')}</span>
|
| 260 |
</div>
|
| 261 |
`;
|
| 262 |
}).join('');
|
|
|
|
| 270 |
.sort((a, b) => a.zIndex - b.zIndex);
|
| 271 |
|
| 272 |
layer.innerHTML = sorted.map(item => `
|
| 273 |
+
<img src="${item.url}" class="placed-item ${item.sparkle ? 'sparkle-effect' : ''}" draggable="false"
|
| 274 |
data-orig-idx="${item.origIdx}"
|
| 275 |
style="
|
| 276 |
position:absolute;
|
|
|
|
| 282 |
"
|
| 283 |
alt=""/>
|
| 284 |
`).join('');
|
| 285 |
+
|
| 286 |
+
renderBBox();
|
| 287 |
}
|
| 288 |
|
| 289 |
function updatePlacedStyle(origIdx) {
|
|
|
|
| 295 |
el.style.top = `${item.y * 100}%`;
|
| 296 |
el.style.width = `${item.scale * 100}%`;
|
| 297 |
el.style.transform = `translate(-50%,-50%) rotate(${item.rotation || 0}deg)`;
|
| 298 |
+
renderBBox();
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
// ---- Desktop bounding box ----
|
| 302 |
+
|
| 303 |
+
function renderBBox() {
|
| 304 |
+
const overlay = document.getElementById('bbox-overlay');
|
| 305 |
+
if (!overlay) return;
|
| 306 |
+
|
| 307 |
+
if (selectedIdx < 0 || selectedIdx >= placedItems.length) {
|
| 308 |
+
overlay.innerHTML = '';
|
| 309 |
+
return;
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
const item = placedItems[selectedIdx];
|
| 313 |
+
const el = document.querySelector(`.placed-item[data-orig-idx="${selectedIdx}"]`);
|
| 314 |
+
if (!el) { overlay.innerHTML = ''; return; }
|
| 315 |
+
|
| 316 |
+
// Get bounding rect relative to canvas
|
| 317 |
+
const canvas = document.getElementById('princess-canvas');
|
| 318 |
+
const canvasRect = canvas.getBoundingClientRect();
|
| 319 |
+
const elRect = el.getBoundingClientRect();
|
| 320 |
+
|
| 321 |
+
const left = elRect.left - canvasRect.left;
|
| 322 |
+
const top = elRect.top - canvasRect.top;
|
| 323 |
+
const w = elRect.width;
|
| 324 |
+
const h = elRect.height;
|
| 325 |
+
|
| 326 |
+
overlay.innerHTML = `
|
| 327 |
+
<div class="bbox" style="left:${left}px;top:${top}px;width:${w}px;height:${h}px;">
|
| 328 |
+
<div class="bbox-handle bbox-handle-br" data-handle="br"></div>
|
| 329 |
+
<button class="bbox-action bbox-sparkle" data-action="sparkle" title="Toggle sparkles">\u2728</button>
|
| 330 |
+
<button class="bbox-action bbox-delete" data-action="delete" title="Remove">\u{1F5D1}</button>
|
| 331 |
+
</div>
|
| 332 |
+
`;
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
function handleBBoxAction(action, idx) {
|
| 336 |
+
if (action === 'delete') {
|
| 337 |
+
const el = document.querySelector(`.placed-item[data-orig-idx="${idx}"]`);
|
| 338 |
+
if (el) el.classList.add('poof');
|
| 339 |
+
setTimeout(() => {
|
| 340 |
+
placedItems.splice(idx, 1);
|
| 341 |
+
selectedIdx = -1;
|
| 342 |
+
renderPlaced();
|
| 343 |
+
autoSave();
|
| 344 |
+
}, 300);
|
| 345 |
+
} else if (action === 'sparkle') {
|
| 346 |
+
placedItems[idx].sparkle = !placedItems[idx].sparkle;
|
| 347 |
+
renderPlaced();
|
| 348 |
+
autoSave();
|
| 349 |
+
}
|
| 350 |
}
|
| 351 |
|
| 352 |
// ---- Placement animation ----
|
|
|
|
| 394 |
|
| 395 |
async function takeScreenshot() {
|
| 396 |
const canvasEl = document.getElementById('princess-canvas');
|
|
|
|
| 397 |
const rect = canvasEl.getBoundingClientRect();
|
| 398 |
|
|
|
|
| 399 |
const w = Math.min(rect.width * 2, 1024);
|
| 400 |
const scale = w / rect.width;
|
| 401 |
const h = rect.height * scale;
|
|
|
|
| 407 |
ctx.fillStyle = '#fff8f0';
|
| 408 |
ctx.fillRect(0, 0, w, h);
|
| 409 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 410 |
// Draw placed items in z-order
|
| 411 |
const sorted = [...placedItems]
|
| 412 |
.map((item, i) => ({ ...item, i }))
|
|
|
|
| 416 |
const img = await loadImage(item.url);
|
| 417 |
const itemW = item.scale * w;
|
| 418 |
const itemH = itemW * (img.height / img.width);
|
|
|
|
|
|
|
| 419 |
|
| 420 |
ctx.save();
|
| 421 |
ctx.translate(item.x * w, item.y * h);
|
|
|
|
| 426 |
|
| 427 |
const blob = await canvas.convertToBlob({ type: 'image/png' });
|
| 428 |
|
|
|
|
| 429 |
if (navigator.share && navigator.canShare) {
|
| 430 |
+
const file = new File([blob], 'drawing.png', { type: 'image/png' });
|
| 431 |
try {
|
| 432 |
+
await navigator.share({ files: [file], title: 'My Drawing' });
|
| 433 |
return;
|
| 434 |
} catch (_) {}
|
| 435 |
}
|
| 436 |
|
|
|
|
| 437 |
const url = URL.createObjectURL(blob);
|
| 438 |
const a = document.createElement('a');
|
| 439 |
a.href = url;
|
| 440 |
+
a.download = 'drawing.png';
|
| 441 |
a.click();
|
| 442 |
URL.revokeObjectURL(url);
|
| 443 |
|
|
|
|
| 444 |
canvasEl.classList.add('screenshot-flash');
|
| 445 |
setTimeout(() => canvasEl.classList.remove('screenshot-flash'), 400);
|
| 446 |
}
|
|
|
|
| 459 |
|
| 460 |
function bindEvents() {
|
| 461 |
document.getElementById('prev-btn').addEventListener('click', () => {
|
| 462 |
+
if (currentIdx > 0) { flushSave(); currentIdx--; renderDrawing(); }
|
| 463 |
});
|
| 464 |
document.getElementById('next-btn').addEventListener('click', () => {
|
| 465 |
+
if (currentIdx < drawings.length - 1) { flushSave(); currentIdx++; renderDrawing(); }
|
| 466 |
+
});
|
| 467 |
+
document.getElementById('new-drawing-btn').addEventListener('click', async () => {
|
| 468 |
+
flushSave();
|
| 469 |
+
const d = await saveDrawing({ name: '', items: [] });
|
| 470 |
+
drawings.push(d);
|
| 471 |
+
currentIdx = drawings.length - 1;
|
| 472 |
+
renderDrawing();
|
| 473 |
});
|
| 474 |
|
| 475 |
// Tray tabs
|
|
|
|
| 484 |
const placedLayer = document.getElementById('placed-layer');
|
| 485 |
placedLayer.addEventListener('pointerdown', onPlacedPointerDown, { passive: false });
|
| 486 |
|
| 487 |
+
// Deselect when clicking empty canvas area
|
| 488 |
+
const canvasEl = document.getElementById('princess-canvas');
|
| 489 |
+
canvasEl.addEventListener('pointerdown', (e) => {
|
| 490 |
+
if (e.target === canvasEl || e.target.id === 'placed-layer') {
|
| 491 |
+
selectedIdx = -1;
|
| 492 |
+
renderBBox();
|
| 493 |
+
}
|
| 494 |
+
});
|
| 495 |
+
|
| 496 |
+
// BBox actions
|
| 497 |
+
const bboxOverlay = document.getElementById('bbox-overlay');
|
| 498 |
+
bboxOverlay.addEventListener('pointerdown', (e) => {
|
| 499 |
+
const handle = e.target.closest('.bbox-handle');
|
| 500 |
+
if (handle && selectedIdx >= 0) {
|
| 501 |
+
e.preventDefault();
|
| 502 |
+
e.stopPropagation();
|
| 503 |
+
const canvas = document.getElementById('princess-canvas');
|
| 504 |
+
const rect = canvas.getBoundingClientRect();
|
| 505 |
+
resizeHandle = {
|
| 506 |
+
idx: selectedIdx,
|
| 507 |
+
startX: e.clientX,
|
| 508 |
+
startY: e.clientY,
|
| 509 |
+
startScale: placedItems[selectedIdx].scale,
|
| 510 |
+
canvasW: rect.width,
|
| 511 |
+
};
|
| 512 |
+
return;
|
| 513 |
+
}
|
| 514 |
+
const actionBtn = e.target.closest('.bbox-action');
|
| 515 |
+
if (actionBtn && selectedIdx >= 0) {
|
| 516 |
+
e.preventDefault();
|
| 517 |
+
e.stopPropagation();
|
| 518 |
+
handleBBoxAction(actionBtn.dataset.action, selectedIdx);
|
| 519 |
+
}
|
| 520 |
+
});
|
| 521 |
+
|
| 522 |
window.addEventListener('pointermove', onPointerMove, { passive: false });
|
| 523 |
window.addEventListener('pointerup', onPointerUp);
|
| 524 |
window.addEventListener('pointercancel', onPointerUp);
|
|
|
|
| 555 |
drag = {
|
| 556 |
source: 'tray',
|
| 557 |
id: item.dataset.id,
|
| 558 |
+
url: itemsURLMap[item.dataset.id],
|
| 559 |
scale: parseFloat(item.dataset.scale),
|
| 560 |
zIndex: parseInt(item.dataset.z),
|
| 561 |
isSticker: false,
|
|
|
|
| 574 |
e.preventDefault();
|
| 575 |
|
| 576 |
const origIdx = parseInt(img.dataset.origIdx);
|
| 577 |
+
|
| 578 |
+
// Select for desktop bbox
|
| 579 |
+
selectedIdx = origIdx;
|
| 580 |
+
renderBBox();
|
| 581 |
+
|
| 582 |
const ptr = { x: e.clientX, y: e.clientY, origIdx };
|
| 583 |
activePointers.set(e.pointerId, ptr);
|
| 584 |
|
|
|
|
| 611 |
img.classList.add('poof');
|
| 612 |
setTimeout(() => {
|
| 613 |
placedItems.splice(origIdx, 1);
|
| 614 |
+
selectedIdx = -1;
|
| 615 |
renderPlaced();
|
| 616 |
autoSave();
|
| 617 |
}, 300);
|
|
|
|
| 621 |
}
|
| 622 |
lastPlacedTap = { idx: origIdx, time: now };
|
| 623 |
|
| 624 |
+
// Single-finger drag — move item in-place
|
| 625 |
const item = placedItems[origIdx];
|
| 626 |
if (!item) return;
|
| 627 |
|
| 628 |
+
const canvas = document.getElementById('princess-canvas');
|
| 629 |
+
const rect = canvas.getBoundingClientRect();
|
| 630 |
+
|
| 631 |
drag = {
|
| 632 |
source: 'canvas',
|
| 633 |
+
canvasIdx: origIdx,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 634 |
startX: e.clientX,
|
| 635 |
startY: e.clientY,
|
| 636 |
+
startItemX: item.x,
|
| 637 |
+
startItemY: item.y,
|
| 638 |
+
canvasW: rect.width,
|
| 639 |
+
canvasH: rect.height,
|
| 640 |
hasMoved: false,
|
| 641 |
};
|
| 642 |
}
|
|
|
|
| 648 |
ptr.y = e.clientY;
|
| 649 |
}
|
| 650 |
|
| 651 |
+
// Desktop resize handle
|
| 652 |
+
if (resizeHandle) {
|
| 653 |
+
e.preventDefault();
|
| 654 |
+
const dx = e.clientX - resizeHandle.startX;
|
| 655 |
+
const scaleDelta = dx / resizeHandle.canvasW;
|
| 656 |
+
const newScale = Math.max(0.05, Math.min(3.0, resizeHandle.startScale + scaleDelta));
|
| 657 |
+
placedItems[resizeHandle.idx].scale = newScale;
|
| 658 |
+
updatePlacedStyle(resizeHandle.idx);
|
| 659 |
+
return;
|
| 660 |
+
}
|
| 661 |
+
|
| 662 |
// Pinch-to-resize + rotate
|
| 663 |
if (pinch) {
|
| 664 |
e.preventDefault();
|
|
|
|
| 702 |
if (drag.source === 'tray') {
|
| 703 |
createGhost(drag.url);
|
| 704 |
ghost.classList.add('lifting');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 705 |
}
|
| 706 |
}
|
| 707 |
|
| 708 |
+
if (drag.source === 'canvas') {
|
| 709 |
+
// Move item in-place
|
| 710 |
+
placedItems[drag.canvasIdx].x = drag.startItemX + dx / drag.canvasW;
|
| 711 |
+
placedItems[drag.canvasIdx].y = drag.startItemY + dy / drag.canvasH;
|
| 712 |
+
updatePlacedStyle(drag.canvasIdx);
|
| 713 |
+
} else {
|
| 714 |
+
moveGhost(e.clientX, e.clientY);
|
| 715 |
+
}
|
| 716 |
}
|
| 717 |
|
| 718 |
function onPointerUp(e) {
|
| 719 |
activePointers.delete(e.pointerId);
|
| 720 |
|
| 721 |
+
// Desktop resize handle
|
| 722 |
+
if (resizeHandle) {
|
| 723 |
+
resizeHandle = null;
|
| 724 |
+
autoSave();
|
| 725 |
+
return;
|
| 726 |
+
}
|
| 727 |
+
|
| 728 |
if (pinch) {
|
| 729 |
const sameItem = [...activePointers.values()].filter(p => p.origIdx === pinch.idx);
|
| 730 |
if (sameItem.length < 2) {
|
|
|
|
| 736 |
|
| 737 |
if (!drag) return;
|
| 738 |
|
| 739 |
+
if (drag.source === 'canvas') {
|
| 740 |
+
// Canvas item: just save the new position
|
| 741 |
+
if (drag.hasMoved) autoSave();
|
| 742 |
+
drag = null;
|
| 743 |
+
return;
|
| 744 |
+
}
|
| 745 |
+
|
| 746 |
+
// Tray item: tap-to-place or drag-to-drop
|
| 747 |
if (!drag.hasMoved) {
|
| 748 |
+
const ox = (Math.random() - 0.5) * 0.15;
|
| 749 |
+
const oy = (Math.random() - 0.5) * 0.15;
|
| 750 |
+
placedItems.push({
|
| 751 |
+
itemId: drag.id,
|
| 752 |
+
x: 0.5 + ox,
|
| 753 |
+
y: 0.5 + oy,
|
| 754 |
+
scale: drag.scale,
|
| 755 |
+
rotation: 0,
|
| 756 |
+
zIndex: drag.zIndex,
|
| 757 |
+
url: drag.url,
|
| 758 |
+
isSticker: drag.isSticker,
|
| 759 |
+
sparkle: false,
|
| 760 |
+
});
|
| 761 |
+
renderPlaced();
|
| 762 |
+
animateNewItem();
|
| 763 |
+
autoSave();
|
|
|
|
| 764 |
drag = null;
|
| 765 |
return;
|
| 766 |
}
|
| 767 |
|
| 768 |
+
// Drop from tray onto canvas
|
| 769 |
const canvas = document.getElementById('princess-canvas');
|
| 770 |
const rect = canvas.getBoundingClientRect();
|
|
|
|
| 771 |
|
| 772 |
if (
|
| 773 |
e.clientX >= rect.left && e.clientX <= rect.right &&
|
|
|
|
| 776 |
const nx = (e.clientX - rect.left) / rect.width;
|
| 777 |
const ny = (e.clientY - rect.top) / rect.height;
|
| 778 |
placedItems.push({
|
| 779 |
+
itemId: drag.id,
|
| 780 |
x: nx,
|
| 781 |
y: ny,
|
| 782 |
scale: drag.scale,
|
| 783 |
+
rotation: 0,
|
| 784 |
zIndex: drag.zIndex,
|
| 785 |
url: drag.url,
|
| 786 |
isSticker: drag.isSticker,
|
| 787 |
+
sparkle: false,
|
| 788 |
});
|
| 789 |
renderPlaced();
|
| 790 |
animateNewItem();
|
| 791 |
autoSave();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 792 |
}
|
| 793 |
|
| 794 |
removeGhost();
|
js/manage.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import {
|
| 2 |
import { getCategoryInfo } from './models.js';
|
| 3 |
|
| 4 |
let objectURLs = [];
|
|
@@ -9,8 +9,8 @@ export async function initManage() {
|
|
| 9 |
objectURLs.forEach(u => URL.revokeObjectURL(u));
|
| 10 |
objectURLs = [];
|
| 11 |
|
| 12 |
-
const
|
| 13 |
-
const
|
| 14 |
|
| 15 |
screen.innerHTML = `
|
| 16 |
<div class="manage-layout">
|
|
@@ -20,52 +20,51 @@ export async function initManage() {
|
|
| 20 |
</div>
|
| 21 |
|
| 22 |
<div class="manage-section">
|
| 23 |
-
<h3 class="manage-heading">\u{
|
| 24 |
-
<div id="
|
| 25 |
-
${
|
| 26 |
</div>
|
| 27 |
</div>
|
| 28 |
|
| 29 |
<div class="manage-section">
|
| 30 |
-
<h3 class="manage-heading">\u{
|
| 31 |
-
<div id="
|
| 32 |
-
${
|
| 33 |
</div>
|
| 34 |
</div>
|
| 35 |
</div>
|
| 36 |
`;
|
| 37 |
|
| 38 |
-
const
|
| 39 |
-
if (
|
| 40 |
-
|
| 41 |
-
const url = blobToURL(p.imageBlob);
|
| 42 |
-
objectURLs.push(url);
|
| 43 |
-
return `
|
| 44 |
-
<div class="manage-card" data-id="${p.id}" data-type="princess">
|
| 45 |
-
<img src="${url}" class="manage-thumb" alt="${p.name}"/>
|
| 46 |
-
<span class="manage-name">${p.name || 'Princess'}</span>
|
| 47 |
-
<button class="manage-delete" data-id="${p.id}" data-type="princess">\u{1F5D1}</button>
|
| 48 |
-
</div>
|
| 49 |
-
`;
|
| 50 |
-
}).join('');
|
| 51 |
-
}
|
| 52 |
-
|
| 53 |
-
const clothesGrid = document.getElementById('clothes-grid');
|
| 54 |
-
if (clothes.length > 0) {
|
| 55 |
-
clothesGrid.innerHTML = clothes.map(c => {
|
| 56 |
const url = blobToURL(c.imageBlob);
|
| 57 |
objectURLs.push(url);
|
| 58 |
const catInfo = getCategoryInfo(c.category);
|
| 59 |
return `
|
| 60 |
-
<div class="manage-card" data-id="${c.id}" data-type="
|
| 61 |
<img src="${url}" class="manage-thumb checkerboard-bg" alt="${c.name}"/>
|
| 62 |
<span class="manage-name">${catInfo ? catInfo.emoji : ''} ${c.name || (catInfo ? catInfo.label : '')}</span>
|
| 63 |
-
<button class="manage-delete" data-id="${c.id}" data-type="
|
| 64 |
</div>
|
| 65 |
`;
|
| 66 |
}).join('');
|
| 67 |
}
|
| 68 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
// Delete handlers
|
| 70 |
screen.querySelectorAll('.manage-delete').forEach(btn => {
|
| 71 |
btn.addEventListener('click', async (e) => {
|
|
@@ -77,13 +76,12 @@ export async function initManage() {
|
|
| 77 |
card.classList.add('poof');
|
| 78 |
await new Promise(r => setTimeout(r, 300));
|
| 79 |
|
| 80 |
-
if (type === '
|
| 81 |
-
await
|
| 82 |
} else {
|
| 83 |
-
await
|
| 84 |
}
|
| 85 |
|
| 86 |
-
// Re-render
|
| 87 |
initManage();
|
| 88 |
});
|
| 89 |
});
|
|
|
|
| 1 |
+
import { loadItems, loadDrawings, deleteItem, deleteDrawing, blobToURL } from './store.js';
|
| 2 |
import { getCategoryInfo } from './models.js';
|
| 3 |
|
| 4 |
let objectURLs = [];
|
|
|
|
| 9 |
objectURLs.forEach(u => URL.revokeObjectURL(u));
|
| 10 |
objectURLs = [];
|
| 11 |
|
| 12 |
+
const items = await loadItems();
|
| 13 |
+
const drawings = await loadDrawings();
|
| 14 |
|
| 15 |
screen.innerHTML = `
|
| 16 |
<div class="manage-layout">
|
|
|
|
| 20 |
</div>
|
| 21 |
|
| 22 |
<div class="manage-section">
|
| 23 |
+
<h3 class="manage-heading">\u{1F3A8} Items</h3>
|
| 24 |
+
<div id="items-grid" class="manage-grid">
|
| 25 |
+
${items.length === 0 ? '<p class="manage-empty">No items yet</p>' : ''}
|
| 26 |
</div>
|
| 27 |
</div>
|
| 28 |
|
| 29 |
<div class="manage-section">
|
| 30 |
+
<h3 class="manage-heading">\u{1F4CB} Drawings</h3>
|
| 31 |
+
<div id="drawings-grid" class="manage-grid">
|
| 32 |
+
${drawings.length === 0 ? '<p class="manage-empty">No saved drawings</p>' : ''}
|
| 33 |
</div>
|
| 34 |
</div>
|
| 35 |
</div>
|
| 36 |
`;
|
| 37 |
|
| 38 |
+
const itemsGrid = document.getElementById('items-grid');
|
| 39 |
+
if (items.length > 0) {
|
| 40 |
+
itemsGrid.innerHTML = items.map(c => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
const url = blobToURL(c.imageBlob);
|
| 42 |
objectURLs.push(url);
|
| 43 |
const catInfo = getCategoryInfo(c.category);
|
| 44 |
return `
|
| 45 |
+
<div class="manage-card" data-id="${c.id}" data-type="item">
|
| 46 |
<img src="${url}" class="manage-thumb checkerboard-bg" alt="${c.name}"/>
|
| 47 |
<span class="manage-name">${catInfo ? catInfo.emoji : ''} ${c.name || (catInfo ? catInfo.label : '')}</span>
|
| 48 |
+
<button class="manage-delete" data-id="${c.id}" data-type="item">\u{1F5D1}</button>
|
| 49 |
</div>
|
| 50 |
`;
|
| 51 |
}).join('');
|
| 52 |
}
|
| 53 |
|
| 54 |
+
const drawingsGrid = document.getElementById('drawings-grid');
|
| 55 |
+
if (drawings.length > 0) {
|
| 56 |
+
drawingsGrid.innerHTML = drawings.map((d, i) => `
|
| 57 |
+
<div class="manage-card" data-id="${d.id}" data-type="drawing">
|
| 58 |
+
<div class="manage-thumb drawing-thumb">
|
| 59 |
+
<span class="drawing-icon">\u{1F5BC}\uFE0F</span>
|
| 60 |
+
<span class="drawing-count">${d.items ? d.items.length : 0} items</span>
|
| 61 |
+
</div>
|
| 62 |
+
<span class="manage-name">${d.name || `Drawing ${i + 1}`}</span>
|
| 63 |
+
<button class="manage-delete" data-id="${d.id}" data-type="drawing">\u{1F5D1}</button>
|
| 64 |
+
</div>
|
| 65 |
+
`).join('');
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
// Delete handlers
|
| 69 |
screen.querySelectorAll('.manage-delete').forEach(btn => {
|
| 70 |
btn.addEventListener('click', async (e) => {
|
|
|
|
| 76 |
card.classList.add('poof');
|
| 77 |
await new Promise(r => setTimeout(r, 300));
|
| 78 |
|
| 79 |
+
if (type === 'drawing') {
|
| 80 |
+
await deleteDrawing(id);
|
| 81 |
} else {
|
| 82 |
+
await deleteItem(id);
|
| 83 |
}
|
| 84 |
|
|
|
|
| 85 |
initManage();
|
| 86 |
});
|
| 87 |
});
|
js/ml-client.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Client for the Hugging Face Space that runs background removal
|
| 2 |
+
// server-side. In-browser inference via transformers.js proved
|
| 3 |
+
// unreliable on iOS (see memory/project_in_browser_ml_dead_end.md),
|
| 4 |
+
// so the model now lives on a free HF Docker Space. The whole point
|
| 5 |
+
// of this file is to hide that from upload.js — the `removeBackground`
|
| 6 |
+
// signature is unchanged.
|
| 7 |
+
//
|
| 8 |
+
// Deployment: see ../space/README.md. After pushing the Space, set
|
| 9 |
+
// SPACE_URL below to the resulting `https://USERNAME-princess-bg.hf.space`
|
| 10 |
+
// URL.
|
| 11 |
+
|
| 12 |
+
import { tagOp } from './diag.js';
|
| 13 |
+
|
| 14 |
+
// HF Space: https://huggingface.co/spaces/sdragly/background-removal
|
| 15 |
+
const SPACE_URL = 'https://sdragly-background-removal.hf.space';
|
| 16 |
+
|
| 17 |
+
// 90s covers the worst-case cold-start (~15-30s container boot) plus
|
| 18 |
+
// a slow image upload on a weak cellular connection. If we ever need
|
| 19 |
+
// more, something has gone wrong upstream and we should surface the
|
| 20 |
+
// error rather than hang the UI.
|
| 21 |
+
const REQUEST_TIMEOUT_MS = 90_000;
|
| 22 |
+
|
| 23 |
+
// Show a "waking up" hint if the response doesn't come back quickly.
|
| 24 |
+
// The typical warm response is <3s; anything slower is either a cold
|
| 25 |
+
// start or a slow network, and the user deserves to know.
|
| 26 |
+
const COLD_START_HINT_MS = 3_000;
|
| 27 |
+
|
| 28 |
+
/**
|
| 29 |
+
* Remove background from an image blob via the HF Space.
|
| 30 |
+
* @param {Blob} blob JPEG/PNG image
|
| 31 |
+
* @param {(p: {status: string, message: string, progress?: number}) => void} [onProgress]
|
| 32 |
+
* @returns {Promise<Blob>} PNG with transparent background
|
| 33 |
+
*/
|
| 34 |
+
export async function removeBackground(blob, onProgress) {
|
| 35 |
+
const progress = (message) => {
|
| 36 |
+
if (onProgress) onProgress({ status: 'processing', message });
|
| 37 |
+
};
|
| 38 |
+
|
| 39 |
+
tagOp('[remove-bg] uploading');
|
| 40 |
+
progress('Sending to server...');
|
| 41 |
+
|
| 42 |
+
// If the server is cold, the first POST will hang for ~15-30s. Flip
|
| 43 |
+
// the message after a few seconds so the user knows nothing's broken.
|
| 44 |
+
const coldTimer = setTimeout(() => {
|
| 45 |
+
tagOp('[remove-bg] waking server');
|
| 46 |
+
progress('Waking up server (first request is slow)...');
|
| 47 |
+
}, COLD_START_HINT_MS);
|
| 48 |
+
|
| 49 |
+
const abort = new AbortController();
|
| 50 |
+
const timeoutTimer = setTimeout(() => abort.abort(), REQUEST_TIMEOUT_MS);
|
| 51 |
+
|
| 52 |
+
try {
|
| 53 |
+
const form = new FormData();
|
| 54 |
+
// Use a stable filename — server only cares about content, but
|
| 55 |
+
// FormData requires *something* here.
|
| 56 |
+
form.append('file', blob, 'input.png');
|
| 57 |
+
|
| 58 |
+
const res = await fetch(`${SPACE_URL}/remove-bg`, {
|
| 59 |
+
method: 'POST',
|
| 60 |
+
body: form,
|
| 61 |
+
signal: abort.signal,
|
| 62 |
+
});
|
| 63 |
+
|
| 64 |
+
clearTimeout(coldTimer);
|
| 65 |
+
|
| 66 |
+
if (!res.ok) {
|
| 67 |
+
const text = await res.text().catch(() => '');
|
| 68 |
+
throw new Error(
|
| 69 |
+
`Server returned ${res.status}${text ? `: ${text.slice(0, 200)}` : ''}`,
|
| 70 |
+
);
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
tagOp('[remove-bg] receiving');
|
| 74 |
+
progress('Receiving result...');
|
| 75 |
+
const result = await res.blob();
|
| 76 |
+
tagOp('[remove-bg] done');
|
| 77 |
+
if (onProgress) onProgress({ status: 'done', message: 'Background removed' });
|
| 78 |
+
return result;
|
| 79 |
+
} catch (err) {
|
| 80 |
+
clearTimeout(coldTimer);
|
| 81 |
+
if (err.name === 'AbortError') {
|
| 82 |
+
throw new Error(
|
| 83 |
+
`Server request timed out after ${REQUEST_TIMEOUT_MS / 1000}s`,
|
| 84 |
+
);
|
| 85 |
+
}
|
| 86 |
+
throw err;
|
| 87 |
+
} finally {
|
| 88 |
+
clearTimeout(timeoutTimer);
|
| 89 |
+
}
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
/**
|
| 93 |
+
* Auto-segment a full drawing into distinct parts.
|
| 94 |
+
*
|
| 95 |
+
* Not implemented server-side yet. Returning an empty array lets the
|
| 96 |
+
* Full Drawing flow in upload.js bail out cleanly with "no segments
|
| 97 |
+
* found" instead of crashing. If/when we add a SAM endpoint to the
|
| 98 |
+
* Space, wire it up here.
|
| 99 |
+
*/
|
| 100 |
+
export async function autoSegment(blob, onProgress) {
|
| 101 |
+
tagOp('[auto-segment] stub (not implemented server-side)');
|
| 102 |
+
if (onProgress) {
|
| 103 |
+
onProgress({
|
| 104 |
+
status: 'done',
|
| 105 |
+
message: 'Full-drawing auto-split is not available yet',
|
| 106 |
+
progress: 100,
|
| 107 |
+
});
|
| 108 |
+
}
|
| 109 |
+
return [];
|
| 110 |
+
}
|
js/ml-worker.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Web Worker that runs the heavy ML pipelines off the main thread.
|
| 2 |
+
// Each call to ml-client spawns a fresh worker; this file is loaded once
|
| 3 |
+
// per worker instance and torn down when the worker is terminated.
|
| 4 |
+
//
|
| 5 |
+
// On iOS / Android, the WebContent process can be killed under memory
|
| 6 |
+
// pressure. Putting the model here means a model OOM kills the worker
|
| 7 |
+
// (which we already throw away after each op) — the page itself stays
|
| 8 |
+
// alive much more often.
|
| 9 |
+
|
| 10 |
+
import { removeBackground } from './segmentation.js';
|
| 11 |
+
import { autoSegment } from './auto-segment.js';
|
| 12 |
+
|
| 13 |
+
self.onmessage = async (e) => {
|
| 14 |
+
const { type, payload } = e.data || {};
|
| 15 |
+
|
| 16 |
+
const onProgress = (info) => {
|
| 17 |
+
try { self.postMessage({ kind: 'progress', payload: info }); } catch (_) {}
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
try {
|
| 21 |
+
let result;
|
| 22 |
+
if (type === 'remove-bg') {
|
| 23 |
+
result = await removeBackground(payload.blob, onProgress);
|
| 24 |
+
} else if (type === 'auto-segment') {
|
| 25 |
+
result = await autoSegment(payload.blob, onProgress);
|
| 26 |
+
} else {
|
| 27 |
+
throw new Error(`Unknown task: ${type}`);
|
| 28 |
+
}
|
| 29 |
+
self.postMessage({ kind: 'result', payload: result });
|
| 30 |
+
} catch (err) {
|
| 31 |
+
self.postMessage({
|
| 32 |
+
kind: 'error',
|
| 33 |
+
payload: { message: err && err.message ? err.message : String(err) },
|
| 34 |
+
});
|
| 35 |
+
}
|
| 36 |
+
};
|
js/models.js
CHANGED
|
@@ -1,35 +1,21 @@
|
|
| 1 |
export const CATEGORIES = [
|
| 2 |
-
{ id: '
|
| 3 |
-
{ id: '
|
| 4 |
-
{ id: '
|
| 5 |
-
{ id: '
|
| 6 |
-
{ id: '
|
| 7 |
-
{ id: '
|
| 8 |
-
{ id: '
|
| 9 |
-
{ id: '
|
| 10 |
-
{ id: '
|
| 11 |
-
{ id: '
|
|
|
|
|
|
|
| 12 |
];
|
| 13 |
|
| 14 |
export function getCategoryInfo(id) {
|
| 15 |
-
return CATEGORIES.find(c => c.id === id);
|
| 16 |
}
|
| 17 |
|
| 18 |
-
// Default princess anchor positions (normalized 0-1)
|
| 19 |
-
// Applied as heuristics based on bounding box
|
| 20 |
-
export const DEFAULT_ANCHORS = {
|
| 21 |
-
head_top: { x: 0.5, y: 0.07 },
|
| 22 |
-
head_center: { x: 0.5, y: 0.17 },
|
| 23 |
-
neck: { x: 0.5, y: 0.27 },
|
| 24 |
-
left_shoulder: { x: 0.35, y: 0.30 },
|
| 25 |
-
right_shoulder: { x: 0.65, y: 0.30 },
|
| 26 |
-
waist: { x: 0.5, y: 0.53 },
|
| 27 |
-
left_hand: { x: 0.30, y: 0.60 },
|
| 28 |
-
right_hand: { x: 0.70, y: 0.60 },
|
| 29 |
-
feet_center: { x: 0.5, y: 0.93 },
|
| 30 |
-
};
|
| 31 |
-
|
| 32 |
-
export const SNAP_THRESHOLD_PX = 80;
|
| 33 |
-
export const MAGNETIC_START_PX = 160;
|
| 34 |
export const DOUBLE_TAP_MS = 300;
|
| 35 |
export const DRAG_DEAD_ZONE_PX = 10;
|
|
|
|
| 1 |
export const CATEGORIES = [
|
| 2 |
+
{ id: 'princess', emoji: '\u{1F478}', label: 'Princess', zIndex: 2, scale: 0.8 },
|
| 3 |
+
{ id: 'crown', emoji: '\u{1F451}', label: 'Crown', zIndex: 6, scale: 0.3 },
|
| 4 |
+
{ id: 'dress', emoji: '\u{1F457}', label: 'Dress', zIndex: 3, scale: 0.6 },
|
| 5 |
+
{ id: 'top', emoji: '\u{1F455}', label: 'Top', zIndex: 4, scale: 0.5 },
|
| 6 |
+
{ id: 'skirt', emoji: '\u{1FA73}', label: 'Skirt', zIndex: 3, scale: 0.5 },
|
| 7 |
+
{ id: 'shoes', emoji: '\u{1F460}', label: 'Shoes', zIndex: 1, scale: 0.3 },
|
| 8 |
+
{ id: 'cape', emoji: '\u{1F9E3}', label: 'Cape', zIndex: 4, scale: 0.6 },
|
| 9 |
+
{ id: 'wand', emoji: '\u{1FA84}', label: 'Wand', zIndex: 7, scale: 0.3 },
|
| 10 |
+
{ id: 'necklace', emoji: '\u{1F4FF}', label: 'Necklace', zIndex: 5, scale: 0.25 },
|
| 11 |
+
{ id: 'earrings', emoji: '\u2728', label: 'Earrings', zIndex: 6, scale: 0.2 },
|
| 12 |
+
{ id: 'bag', emoji: '\u{1F45C}', label: 'Bag', zIndex: 7, scale: 0.3 },
|
| 13 |
+
{ id: 'other', emoji: '\u{1F3A8}', label: 'Other', zIndex: 5, scale: 0.5 },
|
| 14 |
];
|
| 15 |
|
| 16 |
export function getCategoryInfo(id) {
|
| 17 |
+
return CATEGORIES.find(c => c.id === id) || CATEGORIES[CATEGORIES.length - 1];
|
| 18 |
}
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
export const DOUBLE_TAP_MS = 300;
|
| 21 |
export const DRAG_DEAD_ZONE_PX = 10;
|
js/segmentation.js
CHANGED
|
@@ -1,22 +1,53 @@
|
|
| 1 |
-
// Background removal using @huggingface/transformers with
|
| 2 |
-
// Runs entirely in the browser (WebGPU with WASM fallback)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
let transformers = null;
|
| 5 |
let loading = false;
|
| 6 |
let loadPromise = null;
|
| 7 |
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
if (transformers) return transformers;
|
| 13 |
transformers = await import(
|
| 14 |
'https://cdn.jsdelivr.net/npm/@huggingface/transformers@3'
|
| 15 |
);
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
| 20 |
}
|
| 21 |
return transformers;
|
| 22 |
}
|
|
@@ -39,69 +70,72 @@ async function _doLoad(onProgress) {
|
|
| 39 |
|
| 40 |
if (onProgress) onProgress({ status: 'loading', message: 'Loading AI model...' });
|
| 41 |
|
| 42 |
-
const
|
| 43 |
-
|
| 44 |
-
dtype: 'fp32',
|
| 45 |
-
progress_callback: (p) => {
|
| 46 |
-
if (onProgress && p.progress != null) {
|
| 47 |
-
onProgress({
|
| 48 |
-
status: 'downloading',
|
| 49 |
-
message: `Downloading model: ${Math.round(p.progress)}%`,
|
| 50 |
-
progress: p.progress,
|
| 51 |
-
});
|
| 52 |
-
}
|
| 53 |
-
},
|
| 54 |
-
});
|
| 55 |
|
| 56 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
|
| 58 |
-
|
|
|
|
|
|
|
| 59 |
}
|
| 60 |
|
| 61 |
/**
|
| 62 |
-
* Decode image once
|
| 63 |
-
*
|
|
|
|
|
|
|
| 64 |
*/
|
| 65 |
-
async function
|
| 66 |
-
// Decode full image once just to get dimensions, then close immediately
|
| 67 |
const probe = await createImageBitmap(imageBlob);
|
| 68 |
const fullW = probe.width;
|
| 69 |
const fullH = probe.height;
|
| 70 |
probe.close();
|
| 71 |
|
| 72 |
-
|
| 73 |
-
const
|
| 74 |
-
const
|
| 75 |
-
const outH = Math.round(fullH * outRatio);
|
| 76 |
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
resizeWidth: outW, resizeHeight: outH, resizeQuality: 'medium',
|
| 80 |
});
|
| 81 |
-
const
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
// Model size (smaller, for inference)
|
| 87 |
-
const modRatio = Math.min(1, MODEL_DIM / Math.max(outW, outH));
|
| 88 |
-
const modW = Math.round(outW * modRatio);
|
| 89 |
-
const modH = Math.round(outH * modRatio);
|
| 90 |
-
|
| 91 |
-
let modelBlob;
|
| 92 |
-
if (modW === outW && modH === outH) {
|
| 93 |
-
modelBlob = outputBlob;
|
| 94 |
-
} else {
|
| 95 |
-
const modBitmap = await createImageBitmap(outputBlob, {
|
| 96 |
-
resizeWidth: modW, resizeHeight: modH, resizeQuality: 'medium',
|
| 97 |
-
});
|
| 98 |
-
const modCanvas = new OffscreenCanvas(modW, modH);
|
| 99 |
-
modCanvas.getContext('2d').drawImage(modBitmap, 0, 0);
|
| 100 |
-
modBitmap.close();
|
| 101 |
-
modelBlob = await modCanvas.convertToBlob({ type: 'image/png' });
|
| 102 |
-
}
|
| 103 |
-
|
| 104 |
-
return { outputBlob, modelBlob };
|
| 105 |
}
|
| 106 |
|
| 107 |
/**
|
|
@@ -111,36 +145,48 @@ async function prepareImages(imageBlob) {
|
|
| 111 |
* @returns {Promise<Blob>} - PNG with transparent background
|
| 112 |
*/
|
| 113 |
export async function removeBackground(imageBlob, onProgress) {
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
|
| 118 |
// 2. Load model
|
| 119 |
-
|
|
|
|
| 120 |
const { RawImage } = await getTransformers();
|
|
|
|
| 121 |
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
//
|
| 125 |
-
|
|
|
|
| 126 |
const image = await RawImage.fromURL(url);
|
| 127 |
URL.revokeObjectURL(url);
|
| 128 |
-
|
| 129 |
const { pixel_values } = await processor(image);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
const { output } = await model({ input: pixel_values });
|
| 131 |
|
| 132 |
const maskData = output[0].data; // Float32Array
|
| 133 |
const maskH = output[0].dims[1] || output.dims?.[2];
|
| 134 |
const maskW = output[0].dims[2] || output.dims?.[3];
|
| 135 |
|
| 136 |
-
// Dispose tensors
|
|
|
|
|
|
|
| 137 |
if (pixel_values.dispose) pixel_values.dispose();
|
| 138 |
if (output[0].dispose) output[0].dispose();
|
| 139 |
-
|
| 140 |
-
// 4. Free model from JS memory (Cache API keeps weights on disk for HTTPS)
|
| 141 |
disposeModel(model);
|
| 142 |
|
| 143 |
-
//
|
|
|
|
| 144 |
const bitmap = await createImageBitmap(outputBlob);
|
| 145 |
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
|
| 146 |
const ctx = canvas.getContext('2d');
|
|
@@ -150,21 +196,27 @@ export async function removeBackground(imageBlob, onProgress) {
|
|
| 150 |
const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
| 151 |
const scaleX = maskW / canvas.width;
|
| 152 |
const scaleY = maskH / canvas.height;
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
const mx = Math.min(Math.floor(x * scaleX), maskW - 1);
|
| 157 |
-
const
|
| 158 |
-
|
| 159 |
-
const alpha = Math.round(Math.max(0, Math.min(1, maskVal)) * 255);
|
| 160 |
-
imgData.data[(y * canvas.width + x) * 4 + 3] = alpha;
|
| 161 |
}
|
| 162 |
}
|
| 163 |
ctx.putImageData(imgData, 0, 0);
|
| 164 |
|
| 165 |
-
|
|
|
|
| 166 |
|
| 167 |
-
|
|
|
|
| 168 |
}
|
| 169 |
|
| 170 |
function disposeModel(model) {
|
|
|
|
| 1 |
+
// Background removal using @huggingface/transformers with MODNet.
|
| 2 |
+
// Runs entirely in the browser (WebGPU with WASM fallback).
|
| 3 |
+
//
|
| 4 |
+
// We use Xenova/modnet (portrait matting, ~6.5M params) instead of
|
| 5 |
+
// briaai/RMBG-1.4 because RMBG's ONNX has a fixed [1,3,1024,1024] input
|
| 6 |
+
// shape. U²-Net activations at 1024x1024 blow the WebContent heap on
|
| 7 |
+
// phones without WebGPU, triggering a Jetsam kill mid-inference. MODNet
|
| 8 |
+
// accepts dynamic shapes and preprocesses to a 512-short-edge image,
|
| 9 |
+
// which is ~4x less activation memory and actually fits on mobile WASM.
|
| 10 |
|
| 11 |
let transformers = null;
|
| 12 |
let loading = false;
|
| 13 |
let loadPromise = null;
|
| 14 |
|
| 15 |
+
// MODNet preprocesses to 512 on the short edge, so activation memory is
|
| 16 |
+
// roughly proportional to the output image we feed it. OUTPUT_DIM caps the
|
| 17 |
+
// saved image (and indirectly the model's working resolution).
|
| 18 |
+
const OUTPUT_DIM = 768;
|
| 19 |
+
const MODEL_ID = 'Xenova/modnet';
|
| 20 |
+
|
| 21 |
+
// Workarounds for transformers.js + onnxruntime-web on iOS Safari
|
| 22 |
+
// (and memory-constrained mobile browsers in general).
|
| 23 |
+
//
|
| 24 |
+
// Cribbed from cmorenogit/app-profile@01081eb, which hit the same
|
| 25 |
+
// "tab silently dies during model load/inference" problem:
|
| 26 |
+
//
|
| 27 |
+
// 1. numThreads=1 — multi-threaded WASM on JavaScriptCore (iOS Safari)
|
| 28 |
+
// spirals activation memory across worker threads. Issue #1242.
|
| 29 |
+
// Slower, but single-threaded stays under the tab budget.
|
| 30 |
+
// 2. useBrowserCache=false — the Cache API adds a full-size *copy* of
|
| 31 |
+
// the model during loading (fetch buffer -> Cache -> WASM heap),
|
| 32 |
+
// tripling peak. Re-downloading each session is cheaper than
|
| 33 |
+
// crashing.
|
| 34 |
+
// 3. No WebGPU on iOS — ONNX Runtime JSEP bug #26827 breaks it on
|
| 35 |
+
// iOS 18.x Safari anyway; we were already on WASM in practice.
|
| 36 |
+
//
|
| 37 |
+
// These are harmless on Android/desktop (just slightly slower loads),
|
| 38 |
+
// so we apply them unconditionally rather than sniffing UA.
|
| 39 |
+
export async function getTransformers() {
|
| 40 |
if (transformers) return transformers;
|
| 41 |
transformers = await import(
|
| 42 |
'https://cdn.jsdelivr.net/npm/@huggingface/transformers@3'
|
| 43 |
);
|
| 44 |
+
const env = transformers.env;
|
| 45 |
+
env.allowLocalModels = false;
|
| 46 |
+
// (1) no Cache API copy — fetch goes straight to WASM heap
|
| 47 |
+
env.useBrowserCache = false;
|
| 48 |
+
// (2) single-threaded WASM — kills the multi-copy activation spiral
|
| 49 |
+
if (env.backends?.onnx?.wasm) {
|
| 50 |
+
env.backends.onnx.wasm.numThreads = 1;
|
| 51 |
}
|
| 52 |
return transformers;
|
| 53 |
}
|
|
|
|
| 70 |
|
| 71 |
if (onProgress) onProgress({ status: 'loading', message: 'Loading AI model...' });
|
| 72 |
|
| 73 |
+
const hasWebGPU = typeof navigator !== 'undefined' && !!navigator.gpu;
|
| 74 |
+
const device = hasWebGPU ? 'webgpu' : 'wasm';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
|
| 76 |
+
const dtypeProgress = (p) => {
|
| 77 |
+
if (onProgress && p.progress != null) {
|
| 78 |
+
onProgress({
|
| 79 |
+
status: 'downloading',
|
| 80 |
+
message: `Downloading model: ${Math.round(p.progress)}%`,
|
| 81 |
+
progress: p.progress,
|
| 82 |
+
});
|
| 83 |
+
}
|
| 84 |
+
};
|
| 85 |
+
|
| 86 |
+
// Try smallest dtype first to keep peak memory low on phones.
|
| 87 |
+
const dtypePreference = device === 'webgpu'
|
| 88 |
+
? ['fp16', 'q8', 'fp32']
|
| 89 |
+
: ['q8', 'fp16', 'fp32'];
|
| 90 |
+
|
| 91 |
+
let model = null;
|
| 92 |
+
let loadedDtype = null;
|
| 93 |
+
let lastErr = null;
|
| 94 |
+
for (const dtype of dtypePreference) {
|
| 95 |
+
try {
|
| 96 |
+
model = await AutoModel.from_pretrained(MODEL_ID, {
|
| 97 |
+
device,
|
| 98 |
+
dtype,
|
| 99 |
+
progress_callback: dtypeProgress,
|
| 100 |
+
});
|
| 101 |
+
loadedDtype = dtype;
|
| 102 |
+
console.log(`[segmentation] loaded ${MODEL_ID} with dtype=${dtype}, device=${device}`);
|
| 103 |
+
break;
|
| 104 |
+
} catch (err) {
|
| 105 |
+
console.warn(`[segmentation] dtype=${dtype} failed:`, err && err.message);
|
| 106 |
+
lastErr = err;
|
| 107 |
+
}
|
| 108 |
+
}
|
| 109 |
+
if (!model) throw lastErr || new Error(`Failed to load ${MODEL_ID}`);
|
| 110 |
|
| 111 |
+
const processor = await AutoProcessor.from_pretrained(MODEL_ID);
|
| 112 |
+
|
| 113 |
+
return { model, processor, dtype: loadedDtype, device };
|
| 114 |
}
|
| 115 |
|
| 116 |
/**
|
| 117 |
+
* Decode the camera image once, downscaled to OUTPUT_DIM. The model's own
|
| 118 |
+
* processor will further resize this to short-edge 512 for inference, so
|
| 119 |
+
* the same blob is used both as the model input and as the canvas we apply
|
| 120 |
+
* the final mask to.
|
| 121 |
*/
|
| 122 |
+
async function prepareOutputImage(imageBlob) {
|
|
|
|
| 123 |
const probe = await createImageBitmap(imageBlob);
|
| 124 |
const fullW = probe.width;
|
| 125 |
const fullH = probe.height;
|
| 126 |
probe.close();
|
| 127 |
|
| 128 |
+
const ratio = Math.min(1, OUTPUT_DIM / Math.max(fullW, fullH));
|
| 129 |
+
const w = Math.round(fullW * ratio);
|
| 130 |
+
const h = Math.round(fullH * ratio);
|
|
|
|
| 131 |
|
| 132 |
+
const bitmap = await createImageBitmap(imageBlob, {
|
| 133 |
+
resizeWidth: w, resizeHeight: h, resizeQuality: 'medium',
|
|
|
|
| 134 |
});
|
| 135 |
+
const canvas = new OffscreenCanvas(w, h);
|
| 136 |
+
canvas.getContext('2d').drawImage(bitmap, 0, 0);
|
| 137 |
+
bitmap.close();
|
| 138 |
+
return canvas.convertToBlob({ type: 'image/png' });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
}
|
| 140 |
|
| 141 |
/**
|
|
|
|
| 145 |
* @returns {Promise<Blob>} - PNG with transparent background
|
| 146 |
*/
|
| 147 |
export async function removeBackground(imageBlob, onProgress) {
|
| 148 |
+
const tag = (message) => {
|
| 149 |
+
if (onProgress) onProgress({ status: 'processing', message });
|
| 150 |
+
};
|
| 151 |
+
|
| 152 |
+
// 1. Downscale the camera photo BEFORE loading the model (lower peak mem)
|
| 153 |
+
tag('bg: decoding photo');
|
| 154 |
+
const outputBlob = await prepareOutputImage(imageBlob);
|
| 155 |
|
| 156 |
// 2. Load model
|
| 157 |
+
tag('bg: loading model');
|
| 158 |
+
const { model, processor, dtype, device } = await loadModel(onProgress);
|
| 159 |
const { RawImage } = await getTransformers();
|
| 160 |
+
const label = `bg[${dtype}/${device}]`;
|
| 161 |
|
| 162 |
+
// 3. Preprocess via the model's own processor. MODNet takes dynamic
|
| 163 |
+
// input sizes, short edge 512, so the working resolution scales
|
| 164 |
+
// with the output blob we feed it.
|
| 165 |
+
tag(`${label}: preprocessing`);
|
| 166 |
+
const url = URL.createObjectURL(outputBlob);
|
| 167 |
const image = await RawImage.fromURL(url);
|
| 168 |
URL.revokeObjectURL(url);
|
|
|
|
| 169 |
const { pixel_values } = await processor(image);
|
| 170 |
+
tag(`${label}: tensor ${pixel_values.dims.join('x')}`);
|
| 171 |
+
|
| 172 |
+
// 4. Forward pass — biggest memory peak. MODNet activations scale with
|
| 173 |
+
// the input tensor size; the tag above shows the actual dims.
|
| 174 |
+
tag(`${label}: running inference`);
|
| 175 |
const { output } = await model({ input: pixel_values });
|
| 176 |
|
| 177 |
const maskData = output[0].data; // Float32Array
|
| 178 |
const maskH = output[0].dims[1] || output.dims?.[2];
|
| 179 |
const maskW = output[0].dims[2] || output.dims?.[3];
|
| 180 |
|
| 181 |
+
// 5. Dispose tensors and model immediately so the mask-application step
|
| 182 |
+
// has the heap to itself
|
| 183 |
+
tag(`${label}: disposing model`);
|
| 184 |
if (pixel_values.dispose) pixel_values.dispose();
|
| 185 |
if (output[0].dispose) output[0].dispose();
|
|
|
|
|
|
|
| 186 |
disposeModel(model);
|
| 187 |
|
| 188 |
+
// 6. Apply mask to the output-sized image
|
| 189 |
+
tag(`${label}: applying mask`);
|
| 190 |
const bitmap = await createImageBitmap(outputBlob);
|
| 191 |
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
|
| 192 |
const ctx = canvas.getContext('2d');
|
|
|
|
| 196 |
const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
| 197 |
const scaleX = maskW / canvas.width;
|
| 198 |
const scaleY = maskH / canvas.height;
|
| 199 |
+
const data = imgData.data;
|
| 200 |
+
const cw = canvas.width;
|
| 201 |
+
const ch = canvas.height;
|
| 202 |
+
|
| 203 |
+
for (let y = 0; y < ch; y++) {
|
| 204 |
+
const my = Math.min(Math.floor(y * scaleY), maskH - 1);
|
| 205 |
+
const maskRow = my * maskW;
|
| 206 |
+
const rowOffset = y * cw * 4;
|
| 207 |
+
for (let x = 0; x < cw; x++) {
|
| 208 |
const mx = Math.min(Math.floor(x * scaleX), maskW - 1);
|
| 209 |
+
const v = maskData[maskRow + mx];
|
| 210 |
+
data[rowOffset + x * 4 + 3] = v <= 0 ? 0 : v >= 1 ? 255 : Math.round(v * 255);
|
|
|
|
|
|
|
| 211 |
}
|
| 212 |
}
|
| 213 |
ctx.putImageData(imgData, 0, 0);
|
| 214 |
|
| 215 |
+
tag(`${label}: encoding result`);
|
| 216 |
+
const result = await canvas.convertToBlob({ type: 'image/png' });
|
| 217 |
|
| 218 |
+
if (onProgress) onProgress({ status: 'done', message: `${label}: done` });
|
| 219 |
+
return result;
|
| 220 |
}
|
| 221 |
|
| 222 |
function disposeModel(model) {
|
js/store.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
const DB_NAME = 'princess-game';
|
| 2 |
-
const DB_VERSION =
|
| 3 |
|
| 4 |
let db = null;
|
| 5 |
|
|
@@ -27,6 +27,9 @@ function openDB() {
|
|
| 27 |
if (!d.objectStoreNames.contains('photos')) {
|
| 28 |
d.createObjectStore('photos', { keyPath: 'id' });
|
| 29 |
}
|
|
|
|
|
|
|
|
|
|
| 30 |
};
|
| 31 |
req.onsuccess = () => { db = req.result; resolve(db); };
|
| 32 |
req.onerror = () => reject(req.error);
|
|
@@ -47,67 +50,88 @@ function reqToPromise(req) {
|
|
| 47 |
});
|
| 48 |
}
|
| 49 |
|
| 50 |
-
// ---
|
| 51 |
|
| 52 |
-
export async function
|
| 53 |
const id = uuid();
|
| 54 |
-
const store = await tx('
|
| 55 |
-
const record = { id, name
|
| 56 |
await reqToPromise(store.put(record));
|
| 57 |
return record;
|
| 58 |
}
|
| 59 |
|
| 60 |
-
export async function
|
| 61 |
-
const store = await tx('
|
| 62 |
return reqToPromise(store.getAll());
|
| 63 |
}
|
| 64 |
|
| 65 |
-
export async function
|
| 66 |
-
const store = await tx('
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
const store = await tx('princesses', 'readwrite');
|
| 72 |
-
const p = await reqToPromise(store.get(id));
|
| 73 |
-
if (p) {
|
| 74 |
-
p.anchors = anchors;
|
| 75 |
-
await reqToPromise(store.put(p));
|
| 76 |
}
|
| 77 |
}
|
| 78 |
|
| 79 |
-
export async function
|
| 80 |
-
const store = await tx('
|
| 81 |
-
|
| 82 |
-
if (p) {
|
| 83 |
-
p.outfit = outfit;
|
| 84 |
-
await reqToPromise(store.put(p));
|
| 85 |
-
}
|
| 86 |
}
|
| 87 |
|
| 88 |
-
// ---
|
| 89 |
|
| 90 |
-
export async function
|
| 91 |
const id = uuid();
|
| 92 |
const store = await tx('clothes', 'readwrite');
|
| 93 |
-
const record = {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
await reqToPromise(store.put(record));
|
| 95 |
return record;
|
| 96 |
}
|
| 97 |
|
| 98 |
-
export async function
|
| 99 |
const store = await tx('clothes');
|
| 100 |
return reqToPromise(store.getAll());
|
| 101 |
}
|
| 102 |
|
| 103 |
-
export async function
|
| 104 |
-
const store = await tx('
|
| 105 |
await reqToPromise(store.delete(id));
|
| 106 |
}
|
| 107 |
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
}
|
| 112 |
|
| 113 |
// --- Photos (captured source images) ---
|
|
@@ -125,6 +149,11 @@ export async function loadPhotos() {
|
|
| 125 |
return reqToPromise(store.getAll());
|
| 126 |
}
|
| 127 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
export async function deletePhoto(id) {
|
| 129 |
const store = await tx('photos', 'readwrite');
|
| 130 |
await reqToPromise(store.delete(id));
|
|
|
|
| 1 |
const DB_NAME = 'princess-game';
|
| 2 |
+
const DB_VERSION = 3;
|
| 3 |
|
| 4 |
let db = null;
|
| 5 |
|
|
|
|
| 27 |
if (!d.objectStoreNames.contains('photos')) {
|
| 28 |
d.createObjectStore('photos', { keyPath: 'id' });
|
| 29 |
}
|
| 30 |
+
if (!d.objectStoreNames.contains('drawings')) {
|
| 31 |
+
d.createObjectStore('drawings', { keyPath: 'id' });
|
| 32 |
+
}
|
| 33 |
};
|
| 34 |
req.onsuccess = () => { db = req.result; resolve(db); };
|
| 35 |
req.onerror = () => reject(req.error);
|
|
|
|
| 50 |
});
|
| 51 |
}
|
| 52 |
|
| 53 |
+
// --- Drawings (saved canvases with placed items) ---
|
| 54 |
|
| 55 |
+
export async function saveDrawing({ name, items }) {
|
| 56 |
const id = uuid();
|
| 57 |
+
const store = await tx('drawings', 'readwrite');
|
| 58 |
+
const record = { id, name: name || '', items: items || [], createdAt: Date.now() };
|
| 59 |
await reqToPromise(store.put(record));
|
| 60 |
return record;
|
| 61 |
}
|
| 62 |
|
| 63 |
+
export async function loadDrawings() {
|
| 64 |
+
const store = await tx('drawings');
|
| 65 |
return reqToPromise(store.getAll());
|
| 66 |
}
|
| 67 |
|
| 68 |
+
export async function updateDrawingItems(id, items) {
|
| 69 |
+
const store = await tx('drawings', 'readwrite');
|
| 70 |
+
const d = await reqToPromise(store.get(id));
|
| 71 |
+
if (d) {
|
| 72 |
+
d.items = items;
|
| 73 |
+
await reqToPromise(store.put(d));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
}
|
| 75 |
}
|
| 76 |
|
| 77 |
+
export async function deleteDrawing(id) {
|
| 78 |
+
const store = await tx('drawings', 'readwrite');
|
| 79 |
+
await reqToPromise(store.delete(id));
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
}
|
| 81 |
|
| 82 |
+
// --- Items (all user-uploaded images: princesses, clothing, anything) ---
|
| 83 |
|
| 84 |
+
export async function saveItem({ name, category, imageBlob, scale, zIndex }) {
|
| 85 |
const id = uuid();
|
| 86 |
const store = await tx('clothes', 'readwrite');
|
| 87 |
+
const record = {
|
| 88 |
+
id, name: name || '', category: category || 'other',
|
| 89 |
+
imageBlob, scale: scale || 0.5, zIndex: zIndex || 5,
|
| 90 |
+
createdAt: Date.now(),
|
| 91 |
+
};
|
| 92 |
await reqToPromise(store.put(record));
|
| 93 |
return record;
|
| 94 |
}
|
| 95 |
|
| 96 |
+
export async function loadItems() {
|
| 97 |
const store = await tx('clothes');
|
| 98 |
return reqToPromise(store.getAll());
|
| 99 |
}
|
| 100 |
|
| 101 |
+
export async function deleteItem(id) {
|
| 102 |
+
const store = await tx('clothes', 'readwrite');
|
| 103 |
await reqToPromise(store.delete(id));
|
| 104 |
}
|
| 105 |
|
| 106 |
+
// --- Legacy compat: migrate old princesses into items on load ---
|
| 107 |
+
|
| 108 |
+
export async function migrateOldData() {
|
| 109 |
+
const princessStore = await tx('princesses');
|
| 110 |
+
const oldPrincesses = await reqToPromise(princessStore.getAll());
|
| 111 |
+
|
| 112 |
+
if (oldPrincesses.length > 0) {
|
| 113 |
+
for (const p of oldPrincesses) {
|
| 114 |
+
await saveItem({
|
| 115 |
+
name: p.name || 'Princess',
|
| 116 |
+
category: 'princess',
|
| 117 |
+
imageBlob: p.imageBlob,
|
| 118 |
+
scale: 0.8,
|
| 119 |
+
zIndex: 2,
|
| 120 |
+
});
|
| 121 |
+
}
|
| 122 |
+
// Also migrate any saved outfits into drawings
|
| 123 |
+
for (const p of oldPrincesses) {
|
| 124 |
+
if (p.outfit && p.outfit.length > 0) {
|
| 125 |
+
await saveDrawing({
|
| 126 |
+
name: p.name || 'Drawing',
|
| 127 |
+
items: p.outfit,
|
| 128 |
+
});
|
| 129 |
+
}
|
| 130 |
+
}
|
| 131 |
+
// Clear old store
|
| 132 |
+
const writeStore = await tx('princesses', 'readwrite');
|
| 133 |
+
await reqToPromise(writeStore.clear());
|
| 134 |
+
}
|
| 135 |
}
|
| 136 |
|
| 137 |
// --- Photos (captured source images) ---
|
|
|
|
| 149 |
return reqToPromise(store.getAll());
|
| 150 |
}
|
| 151 |
|
| 152 |
+
export async function updatePhoto(photo) {
|
| 153 |
+
const store = await tx('photos', 'readwrite');
|
| 154 |
+
await reqToPromise(store.put(photo));
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
export async function deletePhoto(id) {
|
| 158 |
const store = await tx('photos', 'readwrite');
|
| 159 |
await reqToPromise(store.delete(id));
|
js/upload.js
CHANGED
|
@@ -1,29 +1,66 @@
|
|
| 1 |
-
import { CATEGORIES, getCategoryInfo
|
| 2 |
-
import {
|
| 3 |
-
import { removeBackground } from './
|
| 4 |
-
import { autoSegment } from './auto-segment.js';
|
| 5 |
import { showSegmentReview } from './segment-review.js';
|
| 6 |
import { showPolygonSelector } from './polygon-select.js';
|
| 7 |
-
import {
|
| 8 |
|
| 9 |
-
let
|
| 10 |
-
let selectedCategory = 'dress';
|
| 11 |
let imageBlob = null; // original photo
|
| 12 |
let croppedBlob = null; // after polygon crop (or same as imageBlob if skipped)
|
| 13 |
let polygon = null; // normalized polygon coords from selector
|
| 14 |
let segmentedBlob = null; // after background removal
|
| 15 |
let previewURL = null;
|
| 16 |
let croppedURL = null;
|
|
|
|
| 17 |
|
| 18 |
let photoURLs = []; // object URLs for gallery thumbnails
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
export async function initUpload() {
|
|
|
|
| 21 |
const screen = document.getElementById('upload-screen');
|
| 22 |
|
| 23 |
photoURLs.forEach(u => URL.revokeObjectURL(u));
|
| 24 |
photoURLs = [];
|
| 25 |
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
screen.innerHTML = `
|
| 29 |
<div class="upload-header">
|
|
@@ -31,12 +68,6 @@ export async function initUpload() {
|
|
| 31 |
<h2>Add something new!</h2>
|
| 32 |
</div>
|
| 33 |
|
| 34 |
-
<div class="type-toggle">
|
| 35 |
-
<button class="pill active" data-type="princess">\u{1F478} Princess</button>
|
| 36 |
-
<button class="pill" data-type="clothes">\u{1F457} Clothes</button>
|
| 37 |
-
<button class="pill" data-type="full-drawing">\u2728 Full Drawing</button>
|
| 38 |
-
</div>
|
| 39 |
-
|
| 40 |
<div class="camera-zone">
|
| 41 |
<input type="file" accept="image/*" capture="environment" id="camera-input" class="camera-input"/>
|
| 42 |
<label for="camera-input" id="camera-label" class="camera-label">
|
|
@@ -54,7 +85,8 @@ export async function initUpload() {
|
|
| 54 |
photoURLs.push(url);
|
| 55 |
return `
|
| 56 |
<div class="photo-gallery-item" data-photo-id="${p.id}">
|
| 57 |
-
<img src="${url}" class="photo-gallery-thumb" draggable="false"
|
|
|
|
| 58 |
<button class="photo-gallery-delete" data-photo-id="${p.id}">\u00D7</button>
|
| 59 |
</div>
|
| 60 |
`;
|
|
@@ -67,19 +99,24 @@ export async function initUpload() {
|
|
| 67 |
\u2702\uFE0F Select area
|
| 68 |
</button>
|
| 69 |
|
| 70 |
-
<div id="category-section" class="category-picker"
|
| 71 |
<p class="picker-label">What kind?</p>
|
| 72 |
<div class="category-grid" id="category-grid"></div>
|
| 73 |
</div>
|
| 74 |
|
| 75 |
-
<div class="name-input-wrap">
|
| 76 |
<label for="asset-name">Name (optional)</label>
|
| 77 |
<input id="asset-name" type="text" placeholder="Give it a name..." class="name-input"/>
|
| 78 |
</div>
|
| 79 |
|
| 80 |
-
<
|
| 81 |
-
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
|
| 84 |
<div id="upload-status"></div>
|
| 85 |
`;
|
|
@@ -99,6 +136,38 @@ function buildCategoryGrid() {
|
|
| 99 |
`).join('');
|
| 100 |
}
|
| 101 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
function selectImage(blob) {
|
| 103 |
imageBlob = blob;
|
| 104 |
croppedBlob = null;
|
|
@@ -110,32 +179,13 @@ function selectImage(blob) {
|
|
| 110 |
croppedURL = null;
|
| 111 |
|
| 112 |
showPhotoPreview(previewURL);
|
| 113 |
-
document.getElementById('select-area-btn').style.display =
|
| 114 |
-
uploadType === 'full-drawing' ? 'none' : '';
|
| 115 |
document.getElementById('upload-status').innerHTML = '';
|
| 116 |
-
document.getElementById('
|
| 117 |
updateSubmitState();
|
| 118 |
}
|
| 119 |
|
| 120 |
function bindEvents(photos) {
|
| 121 |
-
// Type toggle
|
| 122 |
-
document.querySelectorAll('.type-toggle .pill').forEach(btn => {
|
| 123 |
-
btn.addEventListener('click', () => {
|
| 124 |
-
uploadType = btn.dataset.type;
|
| 125 |
-
document.querySelectorAll('.type-toggle .pill').forEach(b => b.classList.remove('active'));
|
| 126 |
-
btn.classList.add('active');
|
| 127 |
-
document.getElementById('category-section').style.display =
|
| 128 |
-
uploadType === 'clothes' ? '' : 'none';
|
| 129 |
-
// Hide select-area and name for full-drawing mode
|
| 130 |
-
const isFullDrawing = uploadType === 'full-drawing';
|
| 131 |
-
document.getElementById('select-area-btn').style.display =
|
| 132 |
-
(isFullDrawing || !imageBlob) ? 'none' : '';
|
| 133 |
-
document.querySelector('.name-input-wrap').style.display =
|
| 134 |
-
isFullDrawing ? 'none' : '';
|
| 135 |
-
updateSubmitState();
|
| 136 |
-
});
|
| 137 |
-
});
|
| 138 |
-
|
| 139 |
// Category picker
|
| 140 |
document.getElementById('category-grid').addEventListener('click', (e) => {
|
| 141 |
const btn = e.target.closest('.category-btn');
|
|
@@ -145,36 +195,39 @@ function bindEvents(photos) {
|
|
| 145 |
btn.classList.add('selected');
|
| 146 |
});
|
| 147 |
|
| 148 |
-
// Camera input —
|
|
|
|
| 149 |
document.getElementById('camera-input').addEventListener('change', async (e) => {
|
| 150 |
const file = e.target.files[0];
|
| 151 |
if (!file) return;
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
selectImage(
|
| 157 |
});
|
| 158 |
|
| 159 |
// Photo gallery — tap to reuse, X to delete
|
| 160 |
const gallery = document.getElementById('photo-gallery');
|
| 161 |
if (gallery) {
|
| 162 |
gallery.addEventListener('click', async (e) => {
|
| 163 |
-
// Delete button
|
| 164 |
const delBtn = e.target.closest('.photo-gallery-delete');
|
| 165 |
if (delBtn) {
|
| 166 |
e.stopPropagation();
|
| 167 |
const id = delBtn.dataset.photoId;
|
| 168 |
await deletePhoto(id);
|
| 169 |
-
initUpload();
|
| 170 |
return;
|
| 171 |
}
|
| 172 |
-
|
| 173 |
-
// Select photo
|
| 174 |
const item = e.target.closest('.photo-gallery-item');
|
| 175 |
if (!item) return;
|
| 176 |
const photo = photos.find(p => p.id === item.dataset.photoId);
|
| 177 |
-
if (photo)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
});
|
| 179 |
}
|
| 180 |
|
|
@@ -186,6 +239,9 @@ function bindEvents(photos) {
|
|
| 186 |
|
| 187 |
// Submit -> segmentation
|
| 188 |
document.getElementById('submit-btn').addEventListener('click', handleMagicTime);
|
|
|
|
|
|
|
|
|
|
| 189 |
}
|
| 190 |
|
| 191 |
function showPhotoPreview(url, caption) {
|
|
@@ -201,7 +257,7 @@ async function handleSelectArea() {
|
|
| 201 |
if (!previewURL) return;
|
| 202 |
|
| 203 |
const result = await showPolygonSelector(previewURL);
|
| 204 |
-
if (!result) return;
|
| 205 |
|
| 206 |
croppedBlob = result.imageBlob;
|
| 207 |
polygon = result.polygon;
|
|
@@ -214,58 +270,44 @@ async function handleSelectArea() {
|
|
| 214 |
}
|
| 215 |
|
| 216 |
function updateSubmitState() {
|
| 217 |
-
const name = document.getElementById('asset-name').value.trim();
|
| 218 |
const btn = document.getElementById('submit-btn');
|
| 219 |
-
btn.disabled = !imageBlob;
|
| 220 |
}
|
| 221 |
|
| 222 |
async function handleMagicTime() {
|
| 223 |
const name = document.getElementById('asset-name').value.trim();
|
| 224 |
if (!imageBlob) return;
|
| 225 |
|
| 226 |
-
if (uploadType === 'full-drawing') {
|
| 227 |
-
return handleFullDrawing();
|
| 228 |
-
}
|
| 229 |
-
|
| 230 |
const btn = document.getElementById('submit-btn');
|
| 231 |
const status = document.getElementById('upload-status');
|
| 232 |
-
|
| 233 |
-
// For "save without removing", use the cropped version if available
|
| 234 |
const skipBlob = croppedBlob || imageBlob;
|
| 235 |
|
| 236 |
btn.disabled = true;
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
const modelHint = '<p class="progress-hint">This might take a moment.</p>';
|
| 240 |
|
| 241 |
status.innerHTML = `
|
| 242 |
<div class="processing">
|
| 243 |
<span class="crown-spin">\u{1F451}</span>
|
| 244 |
<p id="seg-message">Removing the background!</p>
|
| 245 |
<div id="seg-progress-bar" class="progress-bar"><div id="seg-progress-fill" class="progress-fill"></div></div>
|
| 246 |
-
|
| 247 |
</div>
|
| 248 |
`;
|
| 249 |
|
| 250 |
try {
|
| 251 |
-
// Always segment the FULL original image for best results
|
| 252 |
let segResult = await removeBackground(imageBlob, (info) => {
|
| 253 |
const msg = document.getElementById('seg-message');
|
| 254 |
const fill = document.getElementById('seg-progress-fill');
|
| 255 |
if (msg) msg.textContent = info.message;
|
| 256 |
-
if (fill && info.progress != null) {
|
| 257 |
-
fill.style.width = `${info.progress}%`;
|
| 258 |
-
}
|
| 259 |
});
|
| 260 |
|
| 261 |
-
// If a polygon was drawn, intersect segmentation with the polygon crop
|
| 262 |
if (polygon && polygon.length >= 3) {
|
| 263 |
segResult = await intersectSegmentationWithPolygon(segResult, polygon);
|
| 264 |
}
|
| 265 |
|
| 266 |
segmentedBlob = segResult;
|
| 267 |
|
| 268 |
-
// Show preview with approve/retry
|
| 269 |
const segURL = URL.createObjectURL(segmentedBlob);
|
| 270 |
const beforeURL = previewURL;
|
| 271 |
status.innerHTML = `
|
|
@@ -289,8 +331,14 @@ async function handleMagicTime() {
|
|
| 289 |
</div>
|
| 290 |
`;
|
| 291 |
|
| 292 |
-
document.getElementById('seg-approve').addEventListener('click', () =>
|
| 293 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 294 |
document.getElementById('seg-retry').addEventListener('click', resetToCamera);
|
| 295 |
|
| 296 |
} catch (err) {
|
|
@@ -307,7 +355,7 @@ async function handleMagicTime() {
|
|
| 307 |
`;
|
| 308 |
document.getElementById('seg-skip-err').addEventListener('click', () => saveAsset(name, skipBlob));
|
| 309 |
document.getElementById('seg-retry-err').addEventListener('click', () => {
|
| 310 |
-
|
| 311 |
btn.disabled = false;
|
| 312 |
status.innerHTML = '';
|
| 313 |
});
|
|
@@ -315,11 +363,12 @@ async function handleMagicTime() {
|
|
| 315 |
}
|
| 316 |
|
| 317 |
async function handleFullDrawing() {
|
| 318 |
-
|
|
|
|
|
|
|
| 319 |
const status = document.getElementById('upload-status');
|
| 320 |
|
| 321 |
-
|
| 322 |
-
btn.style.display = 'none';
|
| 323 |
|
| 324 |
function showProgress(message, progress) {
|
| 325 |
const msg = document.getElementById('seg-message');
|
|
@@ -338,15 +387,12 @@ async function handleFullDrawing() {
|
|
| 338 |
`;
|
| 339 |
|
| 340 |
try {
|
| 341 |
-
// Step 1: Remove background
|
| 342 |
const bgRemoved = await removeBackground(imageBlob, (info) => {
|
| 343 |
-
showProgress(info.message, info.progress * 0.4);
|
| 344 |
});
|
| 345 |
|
| 346 |
-
// Brief pause for GC after model disposal
|
| 347 |
await new Promise(r => setTimeout(r, 200));
|
| 348 |
|
| 349 |
-
// Step 2: Auto-segment
|
| 350 |
showProgress('Finding all the parts...', 40);
|
| 351 |
const segments = await autoSegment(bgRemoved, (info) => {
|
| 352 |
const overall = 40 + (info.progress || 0) * 0.4;
|
|
@@ -363,27 +409,23 @@ async function handleFullDrawing() {
|
|
| 363 |
</div>
|
| 364 |
`;
|
| 365 |
document.getElementById('fd-retry').addEventListener('click', () => {
|
| 366 |
-
|
| 367 |
-
btn.disabled = false;
|
| 368 |
status.innerHTML = '';
|
| 369 |
});
|
| 370 |
return;
|
| 371 |
}
|
| 372 |
|
| 373 |
-
// Step 3: Show review overlay
|
| 374 |
showProgress('Ready for review!', 100);
|
| 375 |
const bgRemovedURL = URL.createObjectURL(bgRemoved);
|
| 376 |
const reviewResult = await showSegmentReview(bgRemovedURL, segments);
|
| 377 |
URL.revokeObjectURL(bgRemovedURL);
|
| 378 |
|
| 379 |
if (!reviewResult) {
|
| 380 |
-
|
| 381 |
-
btn.disabled = false;
|
| 382 |
status.innerHTML = '';
|
| 383 |
return;
|
| 384 |
}
|
| 385 |
|
| 386 |
-
// Step 4: Save all approved items
|
| 387 |
status.innerHTML = `
|
| 388 |
<div class="processing">
|
| 389 |
<span class="crown-spin">\u{1F451}</span>
|
|
@@ -394,22 +436,23 @@ async function handleFullDrawing() {
|
|
| 394 |
let savedCount = 0;
|
| 395 |
|
| 396 |
if (reviewResult.princess) {
|
| 397 |
-
await
|
| 398 |
name: '',
|
|
|
|
| 399 |
imageBlob: reviewResult.princess.blob,
|
| 400 |
-
|
|
|
|
| 401 |
});
|
| 402 |
savedCount++;
|
| 403 |
}
|
| 404 |
|
| 405 |
for (const item of reviewResult.clothing) {
|
| 406 |
const catInfo = getCategoryInfo(item.category);
|
| 407 |
-
await
|
| 408 |
name: '',
|
| 409 |
category: item.category,
|
| 410 |
imageBlob: item.blob,
|
| 411 |
-
|
| 412 |
-
scale: 0.8,
|
| 413 |
zIndex: catInfo.zIndex,
|
| 414 |
});
|
| 415 |
savedCount++;
|
|
@@ -437,8 +480,7 @@ async function handleFullDrawing() {
|
|
| 437 |
</div>
|
| 438 |
`;
|
| 439 |
document.getElementById('fd-retry-err').addEventListener('click', () => {
|
| 440 |
-
|
| 441 |
-
btn.disabled = false;
|
| 442 |
status.innerHTML = '';
|
| 443 |
});
|
| 444 |
}
|
|
@@ -449,20 +491,13 @@ function resetToCamera() {
|
|
| 449 |
initUpload();
|
| 450 |
}
|
| 451 |
|
| 452 |
-
/**
|
| 453 |
-
* Intersect a full-image segmentation result with a polygon selection.
|
| 454 |
-
* Keeps only the segmented foreground pixels that fall inside the polygon,
|
| 455 |
-
* then crops to the polygon bounding box.
|
| 456 |
-
*/
|
| 457 |
async function intersectSegmentationWithPolygon(segBlob, poly) {
|
| 458 |
const bitmap = await createImageBitmap(segBlob);
|
| 459 |
const fullW = bitmap.width;
|
| 460 |
const fullH = bitmap.height;
|
| 461 |
|
| 462 |
-
// Convert normalized polygon to pixel coords
|
| 463 |
const pxPoly = poly.map(p => ({ x: p.x * fullW, y: p.y * fullH }));
|
| 464 |
|
| 465 |
-
// Bounding box
|
| 466 |
let minX = fullW, minY = fullH, maxX = 0, maxY = 0;
|
| 467 |
for (const p of pxPoly) {
|
| 468 |
minX = Math.min(minX, p.x);
|
|
@@ -478,7 +513,6 @@ async function intersectSegmentationWithPolygon(segBlob, poly) {
|
|
| 478 |
const cropW = maxX - minX;
|
| 479 |
const cropH = maxY - minY;
|
| 480 |
|
| 481 |
-
// Draw the segmented image clipped to the polygon, cropped to bounding box
|
| 482 |
const canvas = new OffscreenCanvas(cropW, cropH);
|
| 483 |
const ctx = canvas.getContext('2d');
|
| 484 |
|
|
@@ -496,6 +530,7 @@ async function intersectSegmentationWithPolygon(segBlob, poly) {
|
|
| 496 |
}
|
| 497 |
|
| 498 |
async function saveAsset(name, blob) {
|
|
|
|
| 499 |
const status = document.getElementById('upload-status');
|
| 500 |
status.innerHTML = `
|
| 501 |
<div class="processing">
|
|
@@ -504,24 +539,27 @@ async function saveAsset(name, blob) {
|
|
| 504 |
</div>
|
| 505 |
`;
|
| 506 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 507 |
try {
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
category: selectedCategory,
|
| 519 |
-
imageBlob: blob,
|
| 520 |
-
targetAnchor: catInfo.targetAnchor,
|
| 521 |
-
scale: 0.8,
|
| 522 |
-
zIndex: catInfo.zIndex,
|
| 523 |
-
});
|
| 524 |
-
}
|
| 525 |
|
| 526 |
status.innerHTML = `
|
| 527 |
<div class="success">
|
|
@@ -530,11 +568,13 @@ async function saveAsset(name, blob) {
|
|
| 530 |
<a href="#/" class="pill">Back to game</a>
|
| 531 |
</div>
|
| 532 |
`;
|
|
|
|
| 533 |
|
| 534 |
document.getElementById('another-from-photo').addEventListener('click', () => {
|
| 535 |
resetForAnotherSelection();
|
| 536 |
});
|
| 537 |
} catch (err) {
|
|
|
|
| 538 |
status.innerHTML = `
|
| 539 |
<div class="error-state">
|
| 540 |
<p>Oops! Something went wrong.</p>
|
|
@@ -546,22 +586,18 @@ async function saveAsset(name, blob) {
|
|
| 546 |
|
| 547 |
function resetForAnotherSelection() {
|
| 548 |
const status = document.getElementById('upload-status');
|
| 549 |
-
const btn = document.getElementById('submit-btn');
|
| 550 |
status.innerHTML = '';
|
| 551 |
-
|
| 552 |
-
btn.disabled = false;
|
| 553 |
|
| 554 |
-
// Clear crop/segment state but keep the original photo
|
| 555 |
croppedBlob = null;
|
| 556 |
polygon = null;
|
| 557 |
segmentedBlob = null;
|
| 558 |
if (croppedURL) URL.revokeObjectURL(croppedURL);
|
| 559 |
croppedURL = null;
|
| 560 |
|
| 561 |
-
// Reset name
|
| 562 |
document.getElementById('asset-name').value = '';
|
| 563 |
|
| 564 |
-
// Show original photo preview again
|
| 565 |
showPhotoPreview(previewURL);
|
| 566 |
document.getElementById('select-area-btn').style.display = '';
|
| 567 |
document.getElementById('select-area-btn').textContent = '\u2702\uFE0F Select area';
|
|
@@ -570,8 +606,7 @@ function resetForAnotherSelection() {
|
|
| 570 |
}
|
| 571 |
|
| 572 |
function resetState() {
|
| 573 |
-
|
| 574 |
-
selectedCategory = 'dress';
|
| 575 |
imageBlob = null;
|
| 576 |
croppedBlob = null;
|
| 577 |
polygon = null;
|
|
|
|
| 1 |
+
import { CATEGORIES, getCategoryInfo } from './models.js';
|
| 2 |
+
import { saveItem, savePhoto, loadPhotos, updatePhoto, deletePhoto, blobToURL } from './store.js';
|
| 3 |
+
import { removeBackground, autoSegment } from './ml-client.js';
|
|
|
|
| 4 |
import { showSegmentReview } from './segment-review.js';
|
| 5 |
import { showPolygonSelector } from './polygon-select.js';
|
| 6 |
+
import { tagOp } from './diag.js';
|
| 7 |
|
| 8 |
+
let selectedCategory = 'princess';
|
|
|
|
| 9 |
let imageBlob = null; // original photo
|
| 10 |
let croppedBlob = null; // after polygon crop (or same as imageBlob if skipped)
|
| 11 |
let polygon = null; // normalized polygon coords from selector
|
| 12 |
let segmentedBlob = null; // after background removal
|
| 13 |
let previewURL = null;
|
| 14 |
let croppedURL = null;
|
| 15 |
+
let fullDrawingMode = false;
|
| 16 |
|
| 17 |
let photoURLs = []; // object URLs for gallery thumbnails
|
| 18 |
|
| 19 |
+
// Cap the gallery at the N most recent photos. Each thumbnail is an
|
| 20 |
+
// <img> that iOS may decode on scroll-into-view; even downscaled photos
|
| 21 |
+
// add up, and we don't need to show every photo ever taken.
|
| 22 |
+
const GALLERY_MAX = 10;
|
| 23 |
+
|
| 24 |
+
// Any photo bigger than this is almost certainly a legacy full-size
|
| 25 |
+
// capture from before downscalePhoto existed. We rewrite them in place
|
| 26 |
+
// on load so subsequent sessions don't pay the cost again.
|
| 27 |
+
const LEGACY_MIGRATE_BYTES = 800 * 1024;
|
| 28 |
+
|
| 29 |
+
async function migrateLegacyPhotos(photos) {
|
| 30 |
+
for (const p of photos) {
|
| 31 |
+
if (!p.imageBlob || p.imageBlob.size <= LEGACY_MIGRATE_BYTES) continue;
|
| 32 |
+
tagOp(`migrate: ${Math.round(p.imageBlob.size / 1024)}KB photo`);
|
| 33 |
+
try {
|
| 34 |
+
const small = await downscalePhoto(p.imageBlob);
|
| 35 |
+
if (small && small.size < p.imageBlob.size) {
|
| 36 |
+
p.imageBlob = small;
|
| 37 |
+
await updatePhoto(p);
|
| 38 |
+
}
|
| 39 |
+
} catch (err) {
|
| 40 |
+
console.warn('[upload] photo migration failed', err);
|
| 41 |
+
}
|
| 42 |
+
// Yield so iOS gets a chance to reclaim the full-res decode buffer
|
| 43 |
+
// before we touch the next one.
|
| 44 |
+
await new Promise(r => setTimeout(r, 50));
|
| 45 |
+
}
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
export async function initUpload() {
|
| 49 |
+
tagOp('upload: init');
|
| 50 |
const screen = document.getElementById('upload-screen');
|
| 51 |
|
| 52 |
photoURLs.forEach(u => URL.revokeObjectURL(u));
|
| 53 |
photoURLs = [];
|
| 54 |
|
| 55 |
+
let photos = await loadPhotos();
|
| 56 |
+
await migrateLegacyPhotos(photos);
|
| 57 |
+
// Show only the most recent few; keep oldest photos in IDB (user can
|
| 58 |
+
// still delete them explicitly via the × button after we re-render).
|
| 59 |
+
photos = photos
|
| 60 |
+
.slice()
|
| 61 |
+
.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0))
|
| 62 |
+
.slice(0, GALLERY_MAX);
|
| 63 |
+
tagOp(`upload: ${photos.length} gallery photos`);
|
| 64 |
|
| 65 |
screen.innerHTML = `
|
| 66 |
<div class="upload-header">
|
|
|
|
| 68 |
<h2>Add something new!</h2>
|
| 69 |
</div>
|
| 70 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
<div class="camera-zone">
|
| 72 |
<input type="file" accept="image/*" capture="environment" id="camera-input" class="camera-input"/>
|
| 73 |
<label for="camera-input" id="camera-label" class="camera-label">
|
|
|
|
| 85 |
photoURLs.push(url);
|
| 86 |
return `
|
| 87 |
<div class="photo-gallery-item" data-photo-id="${p.id}">
|
| 88 |
+
<img src="${url}" class="photo-gallery-thumb" draggable="false"
|
| 89 |
+
loading="lazy" decoding="async"/>
|
| 90 |
<button class="photo-gallery-delete" data-photo-id="${p.id}">\u00D7</button>
|
| 91 |
</div>
|
| 92 |
`;
|
|
|
|
| 99 |
\u2702\uFE0F Select area
|
| 100 |
</button>
|
| 101 |
|
| 102 |
+
<div id="category-section" class="category-picker">
|
| 103 |
<p class="picker-label">What kind?</p>
|
| 104 |
<div class="category-grid" id="category-grid"></div>
|
| 105 |
</div>
|
| 106 |
|
| 107 |
+
<div class="name-input-wrap" id="name-wrap">
|
| 108 |
<label for="asset-name">Name (optional)</label>
|
| 109 |
<input id="asset-name" type="text" placeholder="Give it a name..." class="name-input"/>
|
| 110 |
</div>
|
| 111 |
|
| 112 |
+
<div class="upload-actions" id="upload-actions" style="display:none">
|
| 113 |
+
<button id="submit-btn" class="big-button magic-btn">
|
| 114 |
+
\u2728 Magic Time! \u2728
|
| 115 |
+
</button>
|
| 116 |
+
<button id="full-drawing-btn" class="pill">
|
| 117 |
+
\u2728 Full Drawing (auto-split)
|
| 118 |
+
</button>
|
| 119 |
+
</div>
|
| 120 |
|
| 121 |
<div id="upload-status"></div>
|
| 122 |
`;
|
|
|
|
| 136 |
`).join('');
|
| 137 |
}
|
| 138 |
|
| 139 |
+
// Modern phone cameras produce ~3000x4000 JPEGs. Fully decoded that's
|
| 140 |
+
// ~48 MB of RGBA pixels, and iOS holds it pinned for the lifetime of the
|
| 141 |
+
// file reference. We only need ~1280 on the long edge for everything
|
| 142 |
+
// downstream (OUTPUT_DIM=768 for save, MODNet preprocesses to short-edge
|
| 143 |
+
// 512 for inference, polygon selector and preview render well below this).
|
| 144 |
+
// Re-encoding as JPEG at 85% keeps the on-disk size tiny too.
|
| 145 |
+
async function downscalePhoto(file, maxLongEdge = 1280) {
|
| 146 |
+
// We have to decode once to read dimensions. Close it immediately.
|
| 147 |
+
const probe = await createImageBitmap(file);
|
| 148 |
+
const { width: origW, height: origH } = probe;
|
| 149 |
+
probe.close();
|
| 150 |
+
|
| 151 |
+
if (Math.max(origW, origH) <= maxLongEdge) {
|
| 152 |
+
return file; // already small enough, keep original
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
const ratio = maxLongEdge / Math.max(origW, origH);
|
| 156 |
+
const w = Math.round(origW * ratio);
|
| 157 |
+
const h = Math.round(origH * ratio);
|
| 158 |
+
|
| 159 |
+
// Second decode directly at the target size — the big one is short-lived.
|
| 160 |
+
const bitmap = await createImageBitmap(file, {
|
| 161 |
+
resizeWidth: w,
|
| 162 |
+
resizeHeight: h,
|
| 163 |
+
resizeQuality: 'medium',
|
| 164 |
+
});
|
| 165 |
+
const canvas = new OffscreenCanvas(w, h);
|
| 166 |
+
canvas.getContext('2d').drawImage(bitmap, 0, 0);
|
| 167 |
+
bitmap.close();
|
| 168 |
+
return canvas.convertToBlob({ type: 'image/jpeg', quality: 0.85 });
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
function selectImage(blob) {
|
| 172 |
imageBlob = blob;
|
| 173 |
croppedBlob = null;
|
|
|
|
| 179 |
croppedURL = null;
|
| 180 |
|
| 181 |
showPhotoPreview(previewURL);
|
| 182 |
+
document.getElementById('select-area-btn').style.display = '';
|
|
|
|
| 183 |
document.getElementById('upload-status').innerHTML = '';
|
| 184 |
+
document.getElementById('upload-actions').style.display = '';
|
| 185 |
updateSubmitState();
|
| 186 |
}
|
| 187 |
|
| 188 |
function bindEvents(photos) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
// Category picker
|
| 190 |
document.getElementById('category-grid').addEventListener('click', (e) => {
|
| 191 |
const btn = e.target.closest('.category-btn');
|
|
|
|
| 195 |
btn.classList.add('selected');
|
| 196 |
});
|
| 197 |
|
| 198 |
+
// Camera input — downscale immediately so we never hold the full-res
|
| 199 |
+
// camera JPEG, then save to gallery.
|
| 200 |
document.getElementById('camera-input').addEventListener('change', async (e) => {
|
| 201 |
const file = e.target.files[0];
|
| 202 |
if (!file) return;
|
| 203 |
+
tagOp(`capture: downscaling ${Math.round(file.size / 1024)}KB`);
|
| 204 |
+
const resized = await downscalePhoto(file);
|
| 205 |
+
tagOp(`capture: downscaled -> ${Math.round(resized.size / 1024)}KB`);
|
| 206 |
+
await savePhoto(resized);
|
| 207 |
+
selectImage(resized);
|
| 208 |
});
|
| 209 |
|
| 210 |
// Photo gallery — tap to reuse, X to delete
|
| 211 |
const gallery = document.getElementById('photo-gallery');
|
| 212 |
if (gallery) {
|
| 213 |
gallery.addEventListener('click', async (e) => {
|
|
|
|
| 214 |
const delBtn = e.target.closest('.photo-gallery-delete');
|
| 215 |
if (delBtn) {
|
| 216 |
e.stopPropagation();
|
| 217 |
const id = delBtn.dataset.photoId;
|
| 218 |
await deletePhoto(id);
|
| 219 |
+
initUpload();
|
| 220 |
return;
|
| 221 |
}
|
|
|
|
|
|
|
| 222 |
const item = e.target.closest('.photo-gallery-item');
|
| 223 |
if (!item) return;
|
| 224 |
const photo = photos.find(p => p.id === item.dataset.photoId);
|
| 225 |
+
if (photo) {
|
| 226 |
+
// Downscale is idempotent — no-op for photos saved after this fix,
|
| 227 |
+
// catches any full-res photos left over from before.
|
| 228 |
+
const resized = await downscalePhoto(photo.imageBlob);
|
| 229 |
+
selectImage(resized);
|
| 230 |
+
}
|
| 231 |
});
|
| 232 |
}
|
| 233 |
|
|
|
|
| 239 |
|
| 240 |
// Submit -> segmentation
|
| 241 |
document.getElementById('submit-btn').addEventListener('click', handleMagicTime);
|
| 242 |
+
|
| 243 |
+
// Full drawing mode
|
| 244 |
+
document.getElementById('full-drawing-btn').addEventListener('click', handleFullDrawing);
|
| 245 |
}
|
| 246 |
|
| 247 |
function showPhotoPreview(url, caption) {
|
|
|
|
| 257 |
if (!previewURL) return;
|
| 258 |
|
| 259 |
const result = await showPolygonSelector(previewURL);
|
| 260 |
+
if (!result) return;
|
| 261 |
|
| 262 |
croppedBlob = result.imageBlob;
|
| 263 |
polygon = result.polygon;
|
|
|
|
| 270 |
}
|
| 271 |
|
| 272 |
function updateSubmitState() {
|
|
|
|
| 273 |
const btn = document.getElementById('submit-btn');
|
| 274 |
+
if (btn) btn.disabled = !imageBlob;
|
| 275 |
}
|
| 276 |
|
| 277 |
async function handleMagicTime() {
|
| 278 |
const name = document.getElementById('asset-name').value.trim();
|
| 279 |
if (!imageBlob) return;
|
| 280 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 281 |
const btn = document.getElementById('submit-btn');
|
| 282 |
const status = document.getElementById('upload-status');
|
|
|
|
|
|
|
| 283 |
const skipBlob = croppedBlob || imageBlob;
|
| 284 |
|
| 285 |
btn.disabled = true;
|
| 286 |
+
document.getElementById('upload-actions').style.display = 'none';
|
|
|
|
|
|
|
| 287 |
|
| 288 |
status.innerHTML = `
|
| 289 |
<div class="processing">
|
| 290 |
<span class="crown-spin">\u{1F451}</span>
|
| 291 |
<p id="seg-message">Removing the background!</p>
|
| 292 |
<div id="seg-progress-bar" class="progress-bar"><div id="seg-progress-fill" class="progress-fill"></div></div>
|
| 293 |
+
<p class="progress-hint">This might take a moment.</p>
|
| 294 |
</div>
|
| 295 |
`;
|
| 296 |
|
| 297 |
try {
|
|
|
|
| 298 |
let segResult = await removeBackground(imageBlob, (info) => {
|
| 299 |
const msg = document.getElementById('seg-message');
|
| 300 |
const fill = document.getElementById('seg-progress-fill');
|
| 301 |
if (msg) msg.textContent = info.message;
|
| 302 |
+
if (fill && info.progress != null) fill.style.width = `${info.progress}%`;
|
|
|
|
|
|
|
| 303 |
});
|
| 304 |
|
|
|
|
| 305 |
if (polygon && polygon.length >= 3) {
|
| 306 |
segResult = await intersectSegmentationWithPolygon(segResult, polygon);
|
| 307 |
}
|
| 308 |
|
| 309 |
segmentedBlob = segResult;
|
| 310 |
|
|
|
|
| 311 |
const segURL = URL.createObjectURL(segmentedBlob);
|
| 312 |
const beforeURL = previewURL;
|
| 313 |
status.innerHTML = `
|
|
|
|
| 331 |
</div>
|
| 332 |
`;
|
| 333 |
|
| 334 |
+
document.getElementById('seg-approve').addEventListener('click', () => {
|
| 335 |
+
tagOp('approve: clicked');
|
| 336 |
+
saveAsset(name, segmentedBlob);
|
| 337 |
+
});
|
| 338 |
+
document.getElementById('seg-skip').addEventListener('click', () => {
|
| 339 |
+
tagOp('skip: clicked');
|
| 340 |
+
saveAsset(name, skipBlob);
|
| 341 |
+
});
|
| 342 |
document.getElementById('seg-retry').addEventListener('click', resetToCamera);
|
| 343 |
|
| 344 |
} catch (err) {
|
|
|
|
| 355 |
`;
|
| 356 |
document.getElementById('seg-skip-err').addEventListener('click', () => saveAsset(name, skipBlob));
|
| 357 |
document.getElementById('seg-retry-err').addEventListener('click', () => {
|
| 358 |
+
document.getElementById('upload-actions').style.display = '';
|
| 359 |
btn.disabled = false;
|
| 360 |
status.innerHTML = '';
|
| 361 |
});
|
|
|
|
| 363 |
}
|
| 364 |
|
| 365 |
async function handleFullDrawing() {
|
| 366 |
+
if (!imageBlob) return;
|
| 367 |
+
|
| 368 |
+
const btn = document.getElementById('full-drawing-btn');
|
| 369 |
const status = document.getElementById('upload-status');
|
| 370 |
|
| 371 |
+
document.getElementById('upload-actions').style.display = 'none';
|
|
|
|
| 372 |
|
| 373 |
function showProgress(message, progress) {
|
| 374 |
const msg = document.getElementById('seg-message');
|
|
|
|
| 387 |
`;
|
| 388 |
|
| 389 |
try {
|
|
|
|
| 390 |
const bgRemoved = await removeBackground(imageBlob, (info) => {
|
| 391 |
+
showProgress(info.message, info.progress * 0.4);
|
| 392 |
});
|
| 393 |
|
|
|
|
| 394 |
await new Promise(r => setTimeout(r, 200));
|
| 395 |
|
|
|
|
| 396 |
showProgress('Finding all the parts...', 40);
|
| 397 |
const segments = await autoSegment(bgRemoved, (info) => {
|
| 398 |
const overall = 40 + (info.progress || 0) * 0.4;
|
|
|
|
| 409 |
</div>
|
| 410 |
`;
|
| 411 |
document.getElementById('fd-retry').addEventListener('click', () => {
|
| 412 |
+
document.getElementById('upload-actions').style.display = '';
|
|
|
|
| 413 |
status.innerHTML = '';
|
| 414 |
});
|
| 415 |
return;
|
| 416 |
}
|
| 417 |
|
|
|
|
| 418 |
showProgress('Ready for review!', 100);
|
| 419 |
const bgRemovedURL = URL.createObjectURL(bgRemoved);
|
| 420 |
const reviewResult = await showSegmentReview(bgRemovedURL, segments);
|
| 421 |
URL.revokeObjectURL(bgRemovedURL);
|
| 422 |
|
| 423 |
if (!reviewResult) {
|
| 424 |
+
document.getElementById('upload-actions').style.display = '';
|
|
|
|
| 425 |
status.innerHTML = '';
|
| 426 |
return;
|
| 427 |
}
|
| 428 |
|
|
|
|
| 429 |
status.innerHTML = `
|
| 430 |
<div class="processing">
|
| 431 |
<span class="crown-spin">\u{1F451}</span>
|
|
|
|
| 436 |
let savedCount = 0;
|
| 437 |
|
| 438 |
if (reviewResult.princess) {
|
| 439 |
+
await saveItem({
|
| 440 |
name: '',
|
| 441 |
+
category: 'princess',
|
| 442 |
imageBlob: reviewResult.princess.blob,
|
| 443 |
+
scale: 0.8,
|
| 444 |
+
zIndex: 2,
|
| 445 |
});
|
| 446 |
savedCount++;
|
| 447 |
}
|
| 448 |
|
| 449 |
for (const item of reviewResult.clothing) {
|
| 450 |
const catInfo = getCategoryInfo(item.category);
|
| 451 |
+
await saveItem({
|
| 452 |
name: '',
|
| 453 |
category: item.category,
|
| 454 |
imageBlob: item.blob,
|
| 455 |
+
scale: catInfo.scale || 0.5,
|
|
|
|
| 456 |
zIndex: catInfo.zIndex,
|
| 457 |
});
|
| 458 |
savedCount++;
|
|
|
|
| 480 |
</div>
|
| 481 |
`;
|
| 482 |
document.getElementById('fd-retry-err').addEventListener('click', () => {
|
| 483 |
+
document.getElementById('upload-actions').style.display = '';
|
|
|
|
| 484 |
status.innerHTML = '';
|
| 485 |
});
|
| 486 |
}
|
|
|
|
| 491 |
initUpload();
|
| 492 |
}
|
| 493 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 494 |
async function intersectSegmentationWithPolygon(segBlob, poly) {
|
| 495 |
const bitmap = await createImageBitmap(segBlob);
|
| 496 |
const fullW = bitmap.width;
|
| 497 |
const fullH = bitmap.height;
|
| 498 |
|
|
|
|
| 499 |
const pxPoly = poly.map(p => ({ x: p.x * fullW, y: p.y * fullH }));
|
| 500 |
|
|
|
|
| 501 |
let minX = fullW, minY = fullH, maxX = 0, maxY = 0;
|
| 502 |
for (const p of pxPoly) {
|
| 503 |
minX = Math.min(minX, p.x);
|
|
|
|
| 513 |
const cropW = maxX - minX;
|
| 514 |
const cropH = maxY - minY;
|
| 515 |
|
|
|
|
| 516 |
const canvas = new OffscreenCanvas(cropW, cropH);
|
| 517 |
const ctx = canvas.getContext('2d');
|
| 518 |
|
|
|
|
| 530 |
}
|
| 531 |
|
| 532 |
async function saveAsset(name, blob) {
|
| 533 |
+
tagOp('save: clicked');
|
| 534 |
const status = document.getElementById('upload-status');
|
| 535 |
status.innerHTML = `
|
| 536 |
<div class="processing">
|
|
|
|
| 539 |
</div>
|
| 540 |
`;
|
| 541 |
|
| 542 |
+
// iOS is likely still near the high-water mark from MODNet inference.
|
| 543 |
+
// Drop the cropped intermediate (keep imageBlob/previewURL — the
|
| 544 |
+
// "Select another from this photo" flow re-uses them) and give the
|
| 545 |
+
// GC a moment before IndexedDB clones `blob` into a transaction.
|
| 546 |
+
tagOp('save: freeing intermediates');
|
| 547 |
+
croppedBlob = null;
|
| 548 |
+
if (croppedURL) { URL.revokeObjectURL(croppedURL); croppedURL = null; }
|
| 549 |
+
|
| 550 |
+
await new Promise(r => setTimeout(r, 100));
|
| 551 |
+
|
| 552 |
try {
|
| 553 |
+
const catInfo = getCategoryInfo(selectedCategory);
|
| 554 |
+
tagOp(`save: writing to IDB (${Math.round(blob.size / 1024)}KB)`);
|
| 555 |
+
await saveItem({
|
| 556 |
+
name,
|
| 557 |
+
category: selectedCategory,
|
| 558 |
+
imageBlob: blob,
|
| 559 |
+
scale: catInfo.scale || 0.5,
|
| 560 |
+
zIndex: catInfo.zIndex,
|
| 561 |
+
});
|
| 562 |
+
tagOp('save: IDB done');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 563 |
|
| 564 |
status.innerHTML = `
|
| 565 |
<div class="success">
|
|
|
|
| 568 |
<a href="#/" class="pill">Back to game</a>
|
| 569 |
</div>
|
| 570 |
`;
|
| 571 |
+
tagOp('save: success shown');
|
| 572 |
|
| 573 |
document.getElementById('another-from-photo').addEventListener('click', () => {
|
| 574 |
resetForAnotherSelection();
|
| 575 |
});
|
| 576 |
} catch (err) {
|
| 577 |
+
tagOp(`save: error ${err.message}`);
|
| 578 |
status.innerHTML = `
|
| 579 |
<div class="error-state">
|
| 580 |
<p>Oops! Something went wrong.</p>
|
|
|
|
| 586 |
|
| 587 |
function resetForAnotherSelection() {
|
| 588 |
const status = document.getElementById('upload-status');
|
|
|
|
| 589 |
status.innerHTML = '';
|
| 590 |
+
document.getElementById('upload-actions').style.display = '';
|
| 591 |
+
document.getElementById('submit-btn').disabled = false;
|
| 592 |
|
|
|
|
| 593 |
croppedBlob = null;
|
| 594 |
polygon = null;
|
| 595 |
segmentedBlob = null;
|
| 596 |
if (croppedURL) URL.revokeObjectURL(croppedURL);
|
| 597 |
croppedURL = null;
|
| 598 |
|
|
|
|
| 599 |
document.getElementById('asset-name').value = '';
|
| 600 |
|
|
|
|
| 601 |
showPhotoPreview(previewURL);
|
| 602 |
document.getElementById('select-area-btn').style.display = '';
|
| 603 |
document.getElementById('select-area-btn').textContent = '\u2702\uFE0F Select area';
|
|
|
|
| 606 |
}
|
| 607 |
|
| 608 |
function resetState() {
|
| 609 |
+
selectedCategory = 'princess';
|
|
|
|
| 610 |
imageBlob = null;
|
| 611 |
croppedBlob = null;
|
| 612 |
polygon = null;
|
style.css
CHANGED
|
@@ -36,6 +36,8 @@ html, body {
|
|
| 36 |
-webkit-user-select: none;
|
| 37 |
-webkit-tap-highlight-color: transparent;
|
| 38 |
overflow: hidden;
|
|
|
|
|
|
|
| 39 |
}
|
| 40 |
|
| 41 |
a { text-decoration: none; color: inherit; }
|
|
@@ -89,12 +91,6 @@ h2 { font-size: 1.5rem; font-weight: 700; color: var(--lavender-dk); }
|
|
| 89 |
gap: 0.5rem;
|
| 90 |
}
|
| 91 |
|
| 92 |
-
.princess-title {
|
| 93 |
-
text-align: center;
|
| 94 |
-
font-size: 1.6rem;
|
| 95 |
-
padding: 0.25rem;
|
| 96 |
-
}
|
| 97 |
-
|
| 98 |
.canvas-wrap {
|
| 99 |
flex: 1;
|
| 100 |
min-height: 0;
|
|
@@ -105,19 +101,11 @@ h2 { font-size: 1.5rem; font-weight: 700; color: var(--lavender-dk); }
|
|
| 105 |
|
| 106 |
.princess-canvas {
|
| 107 |
position: relative;
|
| 108 |
-
width: 100%;
|
| 109 |
-
max-width: 400px;
|
| 110 |
-
aspect-ratio: 3/4;
|
| 111 |
background: var(--panel);
|
| 112 |
border-radius: 1rem;
|
| 113 |
border: 3px solid var(--lavender);
|
| 114 |
overflow: hidden;
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
.princess-img {
|
| 118 |
-
width: 100%;
|
| 119 |
-
height: 100%;
|
| 120 |
-
object-fit: contain;
|
| 121 |
}
|
| 122 |
|
| 123 |
.placed-layer {
|
|
@@ -131,7 +119,75 @@ h2 { font-size: 1.5rem; font-weight: 700; color: var(--lavender-dk); }
|
|
| 131 |
cursor: grab;
|
| 132 |
}
|
| 133 |
|
| 134 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
|
| 136 |
.switcher {
|
| 137 |
display: flex;
|
|
@@ -193,12 +249,12 @@ h2 { font-size: 1.5rem; font-weight: 700; color: var(--lavender-dk); }
|
|
| 193 |
.clothing-tray {
|
| 194 |
display: flex;
|
| 195 |
gap: 0.5rem;
|
| 196 |
-
padding: 0.
|
| 197 |
overflow-x: auto;
|
| 198 |
scroll-snap-type: x mandatory;
|
| 199 |
-webkit-overflow-scrolling: touch;
|
| 200 |
flex-shrink: 0;
|
| 201 |
-
min-height:
|
| 202 |
}
|
| 203 |
|
| 204 |
.tray-empty {
|
|
@@ -231,8 +287,8 @@ h2 { font-size: 1.5rem; font-weight: 700; color: var(--lavender-dk); }
|
|
| 231 |
.tray-item:active { transform: scale(0.95); }
|
| 232 |
|
| 233 |
.tray-thumb {
|
| 234 |
-
width:
|
| 235 |
-
height:
|
| 236 |
object-fit: contain;
|
| 237 |
border-radius: 0.5rem;
|
| 238 |
}
|
|
@@ -331,6 +387,19 @@ h2 { font-size: 1.5rem; font-weight: 700; color: var(--lavender-dk); }
|
|
| 331 |
border-radius: 0.5rem;
|
| 332 |
}
|
| 333 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 334 |
.manage-name {
|
| 335 |
font-size: 0.8rem;
|
| 336 |
font-weight: 700;
|
|
@@ -564,7 +633,15 @@ h2 { font-size: 1.5rem; font-weight: 700; color: var(--lavender-dk); }
|
|
| 564 |
.name-input:focus { border-color: var(--gold); }
|
| 565 |
|
| 566 |
.select-area-btn { align-self: center; }
|
| 567 |
-
.magic-btn {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 568 |
|
| 569 |
.processing {
|
| 570 |
display: flex;
|
|
|
|
| 36 |
-webkit-user-select: none;
|
| 37 |
-webkit-tap-highlight-color: transparent;
|
| 38 |
overflow: hidden;
|
| 39 |
+
overscroll-behavior: none;
|
| 40 |
+
overscroll-behavior-y: none;
|
| 41 |
}
|
| 42 |
|
| 43 |
a { text-decoration: none; color: inherit; }
|
|
|
|
| 91 |
gap: 0.5rem;
|
| 92 |
}
|
| 93 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
.canvas-wrap {
|
| 95 |
flex: 1;
|
| 96 |
min-height: 0;
|
|
|
|
| 101 |
|
| 102 |
.princess-canvas {
|
| 103 |
position: relative;
|
|
|
|
|
|
|
|
|
|
| 104 |
background: var(--panel);
|
| 105 |
border-radius: 1rem;
|
| 106 |
border: 3px solid var(--lavender);
|
| 107 |
overflow: hidden;
|
| 108 |
+
/* width/height set explicitly by fitCanvas() in dressup.js */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
}
|
| 110 |
|
| 111 |
.placed-layer {
|
|
|
|
| 119 |
cursor: grab;
|
| 120 |
}
|
| 121 |
|
| 122 |
+
/* ---- Bounding box overlay (desktop resize) ---- */
|
| 123 |
+
|
| 124 |
+
.bbox-overlay {
|
| 125 |
+
position: absolute;
|
| 126 |
+
inset: 0;
|
| 127 |
+
pointer-events: none;
|
| 128 |
+
z-index: 9999;
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
.bbox {
|
| 132 |
+
position: absolute;
|
| 133 |
+
border: 2px dashed var(--gold);
|
| 134 |
+
pointer-events: none;
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
.bbox-handle {
|
| 138 |
+
position: absolute;
|
| 139 |
+
width: 18px;
|
| 140 |
+
height: 18px;
|
| 141 |
+
background: var(--gold);
|
| 142 |
+
border: 2px solid white;
|
| 143 |
+
border-radius: 50%;
|
| 144 |
+
pointer-events: auto;
|
| 145 |
+
cursor: nwse-resize;
|
| 146 |
+
z-index: 1;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
.bbox-handle-br {
|
| 150 |
+
bottom: -9px;
|
| 151 |
+
right: -9px;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
.bbox-action {
|
| 155 |
+
position: absolute;
|
| 156 |
+
top: -32px;
|
| 157 |
+
width: 28px;
|
| 158 |
+
height: 28px;
|
| 159 |
+
border-radius: 50%;
|
| 160 |
+
border: 2px solid var(--lavender);
|
| 161 |
+
background: var(--panel);
|
| 162 |
+
font-size: 0.85rem;
|
| 163 |
+
line-height: 1;
|
| 164 |
+
cursor: pointer;
|
| 165 |
+
pointer-events: auto;
|
| 166 |
+
display: flex;
|
| 167 |
+
align-items: center;
|
| 168 |
+
justify-content: center;
|
| 169 |
+
padding: 0;
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
.bbox-sparkle { right: 32px; }
|
| 173 |
+
.bbox-delete { right: 0; }
|
| 174 |
+
|
| 175 |
+
@media (hover: none) {
|
| 176 |
+
.bbox-overlay { display: none; }
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
/* ---- Sparkle effect ---- */
|
| 180 |
+
|
| 181 |
+
.sparkle-effect {
|
| 182 |
+
filter: drop-shadow(0 0 6px rgba(255, 215, 0, 0.7))
|
| 183 |
+
drop-shadow(0 0 12px rgba(255, 215, 0, 0.4));
|
| 184 |
+
animation: sparkle-glow 1.5s ease-in-out infinite alternate;
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
@keyframes sparkle-glow {
|
| 188 |
+
0% { filter: drop-shadow(0 0 4px rgba(255, 215, 0, 0.5)) drop-shadow(0 0 8px rgba(255, 215, 0, 0.3)); }
|
| 189 |
+
100% { filter: drop-shadow(0 0 8px rgba(255, 215, 0, 0.9)) drop-shadow(0 0 16px rgba(255, 215, 0, 0.5)); }
|
| 190 |
+
}
|
| 191 |
|
| 192 |
.switcher {
|
| 193 |
display: flex;
|
|
|
|
| 249 |
.clothing-tray {
|
| 250 |
display: flex;
|
| 251 |
gap: 0.5rem;
|
| 252 |
+
padding: 0.4rem;
|
| 253 |
overflow-x: auto;
|
| 254 |
scroll-snap-type: x mandatory;
|
| 255 |
-webkit-overflow-scrolling: touch;
|
| 256 |
flex-shrink: 0;
|
| 257 |
+
min-height: 96px;
|
| 258 |
}
|
| 259 |
|
| 260 |
.tray-empty {
|
|
|
|
| 287 |
.tray-item:active { transform: scale(0.95); }
|
| 288 |
|
| 289 |
.tray-thumb {
|
| 290 |
+
width: 48px;
|
| 291 |
+
height: 48px;
|
| 292 |
object-fit: contain;
|
| 293 |
border-radius: 0.5rem;
|
| 294 |
}
|
|
|
|
| 387 |
border-radius: 0.5rem;
|
| 388 |
}
|
| 389 |
|
| 390 |
+
.drawing-thumb {
|
| 391 |
+
display: flex;
|
| 392 |
+
flex-direction: column;
|
| 393 |
+
align-items: center;
|
| 394 |
+
justify-content: center;
|
| 395 |
+
background: var(--lavender);
|
| 396 |
+
color: white;
|
| 397 |
+
gap: 0.15rem;
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
.drawing-icon { font-size: 1.5rem; }
|
| 401 |
+
.drawing-count { font-size: 0.65rem; }
|
| 402 |
+
|
| 403 |
.manage-name {
|
| 404 |
font-size: 0.8rem;
|
| 405 |
font-weight: 700;
|
|
|
|
| 633 |
.name-input:focus { border-color: var(--gold); }
|
| 634 |
|
| 635 |
.select-area-btn { align-self: center; }
|
| 636 |
+
.magic-btn { font-size: 1.4rem; }
|
| 637 |
+
|
| 638 |
+
.upload-actions {
|
| 639 |
+
display: flex;
|
| 640 |
+
flex-direction: column;
|
| 641 |
+
align-items: center;
|
| 642 |
+
gap: 0.5rem;
|
| 643 |
+
margin-top: 0.5rem;
|
| 644 |
+
}
|
| 645 |
|
| 646 |
.processing {
|
| 647 |
display: flex;
|