Spaces:
Sleeping
Sleeping
Add premade items
Browse files- js/dressup.js +240 -48
- js/segmentation.js +100 -33
- js/stickers.js +184 -0
- js/store.js +34 -2
- js/upload.js +82 -46
- style.css +106 -1
js/dressup.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
| 1 |
-
import { loadPrincesses, loadClothes, blobToURL } from './store.js';
|
| 2 |
import { getCategoryInfo, DRAG_DEAD_ZONE_PX, DOUBLE_TAP_MS } from './models.js';
|
|
|
|
| 3 |
|
| 4 |
let princesses = [];
|
| 5 |
let clothes = [];
|
| 6 |
let currentIdx = 0;
|
| 7 |
-
let placedItems = []; // { clothingId, x, y, scale, zIndex, url }
|
| 8 |
let objectURLs = [];
|
| 9 |
let clothesURLMap = {}; // id -> objectURL
|
| 10 |
|
|
@@ -12,14 +13,14 @@ let clothesURLMap = {}; // id -> objectURL
|
|
| 12 |
let drag = null;
|
| 13 |
let ghost = null;
|
| 14 |
|
| 15 |
-
// Pinch-to-resize state
|
| 16 |
-
let pinch = null;
|
| 17 |
|
| 18 |
// Double-tap tracking
|
| 19 |
let lastPlacedTap = { idx: -1, time: 0 };
|
| 20 |
|
| 21 |
// Track active pointers on placed items
|
| 22 |
-
let activePointers = new Map();
|
| 23 |
|
| 24 |
export async function initDressUp() {
|
| 25 |
const screen = document.getElementById('dressup-screen');
|
|
@@ -49,6 +50,8 @@ export async function initDressUp() {
|
|
| 49 |
return;
|
| 50 |
}
|
| 51 |
|
|
|
|
|
|
|
| 52 |
screen.innerHTML = `
|
| 53 |
<div class="dressup-layout">
|
| 54 |
<h1 id="princess-name" class="princess-title"></h1>
|
|
@@ -66,12 +69,18 @@ export async function initDressUp() {
|
|
| 66 |
<button id="next-btn" class="switch-btn">\u25B6</button>
|
| 67 |
</div>
|
| 68 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
<div id="clothing-tray" class="clothing-tray">
|
| 70 |
-
${
|
| 71 |
</div>
|
| 72 |
|
| 73 |
<div class="bottom-bar">
|
| 74 |
<a href="#/manage" class="pill">My Collection</a>
|
|
|
|
| 75 |
<a href="#/upload" class="big-button">Add Photo</a>
|
| 76 |
</div>
|
| 77 |
</div>
|
|
@@ -79,10 +88,26 @@ export async function initDressUp() {
|
|
| 79 |
|
| 80 |
currentIdx = 0;
|
| 81 |
renderPrincess();
|
| 82 |
-
renderTray();
|
| 83 |
bindEvents();
|
| 84 |
}
|
| 85 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
// ---- Rendering ----
|
| 87 |
|
| 88 |
function renderPrincess() {
|
|
@@ -99,25 +124,61 @@ function renderPrincess() {
|
|
| 99 |
document.getElementById('prev-btn').disabled = currentIdx === 0;
|
| 100 |
document.getElementById('next-btn').disabled = currentIdx === princesses.length - 1;
|
| 101 |
|
|
|
|
| 102 |
placedItems = [];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
renderPlaced();
|
| 104 |
}
|
| 105 |
|
| 106 |
-
|
|
|
|
|
|
|
|
|
|
| 107 |
const tray = document.getElementById('clothing-tray');
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
|
|
|
|
|
|
| 118 |
</div>
|
| 119 |
-
`;
|
| 120 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
}
|
| 122 |
|
| 123 |
function renderPlaced() {
|
|
@@ -134,15 +195,13 @@ function renderPlaced() {
|
|
| 134 |
left:${item.x * 100}%;
|
| 135 |
top:${item.y * 100}%;
|
| 136 |
width:${item.scale * 100}%;
|
| 137 |
-
transform:translate(-50%,-50%);
|
| 138 |
z-index:${item.zIndex};
|
| 139 |
"
|
| 140 |
alt=""/>
|
| 141 |
`).join('');
|
| 142 |
}
|
| 143 |
|
| 144 |
-
// Update a single placed item's style without re-rendering the whole layer.
|
| 145 |
-
// Avoids flicker during pinch.
|
| 146 |
function updatePlacedStyle(origIdx) {
|
| 147 |
const el = document.querySelector(`.placed-item[data-orig-idx="${origIdx}"]`);
|
| 148 |
if (!el) return;
|
|
@@ -151,6 +210,7 @@ function updatePlacedStyle(origIdx) {
|
|
| 151 |
el.style.left = `${item.x * 100}%`;
|
| 152 |
el.style.top = `${item.y * 100}%`;
|
| 153 |
el.style.width = `${item.scale * 100}%`;
|
|
|
|
| 154 |
}
|
| 155 |
|
| 156 |
// ---- Ghost (for tray drag) ----
|
|
@@ -180,8 +240,92 @@ function pointerDist(a, b) {
|
|
| 180 |
return Math.sqrt(dx * dx + dy * dy);
|
| 181 |
}
|
| 182 |
|
| 183 |
-
function
|
| 184 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
}
|
| 186 |
|
| 187 |
// ---- Events ----
|
|
@@ -194,6 +338,12 @@ function bindEvents() {
|
|
| 194 |
if (currentIdx < princesses.length - 1) { currentIdx++; renderPrincess(); }
|
| 195 |
});
|
| 196 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
const tray = document.getElementById('clothing-tray');
|
| 198 |
tray.addEventListener('pointerdown', onTrayPointerDown, { passive: false });
|
| 199 |
|
|
@@ -203,6 +353,9 @@ function bindEvents() {
|
|
| 203 |
window.addEventListener('pointermove', onPointerMove, { passive: false });
|
| 204 |
window.addEventListener('pointerup', onPointerUp);
|
| 205 |
window.addEventListener('pointercancel', onPointerUp);
|
|
|
|
|
|
|
|
|
|
| 206 |
}
|
| 207 |
|
| 208 |
// ---- Tray drag ----
|
|
@@ -212,19 +365,39 @@ function onTrayPointerDown(e) {
|
|
| 212 |
if (!item) return;
|
| 213 |
e.preventDefault();
|
| 214 |
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
}
|
| 226 |
|
| 227 |
-
// ---- Placed item: drag, pinch, double-tap ----
|
| 228 |
|
| 229 |
function onPlacedPointerDown(e) {
|
| 230 |
const img = e.target.closest('.placed-item');
|
|
@@ -235,19 +408,21 @@ function onPlacedPointerDown(e) {
|
|
| 235 |
const ptr = { x: e.clientX, y: e.clientY, origIdx };
|
| 236 |
activePointers.set(e.pointerId, ptr);
|
| 237 |
|
| 238 |
-
//
|
| 239 |
const sameItem = [...activePointers.values()].filter(p => p.origIdx === origIdx);
|
| 240 |
if (sameItem.length === 2 && !pinch) {
|
| 241 |
-
// Cancel any single-finger drag
|
| 242 |
if (drag && drag.source === 'canvas') {
|
| 243 |
drag = null;
|
| 244 |
removeGhost();
|
| 245 |
}
|
| 246 |
const d = pointerDist(sameItem[0], sameItem[1]);
|
|
|
|
| 247 |
pinch = {
|
| 248 |
idx: origIdx,
|
| 249 |
startDist: d,
|
| 250 |
startScale: placedItems[origIdx].scale,
|
|
|
|
|
|
|
| 251 |
startMidX: (sameItem[0].x + sameItem[1].x) / 2,
|
| 252 |
startMidY: (sameItem[0].y + sameItem[1].y) / 2,
|
| 253 |
startItemX: placedItems[origIdx].x,
|
|
@@ -256,13 +431,14 @@ function onPlacedPointerDown(e) {
|
|
| 256 |
return;
|
| 257 |
}
|
| 258 |
|
| 259 |
-
//
|
| 260 |
const now = Date.now();
|
| 261 |
if (lastPlacedTap.idx === origIdx && (now - lastPlacedTap.time) < DOUBLE_TAP_MS) {
|
| 262 |
img.classList.add('poof');
|
| 263 |
setTimeout(() => {
|
| 264 |
placedItems.splice(origIdx, 1);
|
| 265 |
renderPlaced();
|
|
|
|
| 266 |
}, 300);
|
| 267 |
lastPlacedTap = { idx: -1, time: 0 };
|
| 268 |
activePointers.delete(e.pointerId);
|
|
@@ -270,7 +446,7 @@ function onPlacedPointerDown(e) {
|
|
| 270 |
}
|
| 271 |
lastPlacedTap = { idx: origIdx, time: now };
|
| 272 |
|
| 273 |
-
//
|
| 274 |
const item = placedItems[origIdx];
|
| 275 |
if (!item) return;
|
| 276 |
|
|
@@ -279,7 +455,9 @@ function onPlacedPointerDown(e) {
|
|
| 279 |
id: item.clothingId,
|
| 280 |
url: item.url,
|
| 281 |
scale: item.scale,
|
|
|
|
| 282 |
zIndex: item.zIndex,
|
|
|
|
| 283 |
startX: e.clientX,
|
| 284 |
startY: e.clientY,
|
| 285 |
canvasIdx: origIdx,
|
|
@@ -288,27 +466,31 @@ function onPlacedPointerDown(e) {
|
|
| 288 |
}
|
| 289 |
|
| 290 |
function onPointerMove(e) {
|
| 291 |
-
// Update active pointer position
|
| 292 |
if (activePointers.has(e.pointerId)) {
|
| 293 |
const ptr = activePointers.get(e.pointerId);
|
| 294 |
ptr.x = e.clientX;
|
| 295 |
ptr.y = e.clientY;
|
| 296 |
}
|
| 297 |
|
| 298 |
-
// Pinch-to-resize
|
| 299 |
if (pinch) {
|
| 300 |
e.preventDefault();
|
| 301 |
const sameItem = [...activePointers.values()].filter(p => p.origIdx === pinch.idx);
|
| 302 |
if (sameItem.length >= 2) {
|
| 303 |
const d = pointerDist(sameItem[0], sameItem[1]);
|
|
|
|
| 304 |
const ratio = d / pinch.startDist;
|
| 305 |
-
const newScale = Math.max(0.
|
| 306 |
placedItems[pinch.idx].scale = newScale;
|
| 307 |
|
| 308 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
| 309 |
const canvas = document.getElementById('princess-canvas');
|
| 310 |
const rect = canvas.getBoundingClientRect();
|
| 311 |
-
const mid =
|
| 312 |
const dx = mid.x - pinch.startMidX;
|
| 313 |
const dy = mid.y - pinch.startMidY;
|
| 314 |
placedItems[pinch.idx].x = pinch.startItemX + dx / rect.width;
|
|
@@ -334,7 +516,6 @@ function onPointerMove(e) {
|
|
| 334 |
createGhost(drag.url);
|
| 335 |
ghost.classList.add('lifting');
|
| 336 |
} else if (drag.source === 'canvas') {
|
| 337 |
-
// Remove from placed and drag as ghost
|
| 338 |
placedItems.splice(drag.canvasIdx, 1);
|
| 339 |
renderPlaced();
|
| 340 |
createGhost(drag.url);
|
|
@@ -348,11 +529,11 @@ function onPointerMove(e) {
|
|
| 348 |
function onPointerUp(e) {
|
| 349 |
activePointers.delete(e.pointerId);
|
| 350 |
|
| 351 |
-
// End pinch if we lost a finger
|
| 352 |
if (pinch) {
|
| 353 |
const sameItem = [...activePointers.values()].filter(p => p.origIdx === pinch.idx);
|
| 354 |
if (sameItem.length < 2) {
|
| 355 |
pinch = null;
|
|
|
|
| 356 |
}
|
| 357 |
return;
|
| 358 |
}
|
|
@@ -360,17 +541,19 @@ function onPointerUp(e) {
|
|
| 360 |
if (!drag) return;
|
| 361 |
|
| 362 |
if (!drag.hasMoved) {
|
| 363 |
-
// Tap on tray -> place at center
|
| 364 |
if (drag.source === 'tray') {
|
| 365 |
placedItems.push({
|
| 366 |
clothingId: drag.id,
|
| 367 |
x: 0.5,
|
| 368 |
y: 0.5,
|
| 369 |
scale: drag.scale,
|
|
|
|
| 370 |
zIndex: drag.zIndex,
|
| 371 |
url: drag.url,
|
|
|
|
| 372 |
});
|
| 373 |
renderPlaced();
|
|
|
|
| 374 |
}
|
| 375 |
drag = null;
|
| 376 |
return;
|
|
@@ -379,6 +562,7 @@ function onPointerUp(e) {
|
|
| 379 |
// Drop
|
| 380 |
const canvas = document.getElementById('princess-canvas');
|
| 381 |
const rect = canvas.getBoundingClientRect();
|
|
|
|
| 382 |
|
| 383 |
if (
|
| 384 |
e.clientX >= rect.left && e.clientX <= rect.right &&
|
|
@@ -391,10 +575,18 @@ function onPointerUp(e) {
|
|
| 391 |
x: nx,
|
| 392 |
y: ny,
|
| 393 |
scale: drag.scale,
|
|
|
|
| 394 |
zIndex: drag.zIndex,
|
| 395 |
url: drag.url,
|
|
|
|
| 396 |
});
|
| 397 |
renderPlaced();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 398 |
}
|
| 399 |
|
| 400 |
removeGhost();
|
|
|
|
| 1 |
+
import { loadPrincesses, loadClothes, blobToURL, savePrincessOutfit } from './store.js';
|
| 2 |
import { getCategoryInfo, DRAG_DEAD_ZONE_PX, DOUBLE_TAP_MS } from './models.js';
|
| 3 |
+
import { STICKERS } from './stickers.js';
|
| 4 |
|
| 5 |
let princesses = [];
|
| 6 |
let clothes = [];
|
| 7 |
let currentIdx = 0;
|
| 8 |
+
let placedItems = []; // { clothingId, x, y, scale, rotation, zIndex, url, isSticker }
|
| 9 |
let objectURLs = [];
|
| 10 |
let clothesURLMap = {}; // id -> objectURL
|
| 11 |
|
|
|
|
| 13 |
let drag = null;
|
| 14 |
let ghost = null;
|
| 15 |
|
| 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');
|
|
|
|
| 50 |
return;
|
| 51 |
}
|
| 52 |
|
| 53 |
+
const hasItems = clothes.length > 0 || STICKERS.length > 0;
|
| 54 |
+
|
| 55 |
screen.innerHTML = `
|
| 56 |
<div class="dressup-layout">
|
| 57 |
<h1 id="princess-name" class="princess-title"></h1>
|
|
|
|
| 69 |
<button id="next-btn" class="switch-btn">\u25B6</button>
|
| 70 |
</div>
|
| 71 |
|
| 72 |
+
<div class="tray-tabs" id="tray-tabs">
|
| 73 |
+
${clothes.length > 0 ? '<button class="tray-tab active" data-tab="clothes">\u{1F457} Clothes</button>' : ''}
|
| 74 |
+
<button class="tray-tab ${clothes.length === 0 ? 'active' : ''}" data-tab="stickers">\u2728 Stickers</button>
|
| 75 |
+
</div>
|
| 76 |
+
|
| 77 |
<div id="clothing-tray" class="clothing-tray">
|
| 78 |
+
${!hasItems ? '<p class="tray-empty">Draw some clothes and add photos!</p>' : ''}
|
| 79 |
</div>
|
| 80 |
|
| 81 |
<div class="bottom-bar">
|
| 82 |
<a href="#/manage" class="pill">My Collection</a>
|
| 83 |
+
<button id="screenshot-btn" class="pill">\u{1F4F7} Save</button>
|
| 84 |
<a href="#/upload" class="big-button">Add Photo</a>
|
| 85 |
</div>
|
| 86 |
</div>
|
|
|
|
| 88 |
|
| 89 |
currentIdx = 0;
|
| 90 |
renderPrincess();
|
| 91 |
+
renderTray(clothes.length > 0 ? 'clothes' : 'stickers');
|
| 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 |
// ---- Rendering ----
|
| 112 |
|
| 113 |
function renderPrincess() {
|
|
|
|
| 124 |
document.getElementById('prev-btn').disabled = currentIdx === 0;
|
| 125 |
document.getElementById('next-btn').disabled = currentIdx === princesses.length - 1;
|
| 126 |
|
| 127 |
+
// Restore saved outfit
|
| 128 |
placedItems = [];
|
| 129 |
+
if (p.outfit && Array.isArray(p.outfit)) {
|
| 130 |
+
for (const item of p.outfit) {
|
| 131 |
+
let url;
|
| 132 |
+
if (item.isSticker) {
|
| 133 |
+
const sticker = STICKERS.find(s => s.id === item.clothingId);
|
| 134 |
+
url = sticker ? sticker.url : null;
|
| 135 |
+
} else {
|
| 136 |
+
url = clothesURLMap[item.clothingId];
|
| 137 |
+
}
|
| 138 |
+
if (url) {
|
| 139 |
+
placedItems.push({ ...item, url, rotation: item.rotation || 0 });
|
| 140 |
+
}
|
| 141 |
+
}
|
| 142 |
+
}
|
| 143 |
renderPlaced();
|
| 144 |
}
|
| 145 |
|
| 146 |
+
let currentTab = 'clothes';
|
| 147 |
+
|
| 148 |
+
function renderTray(tab) {
|
| 149 |
+
currentTab = tab || currentTab;
|
| 150 |
const tray = document.getElementById('clothing-tray');
|
| 151 |
+
|
| 152 |
+
// Update tab styling
|
| 153 |
+
document.querySelectorAll('.tray-tab').forEach(t => {
|
| 154 |
+
t.classList.toggle('active', t.dataset.tab === currentTab);
|
| 155 |
+
});
|
| 156 |
+
|
| 157 |
+
if (currentTab === 'stickers') {
|
| 158 |
+
tray.innerHTML = STICKERS.map(s => `
|
| 159 |
+
<div class="tray-item tray-sticker" data-sticker-id="${s.id}"
|
| 160 |
+
data-scale="${s.scale}" data-z="${s.zIndex}">
|
| 161 |
+
<img src="${s.url}" class="tray-thumb" alt="${s.name}" draggable="false"/>
|
| 162 |
+
<span class="tray-label">${s.name}</span>
|
| 163 |
</div>
|
| 164 |
+
`).join('');
|
| 165 |
+
} else {
|
| 166 |
+
if (clothes.length === 0) {
|
| 167 |
+
tray.innerHTML = '<p class="tray-empty">Draw some clothes and add photos!</p>';
|
| 168 |
+
return;
|
| 169 |
+
}
|
| 170 |
+
tray.innerHTML = clothes.map(c => {
|
| 171 |
+
const url = clothesURLMap[c.id];
|
| 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('');
|
| 181 |
+
}
|
| 182 |
}
|
| 183 |
|
| 184 |
function renderPlaced() {
|
|
|
|
| 195 |
left:${item.x * 100}%;
|
| 196 |
top:${item.y * 100}%;
|
| 197 |
width:${item.scale * 100}%;
|
| 198 |
+
transform:translate(-50%,-50%) rotate(${item.rotation || 0}deg);
|
| 199 |
z-index:${item.zIndex};
|
| 200 |
"
|
| 201 |
alt=""/>
|
| 202 |
`).join('');
|
| 203 |
}
|
| 204 |
|
|
|
|
|
|
|
| 205 |
function updatePlacedStyle(origIdx) {
|
| 206 |
const el = document.querySelector(`.placed-item[data-orig-idx="${origIdx}"]`);
|
| 207 |
if (!el) return;
|
|
|
|
| 210 |
el.style.left = `${item.x * 100}%`;
|
| 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 |
// ---- Ghost (for tray drag) ----
|
|
|
|
| 240 |
return Math.sqrt(dx * dx + dy * dy);
|
| 241 |
}
|
| 242 |
|
| 243 |
+
function pointerAngle(a, b) {
|
| 244 |
+
return Math.atan2(b.y - a.y, b.x - a.x) * (180 / Math.PI);
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
// ---- Screenshot ----
|
| 248 |
+
|
| 249 |
+
async function takeScreenshot() {
|
| 250 |
+
const canvasEl = document.getElementById('princess-canvas');
|
| 251 |
+
const princessImg = document.getElementById('princess-img');
|
| 252 |
+
const rect = canvasEl.getBoundingClientRect();
|
| 253 |
+
|
| 254 |
+
// Use a reasonable resolution
|
| 255 |
+
const w = Math.min(rect.width * 2, 1024);
|
| 256 |
+
const scale = w / rect.width;
|
| 257 |
+
const h = rect.height * scale;
|
| 258 |
+
|
| 259 |
+
const canvas = new OffscreenCanvas(w, h);
|
| 260 |
+
const ctx = canvas.getContext('2d');
|
| 261 |
+
|
| 262 |
+
// Draw background
|
| 263 |
+
ctx.fillStyle = '#fff8f0';
|
| 264 |
+
ctx.fillRect(0, 0, w, h);
|
| 265 |
+
|
| 266 |
+
// Draw princess
|
| 267 |
+
const pImg = await loadImage(princessImg.src);
|
| 268 |
+
const pAspect = pImg.width / pImg.height;
|
| 269 |
+
const canvasAspect = w / h;
|
| 270 |
+
let pw, ph, px, py;
|
| 271 |
+
if (pAspect > canvasAspect) {
|
| 272 |
+
pw = w; ph = w / pAspect; px = 0; py = (h - ph) / 2;
|
| 273 |
+
} else {
|
| 274 |
+
ph = h; pw = h * pAspect; px = (w - pw) / 2; py = 0;
|
| 275 |
+
}
|
| 276 |
+
ctx.drawImage(pImg, px, py, pw, ph);
|
| 277 |
+
|
| 278 |
+
// Draw placed items in z-order
|
| 279 |
+
const sorted = [...placedItems]
|
| 280 |
+
.map((item, i) => ({ ...item, i }))
|
| 281 |
+
.sort((a, b) => a.zIndex - b.zIndex);
|
| 282 |
+
|
| 283 |
+
for (const item of sorted) {
|
| 284 |
+
const img = await loadImage(item.url);
|
| 285 |
+
const itemW = item.scale * w;
|
| 286 |
+
const itemH = itemW * (img.height / img.width);
|
| 287 |
+
const ix = item.x * w - itemW / 2;
|
| 288 |
+
const iy = item.y * h - itemH / 2;
|
| 289 |
+
|
| 290 |
+
ctx.save();
|
| 291 |
+
ctx.translate(item.x * w, item.y * h);
|
| 292 |
+
ctx.rotate((item.rotation || 0) * Math.PI / 180);
|
| 293 |
+
ctx.drawImage(img, -itemW / 2, -itemH / 2, itemW, itemH);
|
| 294 |
+
ctx.restore();
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
const blob = await canvas.convertToBlob({ type: 'image/png' });
|
| 298 |
+
|
| 299 |
+
// Try native share, fall back to download
|
| 300 |
+
if (navigator.share && navigator.canShare) {
|
| 301 |
+
const file = new File([blob], 'princess.png', { type: 'image/png' });
|
| 302 |
+
try {
|
| 303 |
+
await navigator.share({ files: [file], title: 'My Princess' });
|
| 304 |
+
return;
|
| 305 |
+
} catch (_) {}
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
// Fallback: download
|
| 309 |
+
const url = URL.createObjectURL(blob);
|
| 310 |
+
const a = document.createElement('a');
|
| 311 |
+
a.href = url;
|
| 312 |
+
a.download = 'princess.png';
|
| 313 |
+
a.click();
|
| 314 |
+
URL.revokeObjectURL(url);
|
| 315 |
+
|
| 316 |
+
// Flash feedback
|
| 317 |
+
canvasEl.classList.add('screenshot-flash');
|
| 318 |
+
setTimeout(() => canvasEl.classList.remove('screenshot-flash'), 400);
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
function loadImage(src) {
|
| 322 |
+
return new Promise((resolve, reject) => {
|
| 323 |
+
const img = new Image();
|
| 324 |
+
img.crossOrigin = 'anonymous';
|
| 325 |
+
img.onload = () => resolve(img);
|
| 326 |
+
img.onerror = reject;
|
| 327 |
+
img.src = src;
|
| 328 |
+
});
|
| 329 |
}
|
| 330 |
|
| 331 |
// ---- Events ----
|
|
|
|
| 338 |
if (currentIdx < princesses.length - 1) { currentIdx++; renderPrincess(); }
|
| 339 |
});
|
| 340 |
|
| 341 |
+
// Tray tabs
|
| 342 |
+
document.getElementById('tray-tabs').addEventListener('click', (e) => {
|
| 343 |
+
const tab = e.target.closest('.tray-tab');
|
| 344 |
+
if (tab) renderTray(tab.dataset.tab);
|
| 345 |
+
});
|
| 346 |
+
|
| 347 |
const tray = document.getElementById('clothing-tray');
|
| 348 |
tray.addEventListener('pointerdown', onTrayPointerDown, { passive: false });
|
| 349 |
|
|
|
|
| 353 |
window.addEventListener('pointermove', onPointerMove, { passive: false });
|
| 354 |
window.addEventListener('pointerup', onPointerUp);
|
| 355 |
window.addEventListener('pointercancel', onPointerUp);
|
| 356 |
+
|
| 357 |
+
// Screenshot
|
| 358 |
+
document.getElementById('screenshot-btn').addEventListener('click', takeScreenshot);
|
| 359 |
}
|
| 360 |
|
| 361 |
// ---- Tray drag ----
|
|
|
|
| 365 |
if (!item) return;
|
| 366 |
e.preventDefault();
|
| 367 |
|
| 368 |
+
const isSticker = item.classList.contains('tray-sticker');
|
| 369 |
+
|
| 370 |
+
if (isSticker) {
|
| 371 |
+
const stickerId = item.dataset.stickerId;
|
| 372 |
+
const sticker = STICKERS.find(s => s.id === stickerId);
|
| 373 |
+
if (!sticker) return;
|
| 374 |
+
drag = {
|
| 375 |
+
source: 'tray',
|
| 376 |
+
id: sticker.id,
|
| 377 |
+
url: sticker.url,
|
| 378 |
+
scale: sticker.scale,
|
| 379 |
+
zIndex: sticker.zIndex,
|
| 380 |
+
isSticker: true,
|
| 381 |
+
startX: e.clientX,
|
| 382 |
+
startY: e.clientY,
|
| 383 |
+
hasMoved: false,
|
| 384 |
+
};
|
| 385 |
+
} else {
|
| 386 |
+
drag = {
|
| 387 |
+
source: 'tray',
|
| 388 |
+
id: item.dataset.id,
|
| 389 |
+
url: clothesURLMap[item.dataset.id],
|
| 390 |
+
scale: parseFloat(item.dataset.scale),
|
| 391 |
+
zIndex: parseInt(item.dataset.z),
|
| 392 |
+
isSticker: false,
|
| 393 |
+
startX: e.clientX,
|
| 394 |
+
startY: e.clientY,
|
| 395 |
+
hasMoved: false,
|
| 396 |
+
};
|
| 397 |
+
}
|
| 398 |
}
|
| 399 |
|
| 400 |
+
// ---- Placed item: drag, pinch+rotate, double-tap ----
|
| 401 |
|
| 402 |
function onPlacedPointerDown(e) {
|
| 403 |
const img = e.target.closest('.placed-item');
|
|
|
|
| 408 |
const ptr = { x: e.clientX, y: e.clientY, origIdx };
|
| 409 |
activePointers.set(e.pointerId, ptr);
|
| 410 |
|
| 411 |
+
// Second finger on same item -> start pinch+rotate
|
| 412 |
const sameItem = [...activePointers.values()].filter(p => p.origIdx === origIdx);
|
| 413 |
if (sameItem.length === 2 && !pinch) {
|
|
|
|
| 414 |
if (drag && drag.source === 'canvas') {
|
| 415 |
drag = null;
|
| 416 |
removeGhost();
|
| 417 |
}
|
| 418 |
const d = pointerDist(sameItem[0], sameItem[1]);
|
| 419 |
+
const a = pointerAngle(sameItem[0], sameItem[1]);
|
| 420 |
pinch = {
|
| 421 |
idx: origIdx,
|
| 422 |
startDist: d,
|
| 423 |
startScale: placedItems[origIdx].scale,
|
| 424 |
+
startAngle: a,
|
| 425 |
+
startRotation: placedItems[origIdx].rotation || 0,
|
| 426 |
startMidX: (sameItem[0].x + sameItem[1].x) / 2,
|
| 427 |
startMidY: (sameItem[0].y + sameItem[1].y) / 2,
|
| 428 |
startItemX: placedItems[origIdx].x,
|
|
|
|
| 431 |
return;
|
| 432 |
}
|
| 433 |
|
| 434 |
+
// Double-tap to remove
|
| 435 |
const now = Date.now();
|
| 436 |
if (lastPlacedTap.idx === origIdx && (now - lastPlacedTap.time) < DOUBLE_TAP_MS) {
|
| 437 |
img.classList.add('poof');
|
| 438 |
setTimeout(() => {
|
| 439 |
placedItems.splice(origIdx, 1);
|
| 440 |
renderPlaced();
|
| 441 |
+
autoSave();
|
| 442 |
}, 300);
|
| 443 |
lastPlacedTap = { idx: -1, time: 0 };
|
| 444 |
activePointers.delete(e.pointerId);
|
|
|
|
| 446 |
}
|
| 447 |
lastPlacedTap = { idx: origIdx, time: now };
|
| 448 |
|
| 449 |
+
// Single-finger drag
|
| 450 |
const item = placedItems[origIdx];
|
| 451 |
if (!item) return;
|
| 452 |
|
|
|
|
| 455 |
id: item.clothingId,
|
| 456 |
url: item.url,
|
| 457 |
scale: item.scale,
|
| 458 |
+
rotation: item.rotation || 0,
|
| 459 |
zIndex: item.zIndex,
|
| 460 |
+
isSticker: item.isSticker || false,
|
| 461 |
startX: e.clientX,
|
| 462 |
startY: e.clientY,
|
| 463 |
canvasIdx: origIdx,
|
|
|
|
| 466 |
}
|
| 467 |
|
| 468 |
function onPointerMove(e) {
|
|
|
|
| 469 |
if (activePointers.has(e.pointerId)) {
|
| 470 |
const ptr = activePointers.get(e.pointerId);
|
| 471 |
ptr.x = e.clientX;
|
| 472 |
ptr.y = e.clientY;
|
| 473 |
}
|
| 474 |
|
| 475 |
+
// Pinch-to-resize + rotate
|
| 476 |
if (pinch) {
|
| 477 |
e.preventDefault();
|
| 478 |
const sameItem = [...activePointers.values()].filter(p => p.origIdx === pinch.idx);
|
| 479 |
if (sameItem.length >= 2) {
|
| 480 |
const d = pointerDist(sameItem[0], sameItem[1]);
|
| 481 |
+
const a = pointerAngle(sameItem[0], sameItem[1]);
|
| 482 |
const ratio = d / pinch.startDist;
|
| 483 |
+
const newScale = Math.max(0.05, Math.min(3.0, pinch.startScale * ratio));
|
| 484 |
placedItems[pinch.idx].scale = newScale;
|
| 485 |
|
| 486 |
+
// Rotation
|
| 487 |
+
const angleDelta = a - pinch.startAngle;
|
| 488 |
+
placedItems[pinch.idx].rotation = pinch.startRotation + angleDelta;
|
| 489 |
+
|
| 490 |
+
// Move with midpoint
|
| 491 |
const canvas = document.getElementById('princess-canvas');
|
| 492 |
const rect = canvas.getBoundingClientRect();
|
| 493 |
+
const mid = { x: (sameItem[0].x + sameItem[1].x) / 2, y: (sameItem[0].y + sameItem[1].y) / 2 };
|
| 494 |
const dx = mid.x - pinch.startMidX;
|
| 495 |
const dy = mid.y - pinch.startMidY;
|
| 496 |
placedItems[pinch.idx].x = pinch.startItemX + dx / rect.width;
|
|
|
|
| 516 |
createGhost(drag.url);
|
| 517 |
ghost.classList.add('lifting');
|
| 518 |
} else if (drag.source === 'canvas') {
|
|
|
|
| 519 |
placedItems.splice(drag.canvasIdx, 1);
|
| 520 |
renderPlaced();
|
| 521 |
createGhost(drag.url);
|
|
|
|
| 529 |
function onPointerUp(e) {
|
| 530 |
activePointers.delete(e.pointerId);
|
| 531 |
|
|
|
|
| 532 |
if (pinch) {
|
| 533 |
const sameItem = [...activePointers.values()].filter(p => p.origIdx === pinch.idx);
|
| 534 |
if (sameItem.length < 2) {
|
| 535 |
pinch = null;
|
| 536 |
+
autoSave();
|
| 537 |
}
|
| 538 |
return;
|
| 539 |
}
|
|
|
|
| 541 |
if (!drag) return;
|
| 542 |
|
| 543 |
if (!drag.hasMoved) {
|
|
|
|
| 544 |
if (drag.source === 'tray') {
|
| 545 |
placedItems.push({
|
| 546 |
clothingId: drag.id,
|
| 547 |
x: 0.5,
|
| 548 |
y: 0.5,
|
| 549 |
scale: drag.scale,
|
| 550 |
+
rotation: 0,
|
| 551 |
zIndex: drag.zIndex,
|
| 552 |
url: drag.url,
|
| 553 |
+
isSticker: drag.isSticker,
|
| 554 |
});
|
| 555 |
renderPlaced();
|
| 556 |
+
autoSave();
|
| 557 |
}
|
| 558 |
drag = null;
|
| 559 |
return;
|
|
|
|
| 562 |
// Drop
|
| 563 |
const canvas = document.getElementById('princess-canvas');
|
| 564 |
const rect = canvas.getBoundingClientRect();
|
| 565 |
+
let dropped = false;
|
| 566 |
|
| 567 |
if (
|
| 568 |
e.clientX >= rect.left && e.clientX <= rect.right &&
|
|
|
|
| 575 |
x: nx,
|
| 576 |
y: ny,
|
| 577 |
scale: drag.scale,
|
| 578 |
+
rotation: drag.rotation || 0,
|
| 579 |
zIndex: drag.zIndex,
|
| 580 |
url: drag.url,
|
| 581 |
+
isSticker: drag.isSticker,
|
| 582 |
});
|
| 583 |
renderPlaced();
|
| 584 |
+
autoSave();
|
| 585 |
+
dropped = true;
|
| 586 |
+
}
|
| 587 |
+
|
| 588 |
+
if (!dropped && drag.source === 'canvas') {
|
| 589 |
+
autoSave();
|
| 590 |
}
|
| 591 |
|
| 592 |
removeGhost();
|
js/segmentation.js
CHANGED
|
@@ -1,23 +1,33 @@
|
|
| 1 |
// Background removal using @huggingface/transformers with RMBG-1.4
|
| 2 |
// Runs entirely in the browser (WebGPU with WASM fallback)
|
| 3 |
|
| 4 |
-
let
|
| 5 |
let loading = false;
|
| 6 |
let loadPromise = null;
|
| 7 |
|
| 8 |
-
/
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
if (loadPromise) return loadPromise;
|
| 15 |
|
| 16 |
loading = true;
|
| 17 |
loadPromise = _doLoad(onProgress);
|
| 18 |
try {
|
| 19 |
-
|
| 20 |
-
return pipeline;
|
| 21 |
} finally {
|
| 22 |
loading = false;
|
| 23 |
loadPromise = null;
|
|
@@ -25,12 +35,7 @@ export async function loadModel(onProgress) {
|
|
| 25 |
}
|
| 26 |
|
| 27 |
async function _doLoad(onProgress) {
|
| 28 |
-
|
| 29 |
-
const { AutoModel, AutoProcessor, RawImage, env } = await import(
|
| 30 |
-
'https://cdn.jsdelivr.net/npm/@huggingface/transformers@3'
|
| 31 |
-
);
|
| 32 |
-
|
| 33 |
-
env.allowLocalModels = false;
|
| 34 |
|
| 35 |
if (onProgress) onProgress({ status: 'loading', message: 'Loading AI model...' });
|
| 36 |
|
|
@@ -50,8 +55,53 @@ async function _doLoad(onProgress) {
|
|
| 50 |
|
| 51 |
const processor = await AutoProcessor.from_pretrained('briaai/RMBG-1.4');
|
| 52 |
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
}
|
| 56 |
|
| 57 |
/**
|
|
@@ -61,43 +111,53 @@ async function _doLoad(onProgress) {
|
|
| 61 |
* @returns {Promise<Blob>} - PNG with transparent background
|
| 62 |
*/
|
| 63 |
export async function removeBackground(imageBlob, onProgress) {
|
| 64 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
|
| 66 |
if (onProgress) onProgress({ status: 'processing', message: 'Removing background...' });
|
| 67 |
|
| 68 |
-
//
|
| 69 |
-
const url = URL.createObjectURL(
|
| 70 |
const image = await RawImage.fromURL(url);
|
| 71 |
URL.revokeObjectURL(url);
|
| 72 |
|
| 73 |
-
// Process
|
| 74 |
const { pixel_values } = await processor(image);
|
| 75 |
const { output } = await model({ input: pixel_values });
|
| 76 |
|
| 77 |
-
// The mask is a tensor, resize it to original image dimensions
|
| 78 |
const maskData = output[0].data; // Float32Array
|
| 79 |
const maskH = output[0].dims[1] || output.dims?.[2];
|
| 80 |
const maskW = output[0].dims[2] || output.dims?.[3];
|
| 81 |
|
| 82 |
-
//
|
| 83 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
|
| 85 |
const ctx = canvas.getContext('2d');
|
| 86 |
ctx.drawImage(bitmap, 0, 0);
|
|
|
|
| 87 |
|
| 88 |
-
|
| 89 |
-
const
|
| 90 |
-
const
|
| 91 |
-
const scaleY = maskH / bitmap.height;
|
| 92 |
|
| 93 |
-
for (let y = 0; y <
|
| 94 |
-
for (let x = 0; x <
|
| 95 |
const mx = Math.min(Math.floor(x * scaleX), maskW - 1);
|
| 96 |
const my = Math.min(Math.floor(y * scaleY), maskH - 1);
|
| 97 |
const maskVal = maskData[my * maskW + mx];
|
| 98 |
-
// Clamp to 0-255
|
| 99 |
const alpha = Math.round(Math.max(0, Math.min(1, maskVal)) * 255);
|
| 100 |
-
imgData.data[(y *
|
| 101 |
}
|
| 102 |
}
|
| 103 |
ctx.putImageData(imgData, 0, 0);
|
|
@@ -107,8 +167,15 @@ export async function removeBackground(imageBlob, onProgress) {
|
|
| 107 |
return canvas.convertToBlob({ type: 'image/png' });
|
| 108 |
}
|
| 109 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
export function isModelLoaded() {
|
| 111 |
-
return
|
| 112 |
}
|
| 113 |
|
| 114 |
export function isModelLoading() {
|
|
|
|
| 1 |
// Background removal using @huggingface/transformers with RMBG-1.4
|
| 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 |
+
const MODEL_DIM = 512; // resolution for model inference
|
| 9 |
+
const OUTPUT_DIM = 1024; // max resolution for final output
|
| 10 |
+
|
| 11 |
+
async function getTransformers() {
|
| 12 |
+
if (transformers) return transformers;
|
| 13 |
+
transformers = await import(
|
| 14 |
+
'https://cdn.jsdelivr.net/npm/@huggingface/transformers@3'
|
| 15 |
+
);
|
| 16 |
+
transformers.env.allowLocalModels = false;
|
| 17 |
+
// Enable browser Cache API on HTTPS (persists model across sessions)
|
| 18 |
+
if (location.protocol === 'https:') {
|
| 19 |
+
transformers.env.useBrowserCache = true;
|
| 20 |
+
}
|
| 21 |
+
return transformers;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
async function loadModel(onProgress) {
|
| 25 |
if (loadPromise) return loadPromise;
|
| 26 |
|
| 27 |
loading = true;
|
| 28 |
loadPromise = _doLoad(onProgress);
|
| 29 |
try {
|
| 30 |
+
return await loadPromise;
|
|
|
|
| 31 |
} finally {
|
| 32 |
loading = false;
|
| 33 |
loadPromise = null;
|
|
|
|
| 35 |
}
|
| 36 |
|
| 37 |
async function _doLoad(onProgress) {
|
| 38 |
+
const { AutoModel, AutoProcessor } = await getTransformers();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
if (onProgress) onProgress({ status: 'loading', message: 'Loading AI model...' });
|
| 41 |
|
|
|
|
| 55 |
|
| 56 |
const processor = await AutoProcessor.from_pretrained('briaai/RMBG-1.4');
|
| 57 |
|
| 58 |
+
return { model, processor };
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
/**
|
| 62 |
+
* Decode image once and produce two sizes (output + model).
|
| 63 |
+
* Avoids decoding the full-res camera image multiple times.
|
| 64 |
+
*/
|
| 65 |
+
async function prepareImages(imageBlob) {
|
| 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 |
+
// Output size (what gets saved)
|
| 73 |
+
const outRatio = Math.min(1, OUTPUT_DIM / Math.max(fullW, fullH));
|
| 74 |
+
const outW = Math.round(fullW * outRatio);
|
| 75 |
+
const outH = Math.round(fullH * outRatio);
|
| 76 |
+
|
| 77 |
+
// Use resizeWidth/resizeHeight to avoid a full-res intermediate bitmap
|
| 78 |
+
const outBitmap = await createImageBitmap(imageBlob, {
|
| 79 |
+
resizeWidth: outW, resizeHeight: outH, resizeQuality: 'medium',
|
| 80 |
+
});
|
| 81 |
+
const outCanvas = new OffscreenCanvas(outW, outH);
|
| 82 |
+
outCanvas.getContext('2d').drawImage(outBitmap, 0, 0);
|
| 83 |
+
outBitmap.close();
|
| 84 |
+
const outputBlob = await outCanvas.convertToBlob({ type: 'image/png' });
|
| 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 |
* @returns {Promise<Blob>} - PNG with transparent background
|
| 112 |
*/
|
| 113 |
export async function removeBackground(imageBlob, onProgress) {
|
| 114 |
+
// 1. Prepare downsized images BEFORE loading model (lower peak memory)
|
| 115 |
+
if (onProgress) onProgress({ status: 'processing', message: 'Preparing image...' });
|
| 116 |
+
const { outputBlob, modelBlob } = await prepareImages(imageBlob);
|
| 117 |
+
|
| 118 |
+
// 2. Load model
|
| 119 |
+
const { model, processor } = await loadModel(onProgress);
|
| 120 |
+
const { RawImage } = await getTransformers();
|
| 121 |
|
| 122 |
if (onProgress) onProgress({ status: 'processing', message: 'Removing background...' });
|
| 123 |
|
| 124 |
+
// 3. Run inference on small image
|
| 125 |
+
const url = URL.createObjectURL(modelBlob);
|
| 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 to free WASM memory
|
| 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 |
+
// 5. Apply mask to the output-sized image
|
| 144 |
+
const bitmap = await createImageBitmap(outputBlob);
|
| 145 |
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
|
| 146 |
const ctx = canvas.getContext('2d');
|
| 147 |
ctx.drawImage(bitmap, 0, 0);
|
| 148 |
+
bitmap.close();
|
| 149 |
|
| 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 |
+
for (let y = 0; y < canvas.height; y++) {
|
| 155 |
+
for (let x = 0; x < canvas.width; x++) {
|
| 156 |
const mx = Math.min(Math.floor(x * scaleX), maskW - 1);
|
| 157 |
const my = Math.min(Math.floor(y * scaleY), maskH - 1);
|
| 158 |
const maskVal = maskData[my * maskW + mx];
|
|
|
|
| 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);
|
|
|
|
| 167 |
return canvas.convertToBlob({ type: 'image/png' });
|
| 168 |
}
|
| 169 |
|
| 170 |
+
function disposeModel(model) {
|
| 171 |
+
try {
|
| 172 |
+
if (model.dispose) model.dispose();
|
| 173 |
+
} catch (_) {}
|
| 174 |
+
loadPromise = null;
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
export function isModelLoaded() {
|
| 178 |
+
return loading;
|
| 179 |
}
|
| 180 |
|
| 181 |
export function isModelLoading() {
|
js/stickers.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Built-in decorative stickers for the dress-up screen.
|
| 2 |
+
// Each sticker is an inline SVG data URL — no downloads needed.
|
| 3 |
+
|
| 4 |
+
function svgURL(svg) {
|
| 5 |
+
return 'data:image/svg+xml,' + encodeURIComponent(svg.trim());
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
export const STICKERS = [
|
| 9 |
+
{
|
| 10 |
+
id: 'stk-sparkle',
|
| 11 |
+
name: 'Sparkles',
|
| 12 |
+
emoji: '\u2728',
|
| 13 |
+
scale: 0.25,
|
| 14 |
+
zIndex: 10,
|
| 15 |
+
url: svgURL(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
| 16 |
+
<polygon points="50,5 58,38 95,38 65,58 75,95 50,72 25,95 35,58 5,38 42,38" fill="#FFD700" opacity="0.9"/>
|
| 17 |
+
<polygon points="50,20 55,42 78,42 60,55 67,78 50,65 33,78 40,55 22,42 45,42" fill="#FFF8DC"/>
|
| 18 |
+
<circle cx="25" cy="20" r="4" fill="#FFD700" opacity="0.7"/>
|
| 19 |
+
<circle cx="80" cy="15" r="3" fill="#FFD700" opacity="0.6"/>
|
| 20 |
+
<circle cx="15" cy="75" r="3" fill="#FFD700" opacity="0.5"/>
|
| 21 |
+
<circle cx="85" cy="70" r="4" fill="#FFD700" opacity="0.6"/>
|
| 22 |
+
</svg>`),
|
| 23 |
+
},
|
| 24 |
+
{
|
| 25 |
+
id: 'stk-heart',
|
| 26 |
+
name: 'Heart',
|
| 27 |
+
emoji: '\u{1F496}',
|
| 28 |
+
scale: 0.2,
|
| 29 |
+
zIndex: 10,
|
| 30 |
+
url: svgURL(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
| 31 |
+
<path d="M50 88 C25 65 5 50 5 30 A22 22 0 0 1 50 25 A22 22 0 0 1 95 30 C95 50 75 65 50 88Z" fill="#FF69B4"/>
|
| 32 |
+
<path d="M50 80 C30 62 15 50 15 34 A16 16 0 0 1 50 30 A16 16 0 0 1 85 34 C85 50 70 62 50 80Z" fill="#FFB6C1" opacity="0.5"/>
|
| 33 |
+
</svg>`),
|
| 34 |
+
},
|
| 35 |
+
{
|
| 36 |
+
id: 'stk-star',
|
| 37 |
+
name: 'Star',
|
| 38 |
+
emoji: '\u2B50',
|
| 39 |
+
scale: 0.22,
|
| 40 |
+
zIndex: 10,
|
| 41 |
+
url: svgURL(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
| 42 |
+
<polygon points="50,5 63,35 95,40 72,62 78,95 50,78 22,95 28,62 5,40 37,35" fill="#FFD700"/>
|
| 43 |
+
<polygon points="50,18 59,38 80,42 65,56 69,78 50,67 31,78 35,56 20,42 41,38" fill="#FFF8DC" opacity="0.6"/>
|
| 44 |
+
</svg>`),
|
| 45 |
+
},
|
| 46 |
+
{
|
| 47 |
+
id: 'stk-butterfly',
|
| 48 |
+
name: 'Butterfly',
|
| 49 |
+
emoji: '\u{1F98B}',
|
| 50 |
+
scale: 0.3,
|
| 51 |
+
zIndex: 10,
|
| 52 |
+
url: svgURL(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 100">
|
| 53 |
+
<ellipse cx="35" cy="35" rx="30" ry="25" fill="#DDA0DD" transform="rotate(-15 35 35)"/>
|
| 54 |
+
<ellipse cx="85" cy="35" rx="30" ry="25" fill="#DDA0DD" transform="rotate(15 85 35)"/>
|
| 55 |
+
<ellipse cx="30" cy="62" rx="22" ry="18" fill="#E6B8E6" transform="rotate(-10 30 62)"/>
|
| 56 |
+
<ellipse cx="90" cy="62" rx="22" ry="18" fill="#E6B8E6" transform="rotate(10 90 62)"/>
|
| 57 |
+
<ellipse cx="35" cy="35" rx="18" ry="14" fill="#BA55D3" opacity="0.5" transform="rotate(-15 35 35)"/>
|
| 58 |
+
<ellipse cx="85" cy="35" rx="18" ry="14" fill="#BA55D3" opacity="0.5" transform="rotate(15 85 35)"/>
|
| 59 |
+
<rect x="58" y="15" width="4" height="65" rx="2" fill="#4B0082"/>
|
| 60 |
+
<circle cx="55" cy="10" r="4" fill="#4B0082"/><circle cx="65" cy="10" r="4" fill="#4B0082"/>
|
| 61 |
+
</svg>`),
|
| 62 |
+
},
|
| 63 |
+
{
|
| 64 |
+
id: 'stk-flower',
|
| 65 |
+
name: 'Flower',
|
| 66 |
+
emoji: '\u{1F338}',
|
| 67 |
+
scale: 0.22,
|
| 68 |
+
zIndex: 10,
|
| 69 |
+
url: svgURL(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
| 70 |
+
<circle cx="50" cy="28" r="18" fill="#FFB7C5"/>
|
| 71 |
+
<circle cx="28" cy="45" r="18" fill="#FFB7C5"/>
|
| 72 |
+
<circle cx="72" cy="45" r="18" fill="#FFB7C5"/>
|
| 73 |
+
<circle cx="35" cy="68" r="18" fill="#FFB7C5"/>
|
| 74 |
+
<circle cx="65" cy="68" r="18" fill="#FFB7C5"/>
|
| 75 |
+
<circle cx="50" cy="50" r="14" fill="#FFD700"/>
|
| 76 |
+
<circle cx="46" cy="46" r="4" fill="#FFA500" opacity="0.5"/>
|
| 77 |
+
<circle cx="54" cy="52" r="3" fill="#FFA500" opacity="0.4"/>
|
| 78 |
+
</svg>`),
|
| 79 |
+
},
|
| 80 |
+
{
|
| 81 |
+
id: 'stk-rainbow',
|
| 82 |
+
name: 'Rainbow',
|
| 83 |
+
emoji: '\u{1F308}',
|
| 84 |
+
scale: 0.4,
|
| 85 |
+
zIndex: 9,
|
| 86 |
+
url: svgURL(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 140 80">
|
| 87 |
+
<path d="M10 75 A60 60 0 0 1 130 75" fill="none" stroke="#FF0000" stroke-width="8" opacity="0.7"/>
|
| 88 |
+
<path d="M18 75 A52 52 0 0 1 122 75" fill="none" stroke="#FF8C00" stroke-width="8" opacity="0.7"/>
|
| 89 |
+
<path d="M26 75 A44 44 0 0 1 114 75" fill="none" stroke="#FFD700" stroke-width="8" opacity="0.7"/>
|
| 90 |
+
<path d="M34 75 A36 36 0 0 1 106 75" fill="none" stroke="#32CD32" stroke-width="8" opacity="0.7"/>
|
| 91 |
+
<path d="M42 75 A28 28 0 0 1 98 75" fill="none" stroke="#4169E1" stroke-width="8" opacity="0.7"/>
|
| 92 |
+
<path d="M50 75 A20 20 0 0 1 90 75" fill="none" stroke="#8A2BE2" stroke-width="8" opacity="0.7"/>
|
| 93 |
+
</svg>`),
|
| 94 |
+
},
|
| 95 |
+
{
|
| 96 |
+
id: 'stk-gem',
|
| 97 |
+
name: 'Gem',
|
| 98 |
+
emoji: '\u{1F48E}',
|
| 99 |
+
scale: 0.18,
|
| 100 |
+
zIndex: 10,
|
| 101 |
+
url: svgURL(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
| 102 |
+
<polygon points="50,8 85,35 70,92 30,92 15,35" fill="#87CEEB"/>
|
| 103 |
+
<polygon points="50,8 65,35 50,92 35,35" fill="#ADD8E6" opacity="0.7"/>
|
| 104 |
+
<polygon points="50,8 85,35 65,35" fill="#B0E0E6" opacity="0.5"/>
|
| 105 |
+
<line x1="50" y1="8" x2="50" y2="92" stroke="white" stroke-width="1" opacity="0.3"/>
|
| 106 |
+
<polygon points="15,35 35,35 30,92" fill="#5F9EA0" opacity="0.3"/>
|
| 107 |
+
</svg>`),
|
| 108 |
+
},
|
| 109 |
+
{
|
| 110 |
+
id: 'stk-bow',
|
| 111 |
+
name: 'Bow',
|
| 112 |
+
emoji: '\u{1F380}',
|
| 113 |
+
scale: 0.25,
|
| 114 |
+
zIndex: 10,
|
| 115 |
+
url: svgURL(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 80">
|
| 116 |
+
<path d="M60 40 Q20 5 8 35 Q5 50 60 40Z" fill="#FF69B4"/>
|
| 117 |
+
<path d="M60 40 Q100 5 112 35 Q115 50 60 40Z" fill="#FF69B4"/>
|
| 118 |
+
<path d="M60 40 Q40 55 35 72 Q50 60 60 40Z" fill="#FF69B4"/>
|
| 119 |
+
<path d="M60 40 Q80 55 85 72 Q70 60 60 40Z" fill="#FF69B4"/>
|
| 120 |
+
<circle cx="60" cy="40" r="8" fill="#FF1493"/>
|
| 121 |
+
<path d="M60 40 Q25 10 12 32" fill="none" stroke="#FFB6C1" stroke-width="2" opacity="0.5"/>
|
| 122 |
+
<path d="M60 40 Q95 10 108 32" fill="none" stroke="#FFB6C1" stroke-width="2" opacity="0.5"/>
|
| 123 |
+
</svg>`),
|
| 124 |
+
},
|
| 125 |
+
{
|
| 126 |
+
id: 'stk-moon',
|
| 127 |
+
name: 'Moon',
|
| 128 |
+
emoji: '\u{1F319}',
|
| 129 |
+
scale: 0.2,
|
| 130 |
+
zIndex: 9,
|
| 131 |
+
url: svgURL(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
| 132 |
+
<circle cx="45" cy="50" r="38" fill="#FFD700"/>
|
| 133 |
+
<circle cx="62" cy="42" r="32" fill="#FDF6E3"/>
|
| 134 |
+
<circle cx="30" cy="35" r="3" fill="#FFF8DC" opacity="0.6"/>
|
| 135 |
+
<circle cx="25" cy="55" r="2" fill="#FFF8DC" opacity="0.5"/>
|
| 136 |
+
</svg>`),
|
| 137 |
+
},
|
| 138 |
+
{
|
| 139 |
+
id: 'stk-wand',
|
| 140 |
+
name: 'Magic wand',
|
| 141 |
+
emoji: '\u{1FA84}',
|
| 142 |
+
scale: 0.35,
|
| 143 |
+
zIndex: 10,
|
| 144 |
+
url: svgURL(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 120">
|
| 145 |
+
<line x1="50" y1="45" x2="50" y2="115" stroke="#DDA0DD" stroke-width="6" stroke-linecap="round"/>
|
| 146 |
+
<line x1="50" y1="45" x2="50" y2="115" stroke="#BA55D3" stroke-width="3" stroke-linecap="round"/>
|
| 147 |
+
<polygon points="50,2 57,18 74,20 62,32 65,50 50,42 35,50 38,32 26,20 43,18" fill="#FFD700"/>
|
| 148 |
+
<polygon points="50,10 54,22 66,23 58,31 60,43 50,37 40,43 42,31 34,23 46,22" fill="#FFF8DC" opacity="0.7"/>
|
| 149 |
+
<circle cx="30" cy="12" r="2" fill="#FFD700" opacity="0.7"/>
|
| 150 |
+
<circle cx="72" cy="8" r="2.5" fill="#FFD700" opacity="0.6"/>
|
| 151 |
+
<circle cx="22" cy="35" r="2" fill="#FFD700" opacity="0.5"/>
|
| 152 |
+
<circle cx="78" cy="32" r="1.5" fill="#FFD700" opacity="0.6"/>
|
| 153 |
+
</svg>`),
|
| 154 |
+
},
|
| 155 |
+
{
|
| 156 |
+
id: 'stk-crown',
|
| 157 |
+
name: 'Tiara',
|
| 158 |
+
emoji: '\u{1F451}',
|
| 159 |
+
scale: 0.3,
|
| 160 |
+
zIndex: 11,
|
| 161 |
+
url: svgURL(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 70">
|
| 162 |
+
<path d="M10 60 L20 20 L40 40 L60 8 L80 40 L100 20 L110 60Z" fill="#FFD700"/>
|
| 163 |
+
<path d="M15 60 L23 25 L40 42 L60 15 L80 42 L97 25 L105 60Z" fill="#FFF8DC" opacity="0.4"/>
|
| 164 |
+
<circle cx="60" cy="18" r="6" fill="#FF69B4"/>
|
| 165 |
+
<circle cx="22" cy="28" r="5" fill="#87CEEB"/>
|
| 166 |
+
<circle cx="98" cy="28" r="5" fill="#87CEEB"/>
|
| 167 |
+
<rect x="10" y="55" width="100" height="10" rx="3" fill="#FFD700"/>
|
| 168 |
+
<rect x="12" y="57" width="96" height="6" rx="2" fill="#DAA520" opacity="0.3"/>
|
| 169 |
+
</svg>`),
|
| 170 |
+
},
|
| 171 |
+
{
|
| 172 |
+
id: 'stk-cloud',
|
| 173 |
+
name: 'Cloud',
|
| 174 |
+
emoji: '\u2601\uFE0F',
|
| 175 |
+
scale: 0.3,
|
| 176 |
+
zIndex: 8,
|
| 177 |
+
url: svgURL(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 70">
|
| 178 |
+
<ellipse cx="60" cy="45" rx="50" ry="22" fill="white" opacity="0.85"/>
|
| 179 |
+
<circle cx="40" cy="32" r="22" fill="white" opacity="0.85"/>
|
| 180 |
+
<circle cx="65" cy="28" r="26" fill="white" opacity="0.85"/>
|
| 181 |
+
<circle cx="85" cy="36" r="18" fill="white" opacity="0.85"/>
|
| 182 |
+
</svg>`),
|
| 183 |
+
},
|
| 184 |
+
];
|
js/store.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
const DB_NAME = 'princess-game';
|
| 2 |
-
const DB_VERSION =
|
| 3 |
|
| 4 |
let db = null;
|
| 5 |
|
|
@@ -24,6 +24,9 @@ function openDB() {
|
|
| 24 |
if (!d.objectStoreNames.contains('clothes')) {
|
| 25 |
d.createObjectStore('clothes', { keyPath: 'id' });
|
| 26 |
}
|
|
|
|
|
|
|
|
|
|
| 27 |
};
|
| 28 |
req.onsuccess = () => { db = req.result; resolve(db); };
|
| 29 |
req.onerror = () => reject(req.error);
|
|
@@ -73,12 +76,21 @@ export async function updatePrincessAnchors(id, anchors) {
|
|
| 73 |
}
|
| 74 |
}
|
| 75 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
// --- Clothes ---
|
| 77 |
|
| 78 |
export async function saveClothing({ name, category, imageBlob, targetAnchor, scale, zIndex }) {
|
| 79 |
const id = uuid();
|
| 80 |
const store = await tx('clothes', 'readwrite');
|
| 81 |
-
const record = { id, name, category, imageBlob, targetAnchor, scale: scale || 0.
|
| 82 |
await reqToPromise(store.put(record));
|
| 83 |
return record;
|
| 84 |
}
|
|
@@ -98,6 +110,26 @@ export async function deleteClothing(id) {
|
|
| 98 |
await reqToPromise(store.delete(id));
|
| 99 |
}
|
| 100 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
// --- Helpers ---
|
| 102 |
|
| 103 |
export function blobToURL(blob) {
|
|
|
|
| 1 |
const DB_NAME = 'princess-game';
|
| 2 |
+
const DB_VERSION = 2;
|
| 3 |
|
| 4 |
let db = null;
|
| 5 |
|
|
|
|
| 24 |
if (!d.objectStoreNames.contains('clothes')) {
|
| 25 |
d.createObjectStore('clothes', { keyPath: 'id' });
|
| 26 |
}
|
| 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);
|
|
|
|
| 76 |
}
|
| 77 |
}
|
| 78 |
|
| 79 |
+
export async function savePrincessOutfit(id, outfit) {
|
| 80 |
+
const store = await tx('princesses', 'readwrite');
|
| 81 |
+
const p = await reqToPromise(store.get(id));
|
| 82 |
+
if (p) {
|
| 83 |
+
p.outfit = outfit;
|
| 84 |
+
await reqToPromise(store.put(p));
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
// --- Clothes ---
|
| 89 |
|
| 90 |
export async function saveClothing({ name, category, imageBlob, targetAnchor, scale, zIndex }) {
|
| 91 |
const id = uuid();
|
| 92 |
const store = await tx('clothes', 'readwrite');
|
| 93 |
+
const record = { id, name, category, imageBlob, targetAnchor, scale: scale || 0.8, zIndex, createdAt: Date.now() };
|
| 94 |
await reqToPromise(store.put(record));
|
| 95 |
return record;
|
| 96 |
}
|
|
|
|
| 110 |
await reqToPromise(store.delete(id));
|
| 111 |
}
|
| 112 |
|
| 113 |
+
// --- Photos (captured source images) ---
|
| 114 |
+
|
| 115 |
+
export async function savePhoto(imageBlob) {
|
| 116 |
+
const id = uuid();
|
| 117 |
+
const store = await tx('photos', 'readwrite');
|
| 118 |
+
const record = { id, imageBlob, createdAt: Date.now() };
|
| 119 |
+
await reqToPromise(store.put(record));
|
| 120 |
+
return record;
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
export async function loadPhotos() {
|
| 124 |
+
const store = await tx('photos');
|
| 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));
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
// --- Helpers ---
|
| 134 |
|
| 135 |
export function blobToURL(blob) {
|
js/upload.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
import { CATEGORIES, getCategoryInfo, DEFAULT_ANCHORS } from './models.js';
|
| 2 |
-
import { savePrincess, saveClothing } from './store.js';
|
| 3 |
-
import { removeBackground
|
| 4 |
import { showPolygonSelector } from './polygon-select.js';
|
| 5 |
import { navigate } from './app.js';
|
| 6 |
|
|
@@ -13,8 +13,16 @@ let segmentedBlob = null; // after background removal
|
|
| 13 |
let previewURL = null;
|
| 14 |
let croppedURL = null;
|
| 15 |
|
| 16 |
-
|
|
|
|
|
|
|
| 17 |
const screen = document.getElementById('upload-screen');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
screen.innerHTML = `
|
| 19 |
<div class="upload-header">
|
| 20 |
<a href="#/" class="back-btn">\u2190 Back</a>
|
|
@@ -34,6 +42,24 @@ export function initUpload() {
|
|
| 34 |
</label>
|
| 35 |
</div>
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
<button id="select-area-btn" class="pill select-area-btn" style="display:none">
|
| 38 |
\u2702\uFE0F Select area
|
| 39 |
</button>
|
|
@@ -56,7 +82,7 @@ export function initUpload() {
|
|
| 56 |
`;
|
| 57 |
|
| 58 |
buildCategoryGrid();
|
| 59 |
-
bindEvents();
|
| 60 |
resetState();
|
| 61 |
}
|
| 62 |
|
|
@@ -70,7 +96,24 @@ function buildCategoryGrid() {
|
|
| 70 |
`).join('');
|
| 71 |
}
|
| 72 |
|
| 73 |
-
function
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
// Type toggle
|
| 75 |
document.querySelectorAll('.type-toggle .pill').forEach(btn => {
|
| 76 |
btn.addEventListener('click', () => {
|
|
@@ -92,27 +135,39 @@ function bindEvents() {
|
|
| 92 |
btn.classList.add('selected');
|
| 93 |
});
|
| 94 |
|
| 95 |
-
// Camera input
|
| 96 |
-
document.getElementById('camera-input').addEventListener('change', (e) => {
|
| 97 |
const file = e.target.files[0];
|
| 98 |
if (!file) return;
|
| 99 |
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
if (previewURL) URL.revokeObjectURL(previewURL);
|
| 105 |
-
if (croppedURL) URL.revokeObjectURL(croppedURL);
|
| 106 |
-
previewURL = URL.createObjectURL(file);
|
| 107 |
-
croppedURL = null;
|
| 108 |
-
|
| 109 |
-
showPhotoPreview(previewURL);
|
| 110 |
-
document.getElementById('select-area-btn').style.display = '';
|
| 111 |
-
document.getElementById('upload-status').innerHTML = '';
|
| 112 |
-
document.getElementById('submit-btn').style.display = '';
|
| 113 |
-
updateSubmitState();
|
| 114 |
});
|
| 115 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
// Select area
|
| 117 |
document.getElementById('select-area-btn').addEventListener('click', handleSelectArea);
|
| 118 |
|
|
@@ -143,7 +198,7 @@ async function handleSelectArea() {
|
|
| 143 |
if (croppedURL) URL.revokeObjectURL(croppedURL);
|
| 144 |
croppedURL = URL.createObjectURL(croppedBlob);
|
| 145 |
|
| 146 |
-
showPhotoPreview(
|
| 147 |
document.getElementById('select-area-btn').textContent = '\u2702\uFE0F Reselect area';
|
| 148 |
updateSubmitState();
|
| 149 |
}
|
|
@@ -167,9 +222,7 @@ async function handleMagicTime() {
|
|
| 167 |
btn.disabled = true;
|
| 168 |
btn.style.display = 'none';
|
| 169 |
|
| 170 |
-
const modelHint =
|
| 171 |
-
? ''
|
| 172 |
-
: '<p class="progress-hint">First time takes a moment to download the AI model.</p>';
|
| 173 |
|
| 174 |
status.innerHTML = `
|
| 175 |
<div class="processing">
|
|
@@ -200,7 +253,7 @@ async function handleMagicTime() {
|
|
| 200 |
|
| 201 |
// Show preview with approve/retry
|
| 202 |
const segURL = URL.createObjectURL(segmentedBlob);
|
| 203 |
-
const beforeURL =
|
| 204 |
status.innerHTML = `
|
| 205 |
<div class="seg-preview">
|
| 206 |
<p class="seg-preview-label">How does it look?</p>
|
|
@@ -248,25 +301,8 @@ async function handleMagicTime() {
|
|
| 248 |
}
|
| 249 |
|
| 250 |
function resetToCamera() {
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
status.innerHTML = '';
|
| 254 |
-
btn.style.display = '';
|
| 255 |
-
btn.disabled = false;
|
| 256 |
-
document.getElementById('camera-input').value = '';
|
| 257 |
-
const label = document.getElementById('camera-label');
|
| 258 |
-
label.innerHTML = `
|
| 259 |
-
<span class="camera-icon">\u{1F4F8}</span>
|
| 260 |
-
<span class="camera-text">Tap to photograph!</span>
|
| 261 |
-
`;
|
| 262 |
-
label.className = 'camera-label';
|
| 263 |
-
document.getElementById('select-area-btn').style.display = 'none';
|
| 264 |
-
document.getElementById('select-area-btn').textContent = '\u2702\uFE0F Select area';
|
| 265 |
-
imageBlob = null;
|
| 266 |
-
croppedBlob = null;
|
| 267 |
-
polygon = null;
|
| 268 |
-
segmentedBlob = null;
|
| 269 |
-
updateSubmitState();
|
| 270 |
}
|
| 271 |
|
| 272 |
/**
|
|
@@ -338,7 +374,7 @@ async function saveAsset(name, blob) {
|
|
| 338 |
category: selectedCategory,
|
| 339 |
imageBlob: blob,
|
| 340 |
targetAnchor: catInfo.targetAnchor,
|
| 341 |
-
scale: 0.
|
| 342 |
zIndex: catInfo.zIndex,
|
| 343 |
});
|
| 344 |
}
|
|
|
|
| 1 |
import { CATEGORIES, getCategoryInfo, DEFAULT_ANCHORS } from './models.js';
|
| 2 |
+
import { savePrincess, saveClothing, savePhoto, loadPhotos, deletePhoto, blobToURL } from './store.js';
|
| 3 |
+
import { removeBackground } from './segmentation.js';
|
| 4 |
import { showPolygonSelector } from './polygon-select.js';
|
| 5 |
import { navigate } from './app.js';
|
| 6 |
|
|
|
|
| 13 |
let previewURL = null;
|
| 14 |
let croppedURL = null;
|
| 15 |
|
| 16 |
+
let photoURLs = []; // object URLs for gallery thumbnails
|
| 17 |
+
|
| 18 |
+
export async function initUpload() {
|
| 19 |
const screen = document.getElementById('upload-screen');
|
| 20 |
+
|
| 21 |
+
photoURLs.forEach(u => URL.revokeObjectURL(u));
|
| 22 |
+
photoURLs = [];
|
| 23 |
+
|
| 24 |
+
const photos = await loadPhotos();
|
| 25 |
+
|
| 26 |
screen.innerHTML = `
|
| 27 |
<div class="upload-header">
|
| 28 |
<a href="#/" class="back-btn">\u2190 Back</a>
|
|
|
|
| 42 |
</label>
|
| 43 |
</div>
|
| 44 |
|
| 45 |
+
${photos.length > 0 ? `
|
| 46 |
+
<div class="photo-gallery">
|
| 47 |
+
<p class="picker-label">Or use a previous photo</p>
|
| 48 |
+
<div class="photo-gallery-scroll" id="photo-gallery">
|
| 49 |
+
${photos.map(p => {
|
| 50 |
+
const url = blobToURL(p.imageBlob);
|
| 51 |
+
photoURLs.push(url);
|
| 52 |
+
return `
|
| 53 |
+
<div class="photo-gallery-item" data-photo-id="${p.id}">
|
| 54 |
+
<img src="${url}" class="photo-gallery-thumb" draggable="false"/>
|
| 55 |
+
<button class="photo-gallery-delete" data-photo-id="${p.id}">\u00D7</button>
|
| 56 |
+
</div>
|
| 57 |
+
`;
|
| 58 |
+
}).join('')}
|
| 59 |
+
</div>
|
| 60 |
+
</div>
|
| 61 |
+
` : ''}
|
| 62 |
+
|
| 63 |
<button id="select-area-btn" class="pill select-area-btn" style="display:none">
|
| 64 |
\u2702\uFE0F Select area
|
| 65 |
</button>
|
|
|
|
| 82 |
`;
|
| 83 |
|
| 84 |
buildCategoryGrid();
|
| 85 |
+
bindEvents(photos);
|
| 86 |
resetState();
|
| 87 |
}
|
| 88 |
|
|
|
|
| 96 |
`).join('');
|
| 97 |
}
|
| 98 |
|
| 99 |
+
function selectImage(blob) {
|
| 100 |
+
imageBlob = blob;
|
| 101 |
+
croppedBlob = null;
|
| 102 |
+
polygon = null;
|
| 103 |
+
segmentedBlob = null;
|
| 104 |
+
if (previewURL) URL.revokeObjectURL(previewURL);
|
| 105 |
+
if (croppedURL) URL.revokeObjectURL(croppedURL);
|
| 106 |
+
previewURL = URL.createObjectURL(blob);
|
| 107 |
+
croppedURL = null;
|
| 108 |
+
|
| 109 |
+
showPhotoPreview(previewURL);
|
| 110 |
+
document.getElementById('select-area-btn').style.display = '';
|
| 111 |
+
document.getElementById('upload-status').innerHTML = '';
|
| 112 |
+
document.getElementById('submit-btn').style.display = '';
|
| 113 |
+
updateSubmitState();
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
function bindEvents(photos) {
|
| 117 |
// Type toggle
|
| 118 |
document.querySelectorAll('.type-toggle .pill').forEach(btn => {
|
| 119 |
btn.addEventListener('click', () => {
|
|
|
|
| 135 |
btn.classList.add('selected');
|
| 136 |
});
|
| 137 |
|
| 138 |
+
// Camera input — save to gallery automatically
|
| 139 |
+
document.getElementById('camera-input').addEventListener('change', async (e) => {
|
| 140 |
const file = e.target.files[0];
|
| 141 |
if (!file) return;
|
| 142 |
|
| 143 |
+
// Save to photo gallery for reuse
|
| 144 |
+
await savePhoto(file);
|
| 145 |
+
|
| 146 |
+
selectImage(file);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
});
|
| 148 |
|
| 149 |
+
// Photo gallery — tap to reuse, X to delete
|
| 150 |
+
const gallery = document.getElementById('photo-gallery');
|
| 151 |
+
if (gallery) {
|
| 152 |
+
gallery.addEventListener('click', async (e) => {
|
| 153 |
+
// Delete button
|
| 154 |
+
const delBtn = e.target.closest('.photo-gallery-delete');
|
| 155 |
+
if (delBtn) {
|
| 156 |
+
e.stopPropagation();
|
| 157 |
+
const id = delBtn.dataset.photoId;
|
| 158 |
+
await deletePhoto(id);
|
| 159 |
+
initUpload(); // re-render
|
| 160 |
+
return;
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
// Select photo
|
| 164 |
+
const item = e.target.closest('.photo-gallery-item');
|
| 165 |
+
if (!item) return;
|
| 166 |
+
const photo = photos.find(p => p.id === item.dataset.photoId);
|
| 167 |
+
if (photo) selectImage(photo.imageBlob);
|
| 168 |
+
});
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
// Select area
|
| 172 |
document.getElementById('select-area-btn').addEventListener('click', handleSelectArea);
|
| 173 |
|
|
|
|
| 198 |
if (croppedURL) URL.revokeObjectURL(croppedURL);
|
| 199 |
croppedURL = URL.createObjectURL(croppedBlob);
|
| 200 |
|
| 201 |
+
showPhotoPreview(previewURL, '\u2702\uFE0F Area selected \u2014 tap to retake photo');
|
| 202 |
document.getElementById('select-area-btn').textContent = '\u2702\uFE0F Reselect area';
|
| 203 |
updateSubmitState();
|
| 204 |
}
|
|
|
|
| 222 |
btn.disabled = true;
|
| 223 |
btn.style.display = 'none';
|
| 224 |
|
| 225 |
+
const modelHint = '<p class="progress-hint">This might take a moment.</p>';
|
|
|
|
|
|
|
| 226 |
|
| 227 |
status.innerHTML = `
|
| 228 |
<div class="processing">
|
|
|
|
| 253 |
|
| 254 |
// Show preview with approve/retry
|
| 255 |
const segURL = URL.createObjectURL(segmentedBlob);
|
| 256 |
+
const beforeURL = previewURL;
|
| 257 |
status.innerHTML = `
|
| 258 |
<div class="seg-preview">
|
| 259 |
<p class="seg-preview-label">How does it look?</p>
|
|
|
|
| 301 |
}
|
| 302 |
|
| 303 |
function resetToCamera() {
|
| 304 |
+
resetState();
|
| 305 |
+
initUpload();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 306 |
}
|
| 307 |
|
| 308 |
/**
|
|
|
|
| 374 |
category: selectedCategory,
|
| 375 |
imageBlob: blob,
|
| 376 |
targetAnchor: catInfo.targetAnchor,
|
| 377 |
+
scale: 0.8,
|
| 378 |
zIndex: catInfo.zIndex,
|
| 379 |
});
|
| 380 |
}
|
style.css
CHANGED
|
@@ -161,6 +161,35 @@ h2 { font-size: 1.5rem; font-weight: 700; color: var(--lavender-dk); }
|
|
| 161 |
|
| 162 |
.switch-label { font-size: 1.1rem; font-weight: 700; }
|
| 163 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
.clothing-tray {
|
| 165 |
display: flex;
|
| 166 |
gap: 0.5rem;
|
|
@@ -227,6 +256,23 @@ h2 { font-size: 1.5rem; font-weight: 700; color: var(--lavender-dk); }
|
|
| 227 |
flex-shrink: 0;
|
| 228 |
}
|
| 229 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
/* ---- Manage screen ---- */
|
| 231 |
|
| 232 |
#manage-screen {
|
|
@@ -376,6 +422,64 @@ h2 { font-size: 1.5rem; font-weight: 700; color: var(--lavender-dk); }
|
|
| 376 |
box-shadow: 0 2px 0 var(--ink);
|
| 377 |
}
|
| 378 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 379 |
.camera-zone { position: relative; flex-shrink: 0; }
|
| 380 |
.camera-input { position: absolute; opacity: 0; width: 0; height: 0; pointer-events: none; }
|
| 381 |
|
|
@@ -551,10 +655,11 @@ h2 { font-size: 1.5rem; font-weight: 700; color: var(--lavender-dk); }
|
|
| 551 |
|
| 552 |
.placed-item.poof {
|
| 553 |
animation: poof 0.3s ease-out forwards;
|
|
|
|
| 554 |
}
|
| 555 |
|
| 556 |
@keyframes poof {
|
| 557 |
-
to {
|
| 558 |
}
|
| 559 |
|
| 560 |
/* ---- Sparkle burst ---- */
|
|
|
|
| 161 |
|
| 162 |
.switch-label { font-size: 1.1rem; font-weight: 700; }
|
| 163 |
|
| 164 |
+
/* ---- Tray tabs ---- */
|
| 165 |
+
|
| 166 |
+
.tray-tabs {
|
| 167 |
+
display: flex;
|
| 168 |
+
justify-content: center;
|
| 169 |
+
gap: 0.4rem;
|
| 170 |
+
flex-shrink: 0;
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
.tray-tab {
|
| 174 |
+
font-family: var(--font);
|
| 175 |
+
font-size: 0.9rem;
|
| 176 |
+
font-weight: 700;
|
| 177 |
+
padding: 0.4rem 1rem;
|
| 178 |
+
border: 2px solid var(--lavender);
|
| 179 |
+
border-radius: 1.5rem;
|
| 180 |
+
background: var(--panel);
|
| 181 |
+
color: var(--lavender-dk);
|
| 182 |
+
cursor: pointer;
|
| 183 |
+
transition: all 0.15s var(--spring);
|
| 184 |
+
touch-action: manipulation;
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
.tray-tab.active {
|
| 188 |
+
background: var(--lavender);
|
| 189 |
+
color: white;
|
| 190 |
+
border-color: var(--lavender-dk);
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
.clothing-tray {
|
| 194 |
display: flex;
|
| 195 |
gap: 0.5rem;
|
|
|
|
| 256 |
flex-shrink: 0;
|
| 257 |
}
|
| 258 |
|
| 259 |
+
/* ---- Screenshot flash ---- */
|
| 260 |
+
|
| 261 |
+
.screenshot-flash {
|
| 262 |
+
animation: flash 0.4s ease-out;
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
@keyframes flash {
|
| 266 |
+
0% { filter: brightness(2); }
|
| 267 |
+
100% { filter: brightness(1); }
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
/* ---- Sticker tray items ---- */
|
| 271 |
+
|
| 272 |
+
.tray-sticker .tray-thumb {
|
| 273 |
+
background: none;
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
/* ---- Manage screen ---- */
|
| 277 |
|
| 278 |
#manage-screen {
|
|
|
|
| 422 |
box-shadow: 0 2px 0 var(--ink);
|
| 423 |
}
|
| 424 |
|
| 425 |
+
/* ---- Photo gallery (reuse previous captures) ---- */
|
| 426 |
+
|
| 427 |
+
.photo-gallery {
|
| 428 |
+
display: flex;
|
| 429 |
+
flex-direction: column;
|
| 430 |
+
gap: 0.4rem;
|
| 431 |
+
}
|
| 432 |
+
|
| 433 |
+
.photo-gallery-scroll {
|
| 434 |
+
display: flex;
|
| 435 |
+
gap: 0.5rem;
|
| 436 |
+
overflow-x: auto;
|
| 437 |
+
padding: 0.25rem;
|
| 438 |
+
-webkit-overflow-scrolling: touch;
|
| 439 |
+
scroll-snap-type: x mandatory;
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
.photo-gallery-item {
|
| 443 |
+
position: relative;
|
| 444 |
+
flex-shrink: 0;
|
| 445 |
+
scroll-snap-align: start;
|
| 446 |
+
cursor: pointer;
|
| 447 |
+
}
|
| 448 |
+
|
| 449 |
+
.photo-gallery-thumb {
|
| 450 |
+
width: 80px;
|
| 451 |
+
height: 80px;
|
| 452 |
+
object-fit: cover;
|
| 453 |
+
border-radius: 0.75rem;
|
| 454 |
+
border: 3px solid var(--lavender);
|
| 455 |
+
transition: border-color 0.15s;
|
| 456 |
+
}
|
| 457 |
+
|
| 458 |
+
.photo-gallery-item:active .photo-gallery-thumb {
|
| 459 |
+
border-color: var(--gold);
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
.photo-gallery-delete {
|
| 463 |
+
position: absolute;
|
| 464 |
+
top: -6px;
|
| 465 |
+
right: -6px;
|
| 466 |
+
width: 24px;
|
| 467 |
+
height: 24px;
|
| 468 |
+
border: 2px solid var(--ink);
|
| 469 |
+
border-radius: 50%;
|
| 470 |
+
background: var(--rose);
|
| 471 |
+
color: white;
|
| 472 |
+
font-size: 0.9rem;
|
| 473 |
+
font-weight: 700;
|
| 474 |
+
line-height: 1;
|
| 475 |
+
cursor: pointer;
|
| 476 |
+
display: flex;
|
| 477 |
+
align-items: center;
|
| 478 |
+
justify-content: center;
|
| 479 |
+
padding: 0;
|
| 480 |
+
touch-action: manipulation;
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
.camera-zone { position: relative; flex-shrink: 0; }
|
| 484 |
.camera-input { position: absolute; opacity: 0; width: 0; height: 0; pointer-events: none; }
|
| 485 |
|
|
|
|
| 655 |
|
| 656 |
.placed-item.poof {
|
| 657 |
animation: poof 0.3s ease-out forwards;
|
| 658 |
+
pointer-events: none;
|
| 659 |
}
|
| 660 |
|
| 661 |
@keyframes poof {
|
| 662 |
+
to { scale: 0; opacity: 0; }
|
| 663 |
}
|
| 664 |
|
| 665 |
/* ---- Sparkle burst ---- */
|