import { CATEGORIES, getCategoryInfo } from './models.js'; import { saveItem, savePhoto, loadPhotos, updatePhoto, deletePhoto, blobToURL } from './store.js'; import { removeBackground, autoSegment } from './ml-client.js'; import { showSegmentReview } from './segment-review.js'; import { showPolygonSelector } from './polygon-select.js'; import { normalizeColors } from './color-normalize.js'; import { tagOp } from './diag.js'; let selectedCategory = 'princess'; let imageBlob = null; // original photo let croppedBlob = null; // after polygon crop (or same as imageBlob if skipped) let polygon = null; // normalized polygon coords from selector let segmentedBlob = null; // after background removal let previewURL = null; let croppedURL = null; let fullDrawingMode = false; let photoURLs = []; // object URLs for gallery thumbnails // Cap the gallery at the N most recent photos. Each thumbnail is an // that iOS may decode on scroll-into-view; even downscaled photos // add up, and we don't need to show every photo ever taken. const GALLERY_MAX = 10; // Any photo bigger than this is almost certainly a legacy full-size // capture from before downscalePhoto existed. We rewrite them in place // on load so subsequent sessions don't pay the cost again. const LEGACY_MIGRATE_BYTES = 800 * 1024; async function migrateLegacyPhotos(photos) { for (const p of photos) { if (!p.imageBlob || p.imageBlob.size <= LEGACY_MIGRATE_BYTES) continue; tagOp(`migrate: ${Math.round(p.imageBlob.size / 1024)}KB photo`); try { const small = await downscalePhoto(p.imageBlob); if (small && small.size < p.imageBlob.size) { p.imageBlob = small; await updatePhoto(p); } } catch (err) { console.warn('[upload] photo migration failed', err); } // Yield so iOS gets a chance to reclaim the full-res decode buffer // before we touch the next one. await new Promise(r => setTimeout(r, 50)); } } export async function initUpload() { tagOp('upload: init'); const screen = document.getElementById('upload-screen'); photoURLs.forEach(u => URL.revokeObjectURL(u)); photoURLs = []; let photos = await loadPhotos(); await migrateLegacyPhotos(photos); // Show only the most recent few; keep oldest photos in IDB (user can // still delete them explicitly via the × button after we re-render). photos = photos .slice() .sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0)) .slice(0, GALLERY_MAX); tagOp(`upload: ${photos.length} gallery photos`); screen.innerHTML = `
\u2190 Back

Add something new!

${photos.length > 0 ? ` ` : ''}

What kind?

`; buildCategoryGrid(); bindEvents(photos); resetState(); } function buildCategoryGrid() { const grid = document.getElementById('category-grid'); grid.innerHTML = CATEGORIES.map(c => ` `).join(''); } // Modern phone cameras produce ~3000x4000 JPEGs. Fully decoded that's // ~48 MB of RGBA pixels, and iOS holds it pinned for the lifetime of the // file reference. We only need ~1280 on the long edge for everything // downstream (OUTPUT_DIM=768 for save, MODNet preprocesses to short-edge // 512 for inference, polygon selector and preview render well below this). // Re-encoding as JPEG at 85% keeps the on-disk size tiny too. async function downscalePhoto(file, maxLongEdge = 1280) { // We have to decode once to read dimensions. Close it immediately. const probe = await createImageBitmap(file); const { width: origW, height: origH } = probe; probe.close(); if (Math.max(origW, origH) <= maxLongEdge) { return file; // already small enough, keep original } const ratio = maxLongEdge / Math.max(origW, origH); const w = Math.round(origW * ratio); const h = Math.round(origH * ratio); // Second decode directly at the target size — the big one is short-lived. const bitmap = await createImageBitmap(file, { resizeWidth: w, resizeHeight: h, resizeQuality: 'medium', }); const canvas = new OffscreenCanvas(w, h); canvas.getContext('2d').drawImage(bitmap, 0, 0); bitmap.close(); return canvas.convertToBlob({ type: 'image/jpeg', quality: 0.85 }); } function selectImage(blob) { imageBlob = blob; croppedBlob = null; polygon = null; segmentedBlob = null; if (previewURL) URL.revokeObjectURL(previewURL); if (croppedURL) URL.revokeObjectURL(croppedURL); previewURL = URL.createObjectURL(blob); croppedURL = null; showPhotoPreview(previewURL); document.getElementById('select-area-btn').style.display = ''; document.getElementById('upload-status').innerHTML = ''; document.getElementById('upload-actions').style.display = ''; updateSubmitState(); } function bindEvents(photos) { // Category picker document.getElementById('category-grid').addEventListener('click', (e) => { const btn = e.target.closest('.category-btn'); if (!btn) return; selectedCategory = btn.dataset.cat; document.querySelectorAll('.category-btn').forEach(b => b.classList.remove('selected')); btn.classList.add('selected'); }); // Camera input — downscale immediately so we never hold the full-res // camera JPEG, then save to gallery. document.getElementById('camera-input').addEventListener('change', async (e) => { const file = e.target.files[0]; if (!file) return; tagOp(`capture: downscaling ${Math.round(file.size / 1024)}KB`); const resized = await downscalePhoto(file); tagOp(`capture: downscaled -> ${Math.round(resized.size / 1024)}KB`); // White-balance before saving so the gallery thumb, polygon preview // and eventual background-removal input all see the same image. const normalized = await normalizeColors(resized); await savePhoto(normalized); selectImage(normalized); }); // Photo gallery — tap to reuse, X to delete const gallery = document.getElementById('photo-gallery'); if (gallery) { gallery.addEventListener('click', async (e) => { const delBtn = e.target.closest('.photo-gallery-delete'); if (delBtn) { e.stopPropagation(); const id = delBtn.dataset.photoId; await deletePhoto(id); initUpload(); return; } const item = e.target.closest('.photo-gallery-item'); if (!item) return; const photo = photos.find(p => p.id === item.dataset.photoId); if (photo) { // Downscale is idempotent — no-op for photos saved after this fix, // catches any full-res photos left over from before. const resized = await downscalePhoto(photo.imageBlob); selectImage(resized); } }); } // Select area document.getElementById('select-area-btn').addEventListener('click', handleSelectArea); // Name input document.getElementById('asset-name').addEventListener('input', updateSubmitState); // Submit -> segmentation document.getElementById('submit-btn').addEventListener('click', handleMagicTime); // Full drawing mode document.getElementById('full-drawing-btn').addEventListener('click', handleFullDrawing); } function showPhotoPreview(url, caption) { const label = document.getElementById('camera-label'); label.innerHTML = ` ${caption || 'Tap to retake'} `; label.className = 'camera-preview-label'; } async function handleSelectArea() { if (!previewURL) return; const result = await showPolygonSelector(previewURL); if (!result) return; croppedBlob = result.imageBlob; polygon = result.polygon; if (croppedURL) URL.revokeObjectURL(croppedURL); croppedURL = URL.createObjectURL(croppedBlob); showPhotoPreview(previewURL, '\u2702\uFE0F Area selected \u2014 tap to retake photo'); document.getElementById('select-area-btn').textContent = '\u2702\uFE0F Reselect area'; updateSubmitState(); } function updateSubmitState() { const btn = document.getElementById('submit-btn'); if (btn) btn.disabled = !imageBlob; } async function handleMagicTime() { const name = document.getElementById('asset-name').value.trim(); if (!imageBlob) return; const btn = document.getElementById('submit-btn'); const status = document.getElementById('upload-status'); btn.disabled = true; document.getElementById('upload-actions').style.display = 'none'; status.innerHTML = `
\u{1F451}

