/** * AI color read: captures a mirrored webcam snapshot, POSTs to `/api/analyze-makeup-colors` * (FastAPI + Google Gemini vision on the server), shows a modal with the result, a hex palette * you can apply to the try-on, copy, or save to localStorage ("scrapbook"). */ import { applyMakeupLookFromHex, type MakeupLookHex } from './customize'; export const AI_LOOK_VIBES = ['natural', 'glam', 'fun'] as const; export type AiLookVibe = (typeof AI_LOOK_VIBES)[number]; export const AI_LOOK_VIBE_LABELS: Record = { natural: 'Natural', glam: 'Glam', fun: 'Fun', }; const VIBE_PREF_KEY = 'doremi-ai-look-vibe-pref'; function readVibePreference(): AiLookVibe { try { const v = sessionStorage.getItem(VIBE_PREF_KEY); if (v && (AI_LOOK_VIBES as readonly string[]).includes(v)) return v as AiLookVibe; } catch { /* ignore */ } return 'natural'; } function storeVibePreference(v: AiLookVibe) { try { sessionStorage.setItem(VIBE_PREF_KEY, v); } catch { /* ignore */ } } function readVibeFromDom(): AiLookVibe { const el = document.querySelector('input[name="aiLookVibe"]:checked'); const v = el?.value; if (v && (AI_LOOK_VIBES as readonly string[]).includes(v)) return v as AiLookVibe; return 'natural'; } function syncVibeRadiosFromStorage() { const pref = readVibePreference(); document.querySelectorAll('input[name="aiLookVibe"]').forEach((r) => { r.checked = r.value === pref; }); } const LOOK_HEX_KEYS = ['lip', 'eye_shadow', 'liner', 'brow', 'blush'] as const; function isLookHex(x: unknown): x is MakeupLookHex { if (!x || typeof x !== 'object') return false; const o = x as Record; for (const k of LOOK_HEX_KEYS) { const v = o[k]; if (typeof v !== 'string') return false; const t = v.trim(); if (!/^#[0-9A-Fa-f]{6}$/i.test(t)) return false; } return true; } function normalizeLookHex(raw: unknown): MakeupLookHex | undefined { if (!isLookHex(raw)) return undefined; const o = raw as Record; const out: Record = {}; for (const k of LOOK_HEX_KEYS) { const h = (o[k].trim().startsWith('#') ? o[k].trim().slice(1) : o[k].trim()).toUpperCase(); out[k] = `#${h}`; } return out as MakeupLookHex; } function stripSparkleEmoji(s: string): string { return s.replace(/\u2728/g, '').replace(/\s{2,}/g, ' ').trim(); } const STORAGE_KEY = 'doremi-ai-color-reads-v1'; export type AiColorAnalysis = { headline: string; vibe_tags: string[]; undertone_read: string; lip_colors: string[]; eye_colors: string[]; blush_colors: string[]; liner_brow: string; tips: string[]; confidence_note: string; disclaimer: string; /** Present on new reads; older scrapbook saves may omit this. */ look_hex?: MakeupLookHex; }; export type SavedAiRead = { id: string; savedAt: number; headline: string; /** Which look direction was used when this read was generated. */ lookVibe?: AiLookVibe; analysis: AiColorAnalysis; }; type InitArgs = { video: HTMLVideoElement; statusEl: HTMLElement | null; /** Called right after AI hex colors are applied (modal + scrapbook Apply). Arg is the read’s vibe, or null for old saves. */ onAfterApplyLook?: (lookVibe: AiLookVibe | null) => void; }; function newId(): string { if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) return crypto.randomUUID(); return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; } function escapeHtml(s: string): string { return s .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function readSavedReads(): SavedAiRead[] { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return []; const parsed = JSON.parse(raw) as unknown; if (!Array.isArray(parsed)) return []; return parsed.filter(isSavedRead); } catch { return []; } } function isSavedRead(x: unknown): x is SavedAiRead { if (!x || typeof x !== 'object') return false; const o = x as Record; if (typeof o.id !== 'string' || typeof o.savedAt !== 'number' || typeof o.headline !== 'string') return false; const a = o.analysis; if (!a || typeof a !== 'object') return false; const ar = a as Record; if (typeof ar.headline !== 'string' || !Array.isArray(ar.vibe_tags)) return false; if (ar.look_hex !== undefined && !isLookHex(ar.look_hex)) return false; if (o.lookVibe !== undefined) { if (typeof o.lookVibe !== 'string' || !(AI_LOOK_VIBES as readonly string[]).includes(o.lookVibe)) return false; } return true; } function writeSavedReads(reads: SavedAiRead[]) { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(reads)); } catch (e) { console.warn('Could not save AI reads', e); } } function asStringArray(x: unknown): string[] { if (!Array.isArray(x)) return []; return x.filter((v): v is string => typeof v === 'string'); } function normalizeAnalysis(raw: Record): AiColorAnalysis { const headlineRaw = typeof raw.headline === 'string' ? raw.headline : 'Your color read'; const look_hex = normalizeLookHex(raw.look_hex); return { headline: stripSparkleEmoji(headlineRaw) || 'Your color read', vibe_tags: asStringArray(raw.vibe_tags), undertone_read: typeof raw.undertone_read === 'string' ? raw.undertone_read : '', lip_colors: asStringArray(raw.lip_colors), eye_colors: asStringArray(raw.eye_colors), blush_colors: asStringArray(raw.blush_colors), liner_brow: typeof raw.liner_brow === 'string' ? raw.liner_brow : '', tips: asStringArray(raw.tips), confidence_note: typeof raw.confidence_note === 'string' ? raw.confidence_note : '', disclaimer: '', ...(look_hex ? { look_hex } : {}), }; } /** Snapshot matches on-screen mirror: horizontal flip, max width capped for upload size. */ function captureMirroredJpeg(video: HTMLVideoElement, maxW = 640, quality = 0.85): Promise { return new Promise((resolve) => { if (video.readyState < 2 || video.videoWidth < 2) { resolve(null); return; } const vw = video.videoWidth; const vh = video.videoHeight; const w = Math.min(maxW, vw); const h = Math.round((vh / vw) * w); const c = document.createElement('canvas'); c.width = w; c.height = h; const ctx = c.getContext('2d'); if (!ctx) { resolve(null); return; } ctx.translate(w, 0); ctx.scale(-1, 1); ctx.drawImage(video, 0, 0, w, h); c.toBlob((b) => resolve(b), 'image/jpeg', quality); }); } function pillTags(tags: string[]): string { if (!tags.length) return 'no tags'; return tags .map((t) => `${escapeHtml(t)}`) .join(''); } function bulletList(items: string[]): string { if (!items.length) return ''; return `
    ${items.map((i) => `
  • ${escapeHtml(i)}
  • `).join('')}
`; } function renderLookHexSection(look: MakeupLookHex | undefined): string { if (!look) { return `

No hex palette in this read. Run a new color read to get loadable colors.

`; } const rows: [string, string][] = [ ['Lips', look.lip], ['Eyeshadow', look.eye_shadow], ['Liner', look.liner], ['Brows', look.brow], ['Blush', look.blush], ]; return `
Palette — tap a swatch to copy hex
${rows .map( ([label, hex]) => `
${escapeHtml(label)}
`, ) .join('')}
`; } function renderAnalysisHtml(a: AiColorAnalysis, vibe: AiLookVibe | null | undefined): string { const vibeLine = vibe != null ? `

${escapeHtml(AI_LOOK_VIBE_LABELS[vibe])} direction

` : ''; return `
${vibeLine}

${escapeHtml(a.headline)}

${pillTags(a.vibe_tags)}
${renderLookHexSection(a.look_hex)}

${escapeHtml(a.undertone_read)}

Lips ${bulletList(a.lip_colors)}
Eyes ${bulletList(a.eye_colors)}
Blush ${bulletList(a.blush_colors)}

${escapeHtml(a.liner_brow)}

Hot tips ${bulletList(a.tips)}

${escapeHtml(a.confidence_note)}

${escapeHtml(a.disclaimer)}

`; } export function initAiColorAnalysis(args: InitArgs): void { const stage = document.querySelector('.stage'); if (!stage) throw new Error('initAiColorAnalysis: .stage missing'); stage.insertAdjacentHTML( 'beforeend', ` `, ); const overlay = document.querySelector('#aiColorOverlay')!; const closeBtn = document.querySelector('#aiColorClose')!; const runBtn = document.querySelector('#aiColorRunBtn')!; const libraryBtn = document.querySelector('#aiColorLibraryBtn')!; const loadingEl = document.querySelector('#aiColorLoading')!; const errorEl = document.querySelector('#aiColorError')!; const bodyEl = document.querySelector('#aiColorBody')!; const footerEl = document.querySelector('#aiColorFooter')!; const applyBtn = document.querySelector('#aiColorApplyBtn')!; const saveBtn = document.querySelector('#aiColorSaveBtn')!; const libraryEl = document.querySelector('#aiColorLibrary')!; const libraryList = document.querySelector('#aiColorLibraryList')!; const libraryEmpty = document.querySelector('#aiColorLibraryEmpty')!; const libraryBack = document.querySelector('#aiColorLibraryBack')!; const mainBlock = document.querySelector('#aiColorMainBlock')!; let latest: AiColorAnalysis | null = null; let lastRunVibe: AiLookVibe | null = null; document.querySelectorAll('input[name="aiLookVibe"]').forEach((r) => { r.addEventListener('change', () => { if (r.checked) storeVibePreference(r.value as AiLookVibe); }); }); syncVibeRadiosFromStorage(); function setVibeFieldsetDisabled(disabled: boolean) { document.querySelector('#aiColorVibeFieldset')!.disabled = disabled; } function toast(msg: string) { if (args.statusEl) args.statusEl.textContent = msg; } function syncApplyButton() { applyBtn.hidden = !latest?.look_hex; } function setOpen(open: boolean) { overlay.hidden = !open; if (!open) { showLibrary(false); errorEl.hidden = true; loadingEl.hidden = true; } } function showLibrary(show: boolean) { libraryEl.hidden = !show; mainBlock.hidden = show; if (show) { loadingEl.hidden = true; renderLibrary(); } } function renderLibrary() { const reads = readSavedReads().sort((a, b) => b.savedAt - a.savedAt); libraryEmpty.hidden = reads.length > 0; libraryList.innerHTML = reads .map( (r) => `
${escapeHtml(r.headline)} ${new Date(r.savedAt).toLocaleString()} ${ r.lookVibe ? `${escapeHtml(AI_LOOK_VIBE_LABELS[r.lookVibe])} look` : '' }
${ r.analysis.look_hex ? `` : '' }
`, ) .join(''); } function openRead(r: SavedAiRead) { latest = r.analysis; showLibrary(false); bodyEl.innerHTML = renderAnalysisHtml(r.analysis, r.lookVibe ?? null); footerEl.hidden = false; syncApplyButton(); errorEl.hidden = true; toast('Opened a saved read.'); } libraryList.addEventListener('click', (e) => { const btn = (e.target as HTMLElement).closest('button[data-act][data-id]'); if (!btn) return; const id = btn.dataset.id!; const reads = readSavedReads(); const r = reads.find((x) => x.id === id); if (!r) return; if (btn.dataset.act === 'view') openRead(r); if (btn.dataset.act === 'apply' && r.analysis.look_hex) { applyMakeupLookFromHex(r.analysis.look_hex); args.onAfterApplyLook?.(r.lookVibe ?? null); setOpen(false); toast('Look applied from scrapbook.'); } if (btn.dataset.act === 'del') { writeSavedReads(reads.filter((x) => x.id !== id)); renderLibrary(); toast('Removed from scrapbook.'); } }); document.getElementById('aiColorReadTrigger')?.addEventListener('click', (e) => { e.preventDefault(); setOpen(true); errorEl.hidden = true; bodyEl.innerHTML = ''; footerEl.hidden = true; latest = null; lastRunVibe = null; showLibrary(false); syncVibeRadiosFromStorage(); }); closeBtn.addEventListener('click', () => setOpen(false)); overlay.addEventListener('click', (e) => { if (e.target === overlay) setOpen(false); }); libraryBtn.addEventListener('click', () => { showLibrary(true); }); libraryBack.addEventListener('click', () => showLibrary(false)); bodyEl.addEventListener('click', (e) => { const chip = (e.target as HTMLElement).closest('[data-copy-hex]'); if (!chip?.dataset.copyHex) return; const h = chip.dataset.copyHex; if (navigator.clipboard?.writeText) { void navigator.clipboard.writeText(h).then( () => toast(`Copied ${h}`), () => toast('Could not copy to clipboard.'), ); } else { toast('Clipboard not available in this context.'); } }); applyBtn.addEventListener('click', () => { if (!latest?.look_hex) return; applyMakeupLookFromHex(latest.look_hex); args.onAfterApplyLook?.(lastRunVibe ?? readVibeFromDom()); setOpen(false); toast('Look applied to try-on.'); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !overlay.hidden) setOpen(false); }); runBtn.addEventListener('click', async () => { errorEl.hidden = true; bodyEl.innerHTML = ''; footerEl.hidden = true; latest = null; lastRunVibe = null; const blob = await captureMirroredJpeg(args.video); if (!blob) { errorEl.textContent = 'Camera is not ready yet — give it a sec and try again.'; errorEl.hidden = false; return; } loadingEl.hidden = false; runBtn.disabled = true; setVibeFieldsetDisabled(true); toast('Sending snapshot for color read…'); try { const fd = new FormData(); fd.append('image', blob, 'snapshot.jpg'); fd.append('look_vibe', readVibeFromDom()); const res = await fetch('/api/analyze-makeup-colors', { method: 'POST', body: fd }); const rawText = await res.text(); let json: Record = {}; try { json = JSON.parse(rawText) as Record; } catch { if (!res.ok) throw new Error(rawText.slice(0, 240) || `Request failed (${res.status})`); } if (!res.ok) { const detail = json.detail; let msg: string; if (typeof detail === 'string') msg = detail; else if (Array.isArray(detail)) msg = detail .map((item) => item && typeof item === 'object' && 'msg' in item ? String((item as { msg: unknown }).msg) : JSON.stringify(item), ) .join(' '); else msg = `Request failed (${res.status})`; throw new Error(msg); } const analysisRaw = json.analysis as Record | undefined; if (!analysisRaw || typeof analysisRaw !== 'object') { throw new Error('Unexpected response shape from server.'); } const analysis = normalizeAnalysis(analysisRaw); latest = analysis; lastRunVibe = readVibeFromDom(); bodyEl.innerHTML = renderAnalysisHtml(analysis, lastRunVibe); footerEl.hidden = false; syncApplyButton(); toast('Color read ready.'); } catch (err) { const msg = err instanceof Error ? err.message : String(err); errorEl.textContent = msg; errorEl.hidden = false; toast('Color read failed — see message in panel.'); } finally { loadingEl.hidden = true; runBtn.disabled = false; setVibeFieldsetDisabled(false); } }); saveBtn.addEventListener('click', () => { if (!latest) return; const entry: SavedAiRead = { id: newId(), savedAt: Date.now(), headline: latest.headline, lookVibe: lastRunVibe ?? readVibeFromDom(), analysis: latest, }; const all = readSavedReads(); all.push(entry); writeSavedReads(all); toast('Saved to scrapbook.'); }); }