// Custom imported transparent photo frame engine and storage export interface CustomFrameHole { x: number; // 0..1 fraction of frame width y: number; // 0..1 fraction of frame height w: number; // 0..1 fraction of frame width h: number; // 0..1 fraction of frame height } export interface CustomFrameData { id: string; name: string; bytes: Uint8Array; dataUrl: string; width: number; height: number; hole: CustomFrameHole; scale: number; // default 1.0 (cover/fill hole) panX: number; // offset in frame pixels panY: number; // offset in frame pixels } const DB_NAME = 'recipescam-studio'; const STORE = 'custom_frames'; const FRAME_KEY = 'active_custom_frame'; function openDb(): Promise { return new Promise((resolve, reject) => { const req = indexedDB.open(DB_NAME, 2); req.onupgradeneeded = () => { const db = req.result; if (!db.objectStoreNames.contains('session')) { db.createObjectStore('session'); } if (!db.objectStoreNames.contains(STORE)) { db.createObjectStore(STORE); } }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); } export async function saveCustomFrame(frame: CustomFrameData): Promise { try { const db = await openDb(); await new Promise((resolve, reject) => { const tx = db.transaction(STORE, 'readwrite'); tx.objectStore(STORE).put(frame, FRAME_KEY); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); db.close(); } catch (err) { console.warn('Failed to save custom frame to IndexedDB', err); } } export async function loadCustomFrame(): Promise { try { const db = await openDb(); const row = await new Promise((resolve, reject) => { const tx = db.transaction(STORE, 'readonly'); const req = tx.objectStore(STORE).get(FRAME_KEY); req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); db.close(); if (row && row.bytes instanceof Uint8Array && row.hole) { return row; } return null; } catch { return null; } } export async function clearCustomFrame(): Promise { try { const db = await openDb(); await new Promise((resolve, reject) => { const tx = db.transaction(STORE, 'readwrite'); tx.objectStore(STORE).delete(FRAME_KEY); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); db.close(); } catch {} } /** * Analyzes an imported frame file (PNG, WebP, SVG, etc.), * decodes it, and detects the transparent opening/hole via alpha channel analysis. */ export async function analyzeCustomFrame(file: File): Promise { const bytes = new Uint8Array(await file.arrayBuffer()); const blob = new Blob([bytes], { type: file.type || 'image/png' }); const dataUrl = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result as string); reader.onerror = reject; reader.readAsDataURL(blob); }); const img = new Image(); await new Promise((resolve, reject) => { img.onload = () => resolve(); img.onerror = () => reject(new Error('Cannot decode frame image')); img.src = dataUrl; }); const w = img.naturalWidth || img.width; const h = img.naturalHeight || img.height; if (!(w > 0 && h > 0)) { throw new Error('Invalid image dimensions'); } // Downscale slightly for fast and accurate pixel analysis (max 600px) const maxDim = 600; const sampleScale = Math.min(1, maxDim / Math.max(w, h)); const sw = Math.max(10, Math.round(w * sampleScale)); const sh = Math.max(10, Math.round(h * sampleScale)); const canvas = document.createElement('canvas'); canvas.width = sw; canvas.height = sh; const ctx = canvas.getContext('2d', { willReadFrequently: true }); if (!ctx) { throw new Error('Canvas 2D context not available'); } ctx.drawImage(img, 0, 0, sw, sh); const imgData = ctx.getImageData(0, 0, sw, sh); const data = imgData.data; // Transparent threshold: alpha < 70 (out of 255) const isAlphaLow = (x: number, y: number) => { return data[(y * sw + x) * 4 + 3] < 70; }; // 1. Flood-fill from outer edges to distinguish outer transparent padding from the inner transparent hole const visited = new Uint8Array(sw * sh); const queue: number[] = []; for (let x = 0; x < sw; x++) { if (isAlphaLow(x, 0)) { visited[0 * sw + x] = 1; queue.push(x, 0); } if (isAlphaLow(x, sh - 1)) { visited[(sh - 1) * sw + x] = 1; queue.push(x, sh - 1); } } for (let y = 0; y < sh; y++) { if (isAlphaLow(0, y) && !visited[y * sw + 0]) { visited[y * sw + 0] = 1; queue.push(0, y); } if (isAlphaLow(sw - 1, y) && !visited[y * sw + (sw - 1)]) { visited[y * sw + (sw - 1)] = 1; queue.push(sw - 1, y); } } let head = 0; while (head < queue.length) { const qx = queue[head++]; const qy = queue[head++]; const neighbors = [ [qx + 1, qy], [qx - 1, qy], [qx, qy + 1], [qx, qy - 1], ]; for (const [nx, ny] of neighbors) { if (nx >= 0 && nx < sw && ny >= 0 && ny < sh) { const idx = ny * sw + nx; if (!visited[idx] && isAlphaLow(nx, ny)) { visited[idx] = 1; queue.push(nx, ny); } } } } // 2. Find bounding box of inner transparent pixels (not reached by flood-fill from border) let minX = sw; let minY = sh; let maxX = -1; let maxY = -1; let innerTransparentCount = 0; for (let y = 0; y < sh; y++) { for (let x = 0; x < sw; x++) { const idx = y * sw + x; if (isAlphaLow(x, y) && !visited[idx]) { innerTransparentCount++; if (x < minX) minX = x; if (x > maxX) maxX = x; if (y < minY) minY = y; if (y > maxY) maxY = y; } } } // 3. Fallback: if no inner transparent pixel was isolated, search for any transparent pixels if (innerTransparentCount < 20 || minX > maxX) { minX = sw; minY = sh; maxX = -1; maxY = -1; let anyCount = 0; for (let y = 0; y < sh; y++) { for (let x = 0; x < sw; x++) { if (isAlphaLow(x, y)) { anyCount++; if (x < minX) minX = x; if (x > maxX) maxX = x; if (y < minY) minY = y; if (y > maxY) maxY = y; } } } innerTransparentCount = anyCount; } // 4. Compute normalized hole geometry let hole: CustomFrameHole = { x: 0.1, y: 0.1, w: 0.8, h: 0.8 }; if (innerTransparentCount > 20 && minX <= maxX && minY <= maxY) { const holeW = Math.max(0.05, (maxX - minX + 1) / sw); const holeH = Math.max(0.05, (maxY - minY + 1) / sh); hole = { x: minX / sw, y: minY / sh, w: holeW, h: holeH, }; } return { id: `custom-${Date.now()}`, name: file.name, bytes, dataUrl, width: w, height: h, hole, scale: 1.0, panX: 0, panY: 0, }; }