Removing the background!

This might take a moment.

`; // Re-normalize as a safety net for legacy gallery photos that were // captured before normalizeColors existed. Post-feature captures are // already normalized, so this is a fast idempotent pass. let skipBlob = croppedBlob || imageBlob; try { const normalized = await normalizeColors(imageBlob); if (!croppedBlob) skipBlob = normalized; let segResult = await removeBackground(normalized, (info) => { const msg = document.getElementById('seg-message'); const fill = document.getElementById('seg-progress-fill'); if (msg) msg.textContent = info.message; if (fill && info.progress != null) fill.style.width = `${info.progress}%`; }); if (polygon && polygon.length >= 3) { segResult = await intersectSegmentationWithPolygon(segResult, polygon); } segmentedBlob = segResult; const segURL = URL.createObjectURL(segmentedBlob); const beforeURL = previewURL; status.innerHTML = `

How does it look?

Before Before
After After
`; document.getElementById('seg-approve').addEventListener('click', () => { tagOp('approve: clicked'); saveAsset(name, segmentedBlob); }); document.getElementById('seg-skip').addEventListener('click', () => { tagOp('skip: clicked'); saveAsset(name, skipBlob); }); document.getElementById('seg-retry').addEventListener('click', resetToCamera); } catch (err) { console.error('Segmentation failed:', err); status.innerHTML = `

Background removal didn't work this time.

${err.message}

`; document.getElementById('seg-skip-err').addEventListener('click', () => saveAsset(name, skipBlob)); document.getElementById('seg-retry-err').addEventListener('click', () => { document.getElementById('upload-actions').style.display = ''; btn.disabled = false; status.innerHTML = ''; }); } } async function handleFullDrawing() { if (!imageBlob) return; const btn = document.getElementById('full-drawing-btn'); const status = document.getElementById('upload-status'); document.getElementById('upload-actions').style.display = 'none'; function showProgress(message, progress) { const msg = document.getElementById('seg-message'); const fill = document.getElementById('seg-progress-fill'); if (msg) msg.textContent = message; if (fill && progress != null) fill.style.width = `${progress}%`; } status.innerHTML = `
\u{1F451}

Removing the background...

This might take a moment.

`; try { const bgRemoved = await removeBackground(imageBlob, (info) => { showProgress(info.message, info.progress * 0.4); }); await new Promise(r => setTimeout(r, 200)); showProgress('Finding all the parts...', 40); const segments = await autoSegment(bgRemoved, (info) => { const overall = 40 + (info.progress || 0) * 0.4; showProgress(info.message, overall); }); if (segments.length === 0) { status.innerHTML = `

