sdragly commited on
Commit
a125618
·
1 Parent(s): e56fe87

Move segmentation to cloud

Browse files
Files changed (12) hide show
  1. js/app.js +6 -1
  2. js/auto-segment.js +38 -24
  3. js/diag.js +64 -0
  4. js/dressup.js +317 -129
  5. js/manage.js +31 -33
  6. js/ml-client.js +110 -0
  7. js/ml-worker.js +36 -0
  8. js/models.js +13 -27
  9. js/segmentation.js +134 -82
  10. js/store.js +63 -34
  11. js/upload.js +162 -127
  12. 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', route);
 
 
 
 
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 = 512;
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 model = await SamModel.from_pretrained(SAM_MODEL, {
38
- device,
39
- dtype: device === 'webgpu' ? 'fp32' : 'fp32',
40
- progress_callback: (p) => {
41
- if (onProgress && p.progress != null) {
42
- onProgress({
43
- message: `Downloading model: ${Math.round(p.progress)}%`,
44
- progress: p.progress * 0.2, // 0-20% for download
45
- });
46
- }
47
- },
48
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
 
50
- if (onProgress) onProgress({ message: 'Preparing model...', progress: 22 });
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 { 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
 
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
- princesses = await loadPrincesses();
29
- clothes = await loadClothes();
 
 
 
 
30
 
31
  objectURLs.forEach(u => URL.revokeObjectURL(u));
32
  objectURLs = [];
33
- clothesURLMap = {};
34
 
35
- clothes.forEach(c => {
 
36
  const url = blobToURL(c.imageBlob);
37
  objectURLs.push(url);
38
- clothesURLMap[c.id] = url;
 
39
  });
 
40
 
41
- if (princesses.length === 0) {
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
- 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>
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="princess-counter" class="switch-label"></span>
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">
@@ -87,63 +146,86 @@ export async function initDressUp() {
87
  `;
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() {
114
- const p = princesses[currentIdx];
115
- if (!p) return;
116
 
117
- const url = blobToURL(p.imageBlob);
118
- objectURLs.push(url);
 
119
 
120
- document.getElementById('princess-name').textContent = p.name || 'Princess';
121
- document.getElementById('princess-img').src = url;
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 === 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;
@@ -163,18 +245,18 @@ function renderTray(tab) {
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('');
@@ -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], 'princess.png', { type: 'image/png' });
312
  try {
313
- await navigator.share({ files: [file], title: 'My Princess' });
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 = 'princess.png';
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--; renderPrincess(); }
346
  });
347
  document.getElementById('next-btn').addEventListener('click', () => {
348
- if (currentIdx < princesses.length - 1) { currentIdx++; renderPrincess(); }
 
 
 
 
 
 
 
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: clothesURLMap[item.dataset.id],
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
- id: item.clothingId,
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
- canvasIdx: origIdx,
 
 
 
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
- moveGhost(e.clientX, e.clientY);
 
 
 
 
 
 
 
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
- if (drag.source === 'tray') {
555
- const ox = (Math.random() - 0.5) * 0.15;
556
- const oy = (Math.random() - 0.5) * 0.15;
557
- placedItems.push({
558
- clothingId: drag.id,
559
- x: 0.5 + ox,
560
- y: 0.5 + oy,
561
- scale: drag.scale,
562
- rotation: 0,
563
- zIndex: drag.zIndex,
564
- url: drag.url,
565
- isSticker: drag.isSticker,
566
- });
567
- renderPlaced();
568
- animateNewItem();
569
- autoSave();
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
- clothingId: drag.id,
588
  x: nx,
589
  y: ny,
590
  scale: drag.scale,
591
- rotation: drag.rotation || 0,
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 { loadPrincesses, loadClothes, deletePrincess, deleteClothing, blobToURL } from './store.js';
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 princesses = await loadPrincesses();
13
- const clothes = await loadClothes();
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{1F478} Princesses</h3>
24
- <div id="princess-grid" class="manage-grid">
25
- ${princesses.length === 0 ? '<p class="manage-empty">No princesses yet</p>' : ''}
26
  </div>
27
  </div>
28
 
29
  <div class="manage-section">
30
- <h3 class="manage-heading">\u{1F457} Clothes</h3>
31
- <div id="clothes-grid" class="manage-grid">
32
- ${clothes.length === 0 ? '<p class="manage-empty">No clothes yet</p>' : ''}
33
  </div>
34
  </div>
35
  </div>
36
  `;
37
 
38
- const princessGrid = document.getElementById('princess-grid');
39
- if (princesses.length > 0) {
40
- princessGrid.innerHTML = princesses.map(p => {
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="clothing">
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="clothing">\u{1F5D1}</button>
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 === 'princess') {
81
- await deletePrincess(id);
82
  } else {
83
- await deleteClothing(id);
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: 'crown', emoji: '\u{1F451}', label: 'Crown', targetAnchor: 'head_top', zIndex: 6 },
3
- { id: 'dress', emoji: '\u{1F457}', label: 'Dress', targetAnchor: 'neck', zIndex: 2 },
4
- { id: 'top', emoji: '\u{1F455}', label: 'Top', targetAnchor: 'neck', zIndex: 3 },
5
- { id: 'skirt', emoji: '\u{1FA73}', label: 'Skirt', targetAnchor: 'waist', zIndex: 3 },
6
- { id: 'shoes', emoji: '\u{1F460}', label: 'Shoes', targetAnchor: 'feet_center', zIndex: 1 },
7
- { id: 'cape', emoji: '\u{1F9E3}', label: 'Cape', targetAnchor: 'neck', zIndex: 4 },
8
- { id: 'wand', emoji: '\u{1FA84}', label: 'Wand', targetAnchor: 'right_hand', zIndex: 7 },
9
- { id: 'necklace', emoji: '\u{1F4FF}', label: 'Necklace', targetAnchor: 'neck', zIndex: 5 },
10
- { id: 'earrings', emoji: '\u2728', label: 'Earrings', targetAnchor: 'head_center', zIndex: 6 },
11
- { id: 'bag', emoji: '\u{1F45C}', label: 'Bag', targetAnchor: 'left_hand', zIndex: 7 },
 
 
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 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
  }
@@ -39,69 +70,72 @@ async function _doLoad(onProgress) {
39
 
40
  if (onProgress) onProgress({ status: 'loading', message: 'Loading AI model...' });
41
 
42
- const model = await AutoModel.from_pretrained('briaai/RMBG-1.4', {
43
- device: 'wasm',
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 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,36 +145,48 @@ async function prepareImages(imageBlob) {
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');
@@ -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
- 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);
164
 
165
- if (onProgress) onProgress({ status: 'done', message: 'Done!' });
 
166
 
167
- return canvas.convertToBlob({ type: 'image/png' });
 
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 = 2;
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
- // --- Princesses ---
51
 
52
- export async function savePrincess({ name, imageBlob, anchors }) {
53
  const id = uuid();
54
- const store = await tx('princesses', 'readwrite');
55
- const record = { id, name, imageBlob, anchors, createdAt: Date.now() };
56
  await reqToPromise(store.put(record));
57
  return record;
58
  }
59
 
60
- export async function loadPrincesses() {
61
- const store = await tx('princesses');
62
  return reqToPromise(store.getAll());
63
  }
64
 
65
- export async function getPrincess(id) {
66
- const store = await tx('princesses');
67
- return reqToPromise(store.get(id));
68
- }
69
-
70
- export async function updatePrincessAnchors(id, anchors) {
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 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
  }
97
 
98
- export async function loadClothes() {
99
  const store = await tx('clothes');
100
  return reqToPromise(store.getAll());
101
  }
102
 
103
- export async function deletePrincess(id) {
104
- const store = await tx('princesses', 'readwrite');
105
  await reqToPromise(store.delete(id));
106
  }
107
 
108
- export async function deleteClothing(id) {
109
- const store = await tx('clothes', 'readwrite');
110
- await reqToPromise(store.delete(id));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, DEFAULT_ANCHORS } from './models.js';
2
- import { savePrincess, saveClothing, savePhoto, loadPhotos, deletePhoto, blobToURL } from './store.js';
3
- import { removeBackground } from './segmentation.js';
4
- import { autoSegment } from './auto-segment.js';
5
  import { showSegmentReview } from './segment-review.js';
6
  import { showPolygonSelector } from './polygon-select.js';
7
- import { navigate } from './app.js';
8
 
9
- let uploadType = 'princess';
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
- const photos = await loadPhotos();
 
 
 
 
 
 
 
 
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" style="display:none">
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
- <button id="submit-btn" class="big-button magic-btn" disabled>
81
- \u2728 Magic Time! \u2728
82
- </button>
 
 
 
 
 
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('submit-btn').style.display = '';
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 — save to gallery automatically
 
149
  document.getElementById('camera-input').addEventListener('change', async (e) => {
150
  const file = e.target.files[0];
151
  if (!file) return;
152
-
153
- // Save to photo gallery for reuse
154
- await savePhoto(file);
155
-
156
- selectImage(file);
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(); // re-render
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) selectImage(photo.imageBlob);
 
 
 
 
 
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; // cancelled
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
- btn.style.display = 'none';
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
- ${modelHint}
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', () => saveAsset(name, segmentedBlob));
293
- document.getElementById('seg-skip').addEventListener('click', () => saveAsset(name, skipBlob));
 
 
 
 
 
 
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
- btn.style.display = '';
311
  btn.disabled = false;
312
  status.innerHTML = '';
313
  });
@@ -315,11 +363,12 @@ async function handleMagicTime() {
315
  }
316
 
317
  async function handleFullDrawing() {
318
- const btn = document.getElementById('submit-btn');
 
 
319
  const status = document.getElementById('upload-status');
320
 
321
- btn.disabled = true;
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); // 0-40%
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
- btn.style.display = '';
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
- btn.style.display = '';
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 savePrincess({
398
  name: '',
 
399
  imageBlob: reviewResult.princess.blob,
400
- anchors: { ...DEFAULT_ANCHORS },
 
401
  });
402
  savedCount++;
403
  }
404
 
405
  for (const item of reviewResult.clothing) {
406
  const catInfo = getCategoryInfo(item.category);
407
- await saveClothing({
408
  name: '',
409
  category: item.category,
410
  imageBlob: item.blob,
411
- targetAnchor: catInfo.targetAnchor,
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
- btn.style.display = '';
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
- if (uploadType === 'princess') {
509
- await savePrincess({
510
- name,
511
- imageBlob: blob,
512
- anchors: { ...DEFAULT_ANCHORS },
513
- });
514
- } else {
515
- const catInfo = getCategoryInfo(selectedCategory);
516
- await saveClothing({
517
- name,
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
- btn.style.display = '';
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
- uploadType = 'princess';
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
- .snap-rings { position: absolute; inset: 0; pointer-events: none; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.5rem;
197
  overflow-x: auto;
198
  scroll-snap-type: x mandatory;
199
  -webkit-overflow-scrolling: touch;
200
  flex-shrink: 0;
201
- min-height: 110px;
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: 56px;
235
- height: 56px;
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 { align-self: center; font-size: 1.4rem; margin-top: 0.5rem; }
 
 
 
 
 
 
 
 
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;