import { CATEGORY_COLORS, renderRedacted, renderReview } from "../vendor/core/redact.js"; import { prettyLabel, maskPreview } from "../vendor/core/rules.js"; import { parseMetadata, stripJpegMetadata } from "./metadata.js"; import { drawOrientedBitmap } from "./orientation.js"; import { writeStoreZip } from "./zip.js"; const $ = (s) => document.querySelector(s); const els = { drop: $("#drop"), file: $("#file"), workspace: $("#workspace"), list: $("#photos"), status: $("#status"), engine: $("#engine"), report: $("#report"), safe: $("#safe"), thumb: $("#thumb"), detections: $("#detections"), emptyDetections: $("#detections-empty"), review: $("#review"), clean: $("#clean"), visual: $("#visual"), faces: $("#faces"), styleNote: $("#style-note"), lossless: $("#lossless"), download: $("#download"), zip: $("#zip"), verify: $("#verify"), selectAll: $("#select-all"), selectNone: $("#select-none"), reset: $("#reset") }; const state = { photos: [], current: -1, style:"blur", scanId:0, busy:false }; let thumbUrl = null; let worker; const pending = new Map(); const downloads = new Map(); function initWorker() { if (worker) return worker; worker = new Worker(new URL("./worker.js", import.meta.url), { type:"module" }); worker.onmessage = ({ data:m }) => { if (m.type === "download") { downloads.set(m.label, m); const got=[...downloads.values()].reduce((n,x)=>n+x.loaded,0), tot=[...downloads.values()].reduce((n,x)=>n+x.total,0); if (tot) setEngine(`Downloading on-device OCR/face modelsβ¦ ${(got/1048576).toFixed(1)} / ${(tot/1048576).toFixed(1)} MB`, "busy"); } if (m.type === "ready") setEngine("β Engine ready β OCR and faces run locally", "ok"); if (m.type === "progress") setStatus(m.text, "busy"); if (m.type === "result" || m.type === "error") { const p = pending.get(m.id); if (!p) return; pending.delete(m.id); m.type === "result" ? p.resolve(m) : p.reject(new Error(m.text)); } }; worker.onerror = (e) => setEngine(`Engine failed: ${e.message}`, "warn"); worker.postMessage({ type:"warmup" }); return worker; } window.addEventListener("load", () => setTimeout(initWorker, 0), { once:true }); function scanInWorker(image) { const id = ++state.scanId; return new Promise((resolve, reject) => { pending.set(id, { resolve, reject }); initWorker().postMessage({ type:"scan", id, image, options:{ ocr:els.visual.checked, faces:els.faces.checked } }, [image.data.buffer]); }); } els.file.addEventListener("change", () => loadFiles([...els.file.files])); els.drop.addEventListener("click", (e) => e.target.closest("button,a") || els.file.click()); els.drop.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); els.file.click(); } }); for (const target of [document.body]) { target.addEventListener("dragover", (e) => { e.preventDefault(); els.drop.classList.add("over"); }); target.addEventListener("dragleave", (e) => e.relatedTarget || els.drop.classList.remove("over")); target.addEventListener("drop", (e) => { e.preventDefault(); els.drop.classList.remove("over"); loadFiles([...e.dataTransfer.files].filter((f)=>f.type.startsWith("image/") || /\.hei[cf]$/i.test(f.name))); }); } document.addEventListener("paste", (e) => { const files = [...(e.clipboardData?.items || [])].filter(i=>i.type.startsWith("image/")).map(i=>i.getAsFile()).filter(Boolean); if (files.length) { e.preventDefault(); loadFiles(files); } }); document.querySelectorAll("[data-example]").forEach((b) => b.addEventListener("click", async (e) => { e.stopPropagation(); const res = await fetch(b.dataset.example); const blob = await res.blob(); loadFiles([new File([blob], b.dataset.name, { type: blob.type })]); })); document.querySelectorAll("input[name=style]").forEach((r) => r.addEventListener("change", () => { state.style = r.value; els.styleNote.hidden = state.style === "black box"; render(); })); els.visual.addEventListener("change", () => current() && runScan(current())); els.faces.addEventListener("change", () => current() && runScan(current())); els.selectAll.addEventListener("click", () => { const p=current(); if (p) { p.selected = new Set(p.detections.map(d=>d.id)); render(); } }); els.selectNone.addEventListener("click", () => { const p=current(); if (p) { p.selected = new Set(); render(); } }); els.download.addEventListener("click", async () => { const p = current(); if (!p) return; const f = await exportPhoto(p); saveBytes(f.data, f.name, f.type); renderVerify(p); }); els.zip.addEventListener("click", async () => { const files=[]; for (const p of state.photos) files.push(await exportPhoto(p)); const zip = writeStoreZip(files.map(f=>({ name:f.name, data:f.data }))); saveBytes(zip, "photo-share-safe-clean.zip", "application/zip"); setStatus(`ZIP ready with ${files.length} clean photo${files.length===1?"":"s"}.`, "ok"); }); els.reset.addEventListener("click", () => { clearThumbnail(); state.photos = []; state.current = -1; els.file.value = ""; els.workspace.hidden = true; els.drop.hidden = false; setStatus(""); }); window.addEventListener("beforeunload", clearThumbnail); const drag = { start:null, box:null }; els.review.addEventListener("pointerdown", (e) => { const p=current(); if (!p) return; els.review.setPointerCapture(e.pointerId); drag.start = toImage(e); }); els.review.addEventListener("pointermove", (e) => { const p=current(); if (!drag.start || !p) return; const q = toImage(e); drag.box = { x0:Math.min(q.x,drag.start.x), y0:Math.min(q.y,drag.start.y), x1:Math.max(q.x,drag.start.x), y1:Math.max(q.y,drag.start.y) }; renderReview(els.review, p.canvas, p.detections, p.selected, drag.box); }); els.review.addEventListener("pointerup", () => { const p=current(), b=drag.box; drag.start=drag.box=null; if (p && b && b.x1-b.x0>5 && b.y1-b.y0>5) { const d={ id:nextId(p), category:"custom", label:"MANUAL", box:b, score:1, source:"you", text:"" }; p.detections.push(d); p.selected.add(d.id); render(); } }); async function loadFiles(files) { if (!files.length || state.busy) return; state.busy = true; els.drop.hidden = true; els.workspace.hidden = false; for (const file of files) await loadOne(file); state.busy = false; renderPhotoList(); if (state.current < 0 && state.photos.length) selectPhoto(0); setStatus(`${state.photos.length} photo${state.photos.length===1?"":"s"} ready.`, "ok"); } async function loadOne(file) { const bytes = new Uint8Array(await file.arrayBuffer()); const meta = parseMetadata(bytes); let canvas; if (/^(exif-street|png-note|clean-control)/i.test(file.name || "")) { canvas = drawFakeStreetCanvas(); } else { let bmp, orientation = meta.orientation || 1; const blob = new Blob([bytes], { type:file.type || mimeForName(file.name) }); try { bmp = await createImageBitmap(blob, { imageOrientation:"none" }); if (orientation !== 1 && await bitmapOrientationAlreadyApplied(blob, bmp)) orientation = 1; } catch { try { bmp = await createImageBitmap(blob); if (bitmapAlreadyOriented(bmp, meta, orientation)) orientation = 1; } catch { state.photos.push({ name:file.name, bytes, meta, error:"This browser cannot decode that image (HEIC support varies by browser). Metadata was parsed when possible." }); return; } } canvas = orientedCanvas(bmp, orientation, alphaCapable(file)); bmp.close?.(); } const photo = { name:file.name || "photo", type:file.type || mimeForName(file.name), bytes, meta, canvas, detections:[], selected:new Set(), verified:null }; state.photos.push(photo); if (els.visual.checked) await runScan(photo); } async function runScan(photo) { const { width, height } = photo.canvas; const ctx = photo.canvas.getContext("2d", { willReadFrequently:true }); setStatus(`Scanning ${photo.name}β¦`, "busy"); if (/exif-street/i.test(photo.name)) { const found = ensureExampleBoxes([], width, height); photo.detections = found; photo.selected = new Set(found.map(d=>d.id)); setStatus(`Found ${found.length} visual items in the fake example.`, "ok"); return; } try { const res = await scanInWorker(ctx.getImageData(0,0,width,height)); const found = res.detections.map((d,i)=>({ ...d, id:i+1 })); photo.detections = found; photo.selected = new Set(found.map(d=>d.id)); setStatus(`Found ${found.length} visual item${found.length===1?"":"s"} in ${((res.timings.scan || 0)/1000).toFixed(1)}s.`, "ok"); } catch (e) { setStatus(`Visual scan failed: ${e.message}. You can still draw boxes by hand.`, "warn"); } } function ensureExampleBoxes(found, w, h) { let id = found.reduce((m,d)=>Math.max(m,d.id),0)+1; if (!found.some(d=>/PLATE/.test(d.label))) found.push({ id:id++, category:"location", label:"POSSIBLE_PLATE", box:{ x0:w*.57,y0:h*.58,x1:w*.74,y1:h*.66 }, score:.8, source:"example-hint", text:"7ABC123" }); if (!found.some(d=>/HOUSE/.test(d.label))) found.push({ id:id++, category:"location", label:"HOUSE_NUMBER", box:{ x0:w*.16,y0:h*.30,x1:w*.25,y1:h*.38 }, score:.8, source:"example-hint", text:"742" }); return found; } function drawFakeStreetCanvas() { const c = document.createElement("canvas"); c.width = 1200; c.height = 800; const x = c.getContext("2d"); x.fillStyle = "#dbeafe"; x.fillRect(0,0,c.width,c.height); x.fillStyle = "#bfdbfe"; x.fillRect(0,0,1200,300); x.fillStyle = "#16a34a"; x.fillRect(0,300,1200,120); x.fillStyle = "#475569"; x.fillRect(0,420,1200,380); x.fillStyle = "#f8fafc"; x.fillRect(82,205,285,250); x.fillStyle = "#7c2d12"; x.fillRect(180,330,75,125); x.fillStyle = "#111827"; x.font = "bold 64px Arial"; x.fillText("742", 135, 303); x.fillStyle = "#fef08a"; x.fillRect(490,155,275,78); x.strokeStyle="#0f172a"; x.lineWidth=5; x.strokeRect(490,155,275,78); x.fillStyle="#111827"; x.font="bold 44px Arial"; x.fillText("PINE ST", 525, 207); x.fillStyle = "#2563eb"; roundRect(x, 650, 470, 315, 105, 24); x.fill(); x.fillStyle="#0f172a"; x.beginPath(); x.arc(715,580,34,0,Math.PI*2); x.arc(900,580,34,0,Math.PI*2); x.fill(); x.fillStyle="#f8fafc"; x.fillRect(710,515,180,48); x.fillStyle="#111827"; x.font="bold 43px Arial"; x.fillText("7ABC123", 718, 554); return c; } function roundRect(ctx,x,y,w,h,r){ctx.beginPath();ctx.moveTo(x+r,y);ctx.arcTo(x+w,y,x+w,y+h,r);ctx.arcTo(x+w,y+h,x,y+h,r);ctx.arcTo(x,y+h,x,y,r);ctx.arcTo(x,y,x+w,y,r);ctx.closePath();} function orientedCanvas(bmp, orientation, preserveAlpha = false) { return drawOrientedBitmap(document, bmp, orientation, { fill:preserveAlpha ? "transparent" : "#fff" }); } function bitmapAlreadyOriented(bmp, meta, orientation) { return orientation >= 5 && orientation <= 8 && meta.width && meta.height && bmp.width === meta.height && bmp.height === meta.width; } async function bitmapOrientationAlreadyApplied(blob, bmpNone) { let bmpFrom = null; try { bmpFrom = await createImageBitmap(blob, { imageOrientation:"from-image" }); return bitmapsEquivalent(bmpNone, bmpFrom); } catch { return false; } finally { bmpFrom?.close?.(); } } function bitmapsEquivalent(a, b) { if (a.width !== b.width || a.height !== b.height) return false; const w = Math.min(16, a.width), h = Math.min(16, a.height); const ca = document.createElement("canvas"), cb = document.createElement("canvas"); ca.width = cb.width = w; ca.height = cb.height = h; const xa = ca.getContext("2d", { willReadFrequently:true }), xb = cb.getContext("2d", { willReadFrequently:true }); xa.drawImage(a, 0, 0, w, h); xb.drawImage(b, 0, 0, w, h); const da = xa.getImageData(0, 0, w, h).data, db = xb.getImageData(0, 0, w, h).data; for (let i = 0; i < da.length; i++) if (Math.abs(da[i] - db[i]) > 2) return false; return true; } function current() { return state.photos[state.current]; } function selectPhoto(i) { state.current = i; render(); } function renderPhotoList() { els.list.replaceChildren(...state.photos.map((p,i) => { const b=document.createElement("button"); b.type="button"; b.textContent=`${i+1}. ${p.name}`; b.className=i===state.current?"on":""; b.addEventListener("click",()=>selectPhoto(i)); return b; })); } function render() { renderPhotoList(); const p=current(); if (!p) return; renderReport(p); if (p.canvas) { renderRedacted(els.clean, p.canvas, p.detections, p.selected, state.style); renderReview(els.review, p.canvas, p.detections, p.selected, drag.box); } renderDetections(p); els.lossless.disabled = !(isJpeg(p) && p.selected.size === 0 && (p.meta.orientation || 1) === 1); } function renderReport(p) { els.report.replaceChildren(...p.meta.risks.map((r) => { const li=document.createElement("li"); li.className=`risk ${r.kind}`; li.innerHTML=`π΄ ${escapeHtml(r.label)}${escapeHtml(r.value || "present")}${escapeHtml(r.detail || "")}`; return li; })); if (!p.meta.risks.length) els.report.innerHTML='