Couldn't find any distinct parts in this drawing.

`; document.getElementById('fd-retry').addEventListener('click', () => { document.getElementById('upload-actions').style.display = ''; status.innerHTML = ''; }); return; } showProgress('Ready for review!', 100); const bgRemovedURL = URL.createObjectURL(bgRemoved); const reviewResult = await showSegmentReview(bgRemovedURL, segments); URL.revokeObjectURL(bgRemovedURL); if (!reviewResult) { document.getElementById('upload-actions').style.display = ''; status.innerHTML = ''; return; } status.innerHTML = `
\u{1F451}

Saving everything...

`; let savedCount = 0; if (reviewResult.princess) { await saveItem({ name: '', category: 'princess', imageBlob: reviewResult.princess.blob, scale: 0.8, zIndex: 2, }); savedCount++; } for (const item of reviewResult.clothing) { const catInfo = getCategoryInfo(item.category); await saveItem({ name: '', category: item.category, imageBlob: item.blob, scale: catInfo.scale || 0.5, zIndex: catInfo.zIndex, }); savedCount++; } status.innerHTML = `

\u2705 Saved ${savedCount} item${savedCount !== 1 ? 's' : ''}!

Back to game
`; document.getElementById('fd-another').addEventListener('click', resetToCamera); } catch (err) { console.error('Full drawing pipeline failed:', err); status.innerHTML = `

Something went wrong during segmentation.

${err.message}

`; document.getElementById('fd-retry-err').addEventListener('click', () => { document.getElementById('upload-actions').style.display = ''; status.innerHTML = ''; }); } } function resetToCamera() { resetState(); initUpload(); } async function intersectSegmentationWithPolygon(segBlob, poly) { const bitmap = await createImageBitmap(segBlob); const fullW = bitmap.width; const fullH = bitmap.height; const pxPoly = poly.map(p => ({ x: p.x * fullW, y: p.y * fullH })); let minX = fullW, minY = fullH, maxX = 0, maxY = 0; for (const p of pxPoly) { minX = Math.min(minX, p.x); minY = Math.min(minY, p.y); maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y); } const pad = 4; minX = Math.max(0, Math.floor(minX) - pad); minY = Math.max(0, Math.floor(minY) - pad); maxX = Math.min(fullW, Math.ceil(maxX) + pad); maxY = Math.min(fullH, Math.ceil(maxY) + pad); const cropW = maxX - minX; const cropH = maxY - minY; const canvas = new OffscreenCanvas(cropW, cropH); const ctx = canvas.getContext('2d'); ctx.beginPath(); ctx.moveTo(pxPoly[0].x - minX, pxPoly[0].y - minY); for (let i = 1; i < pxPoly.length; i++) { ctx.lineTo(pxPoly[i].x - minX, pxPoly[i].y - minY); } ctx.closePath(); ctx.clip(); ctx.drawImage(bitmap, minX, minY, cropW, cropH, 0, 0, cropW, cropH); return canvas.convertToBlob({ type: 'image/png' }); } async function saveAsset(name, blob) { tagOp('save: clicked'); const status = document.getElementById('upload-status'); status.innerHTML = `
\u{1F451}

Saving!

`; // iOS is likely still near the high-water mark from MODNet inference. // Drop the cropped intermediate (keep imageBlob/previewURL — the // "Select another from this photo" flow re-uses them) and give the // GC a moment before IndexedDB clones `blob` into a transaction. tagOp('save: freeing intermediates'); croppedBlob = null; if (croppedURL) { URL.revokeObjectURL(croppedURL); croppedURL = null; } await new Promise(r => setTimeout(r, 100)); try { const catInfo = getCategoryInfo(selectedCategory); tagOp(`save: writing to IDB (${Math.round(blob.size / 1024)}KB)`); await saveItem({ name, category: selectedCategory, imageBlob: blob, scale: catInfo.scale || 0.5, zIndex: catInfo.zIndex, }); tagOp('save: IDB done'); status.innerHTML = `

\u2705 All done!

Back to game
`; tagOp('save: success shown'); document.getElementById('another-from-photo').addEventListener('click', () => { resetForAnotherSelection(); }); } catch (err) { tagOp(`save: error ${err.message}`); status.innerHTML = `

Oops! Something went wrong.

${err.message}

`; } } function resetForAnotherSelection() { const status = document.getElementById('upload-status'); status.innerHTML = ''; document.getElementById('upload-actions').style.display = ''; document.getElementById('submit-btn').disabled = false; croppedBlob = null; polygon = null; segmentedBlob = null; if (croppedURL) URL.revokeObjectURL(croppedURL); croppedURL = null; document.getElementById('asset-name').value = ''; showPhotoPreview(previewURL); document.getElementById('select-area-btn').style.display = ''; document.getElementById('select-area-btn').textContent = '\u2702\uFE0F Select area'; updateSubmitState(); } function resetState() { selectedCategory = 'princess'; imageBlob = null; croppedBlob = null; polygon = null; segmentedBlob = null; if (previewURL) URL.revokeObjectURL(previewURL); if (croppedURL) URL.revokeObjectURL(croppedURL); previewURL = null; croppedURL = null; }