Download src/app.js from NagaYu/dasumae: direct link, hf CLI and curl.
- Browser
- Download file 25.5 kB
-
https://huggingface.co/spaces/NagaYu/dasumae/resolve/main/src/app.js
- Command line
-
hf download hf://spaces/NagaYu/dasumae/src/app.js
-
curl -L -o app.js https://huggingface.co/spaces/NagaYu/dasumae/resolve/main/src/app.js
25.5 kB
| // DasuMae — main controller. Everything runs in-page: OCR (Tesseract worker), | |
| // detect.js, face detection (ORT worker), editing, and PNG export. No network | |
| // request leaves this origin. | |
| import { detect } from './detect.js'; | |
| import { STRINGS, CLASS_INFO } from './i18n.js'; | |
| import { applyRedaction, STYLES } from './redact.js'; | |
| import { runOcr } from './ocr.js'; | |
| // --------------------------------------------------------------------------- | |
| // State | |
| // --------------------------------------------------------------------------- | |
| // --------------------------------------------------------------------------- | |
| // Language selection (house rule, decided 2026-11-12, global-first) | |
| // 1. The saved choice first ('ja' | 'en'); anything else is ignored. | |
| // 2. Else Japanese only when the browser language starts with 'ja'. | |
| // 3. Else English — including when no locale is available. | |
| // Storage is wrapped in try/catch so a blocked/unavailable store never crashes. | |
| // --------------------------------------------------------------------------- | |
| const LANG_KEY = 'dasumae-lang'; | |
| function savedLang() { | |
| try { | |
| const v = localStorage.getItem(LANG_KEY); | |
| return v === 'ja' || v === 'en' ? v : null; | |
| } catch { return null; } | |
| } | |
| function persistLang(lang) { | |
| try { localStorage.setItem(LANG_KEY, lang); } catch { /* storage unavailable: ignore */ } | |
| } | |
| function initialLang() { | |
| const saved = savedLang(); | |
| if (saved) return saved; | |
| let nav = ''; | |
| try { nav = (navigator.language || '').toLowerCase(); } catch { nav = ''; } | |
| if (nav.startsWith('ja')) return 'ja'; | |
| return 'en'; | |
| } | |
| const state = { | |
| lang: initialLang(), | |
| style: 'solid', | |
| img: null, // ImageBitmap (natural size) | |
| natCanvas: null, // natural-size canvas: OCR input + redaction source | |
| natW: 0, natH: 0, | |
| viewScale: 1, // CSS px per original px | |
| boxes: [], // { id, class, text, bbox, active, manual, score? } | |
| selectedId: null, | |
| faceWorker: null, | |
| faceEnabled: true, | |
| faceStatus: 'idle', // idle | running | ok | unavailable | |
| seq: 0, // image generation, to ignore stale async results | |
| }; | |
| let nextId = 1; | |
| const dpr = Math.min(window.devicePixelRatio || 1, 2); | |
| // --------------------------------------------------------------------------- | |
| // DOM | |
| // --------------------------------------------------------------------------- | |
| const $ = (id) => document.getElementById(id); | |
| const dropzone = $('dropzone'); | |
| const fileInput = $('fileInput'); | |
| const editor = $('editor'); | |
| const canvas = $('canvas'); | |
| const ctx = canvas.getContext('2d'); | |
| const statusEl = $('status'); | |
| const detList = $('detList'); | |
| const noDet = $('noDet'); | |
| const styleSel = $('styleSel'); | |
| const faceToggle = $('faceToggle'); | |
| const faceToggleLabel = $('faceToggleLabel'); | |
| const downloadBtn = $('downloadBtn'); | |
| const resetBtn = $('resetBtn'); | |
| const langToggle = $('langToggle'); | |
| const t = () => STRINGS[state.lang]; | |
| // --------------------------------------------------------------------------- | |
| // i18n application | |
| // --------------------------------------------------------------------------- | |
| function applyI18n() { | |
| const s = t(); | |
| document.documentElement.lang = state.lang; | |
| if (s.title) document.title = s.title; | |
| const metaDesc = document.querySelector('meta[name="description"]'); | |
| if (metaDesc && s.metaDescription) metaDesc.setAttribute('content', s.metaDescription); | |
| for (const el of document.querySelectorAll('[data-i18n]')) { | |
| const key = el.getAttribute('data-i18n'); | |
| if (s[key] != null && typeof s[key] === 'string') el.textContent = s[key]; | |
| } | |
| // lists | |
| fillList('supportedList', s.supportedList); | |
| fillList('limitsList', s.limitsList); | |
| $('stressNote').textContent = s.stressNote || ''; | |
| faceToggleLabel.textContent = state.faceStatus === 'unavailable' ? s.faceToggleSoon : s.faceReady; | |
| langToggle.textContent = s.langToggle; | |
| renderList(); | |
| updateStyleButtons(); | |
| } | |
| function fillList(id, arr) { | |
| const ul = $(id); | |
| if (!ul) return; | |
| ul.innerHTML = ''; | |
| for (const item of arr || []) { | |
| const li = document.createElement('li'); | |
| li.textContent = item; | |
| ul.appendChild(li); | |
| } | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Status | |
| // --------------------------------------------------------------------------- | |
| function setStatus(text) { | |
| if (!text) { statusEl.classList.remove('show'); statusEl.textContent = ''; return; } | |
| statusEl.textContent = text; | |
| statusEl.classList.add('show'); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Image loading + pipeline | |
| // --------------------------------------------------------------------------- | |
| async function loadFromBlob(blob) { | |
| let bitmap; | |
| try { | |
| // Decode with no colour-space conversion so the pixels Tesseract sees match | |
| // a plain leptonica decode as closely as the browser allows (improves OCR | |
| // parity with the Phase 0 node measurements). | |
| bitmap = await createImageBitmap(blob, { colorSpaceConversion: 'none' }); | |
| } catch { | |
| try { bitmap = await createImageBitmap(blob); } catch { setStatus(t().loadError); return; } | |
| } | |
| const seq = ++state.seq; | |
| state.img = bitmap; | |
| state.natW = bitmap.width; | |
| state.natH = bitmap.height; | |
| state.boxes = []; | |
| state.selectedId = null; | |
| // natural-size canvas used as OCR input and redaction source | |
| const nc = document.createElement('canvas'); | |
| nc.width = state.natW; nc.height = state.natH; | |
| nc.getContext('2d').drawImage(bitmap, 0, 0); | |
| state.natCanvas = nc; | |
| editor.hidden = false; | |
| dropzone.hidden = true; | |
| layout(); | |
| redraw(); | |
| renderList(); | |
| await runPipeline(seq); | |
| } | |
| // Browser Tesseract always re-rasterises its input through a canvas (passing raw | |
| // bytes / a blob / a 1x canvas all gave identical OCR). The browser decode is | |
| // noisier than node's leptonica path in SCALE-DEPENDENT ways: at 1x long API | |
| // keys read cleanly but some card digits confuse (0<->9, breaking Luhn); at 2x | |
| // the digits resolve but long keys fragment. So we OCR at BOTH scales and take | |
| // the union of detections — each scale covers the other's blind spot. Boxes are | |
| // scaled back to original-image pixels before merging. | |
| const OCR_SCALES = [1, 2]; | |
| function ocrCanvasAt(scale) { | |
| if (scale === 1) return state.natCanvas; | |
| const c = document.createElement('canvas'); | |
| c.width = Math.round(state.natW * scale); | |
| c.height = Math.round(state.natH * scale); | |
| const cx = c.getContext('2d'); | |
| cx.imageSmoothingEnabled = true; cx.imageSmoothingQuality = 'high'; | |
| cx.drawImage(state.natCanvas, 0, 0, c.width, c.height); | |
| return c; | |
| } | |
| function boxIou(a, b) { | |
| const ix = Math.max(0, Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x)); | |
| const iy = Math.max(0, Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y)); | |
| const inter = ix * iy, uni = a.w * a.h + b.w * b.h - inter; | |
| return uni <= 0 ? 0 : inter / uni; | |
| } | |
| // Union detections from the scale passes, dropping same-class overlaps (keep the | |
| // larger box, which redacts at least as much). | |
| function mergeDetections(dets) { | |
| const out = []; | |
| for (const d of dets.slice().sort((a, b) => (b.bbox.w * b.bbox.h) - (a.bbox.w * a.bbox.h))) { | |
| if (!out.some((o) => o.class === d.class && boxIou(o.bbox, d.bbox) > 0.4)) out.push(d); | |
| } | |
| return out; | |
| } | |
| async function runPipeline(seq) { | |
| if (window.__dasumae) { window.__dasumae.done = false; window.__dasumae.processing = true; } | |
| setStatus(t().statusOcr); | |
| const collected = []; | |
| let totalMs = 0, lastWords = null; | |
| try { | |
| for (const scale of OCR_SCALES) { | |
| const ocr = await runOcr(ocrCanvasAt(scale), (m) => { | |
| if (seq !== state.seq) return; | |
| if (m && m.status && /recogniz/i.test(m.status)) { | |
| setStatus(`${t().statusOcr} ${Math.round((m.progress || 0) * 100)}%`); | |
| } | |
| }); | |
| if (seq !== state.seq) return; | |
| if (scale === 1) lastWords = ocr.words; | |
| totalMs += ocr.ms; | |
| const inv = 1 / scale; | |
| for (const d of detect(ocr.words)) { | |
| const bbox = scale === 1 ? d.bbox : { x: d.bbox.x * inv, y: d.bbox.y * inv, w: d.bbox.w * inv, h: d.bbox.h * inv }; | |
| collected.push({ class: d.class, text: d.text, bbox, confidence: d.confidence }); | |
| } | |
| } | |
| } catch (err) { | |
| console.error('OCR failed', err); | |
| setStatus(t().loadError); | |
| if (window.__dasumae) { window.__dasumae.processing = false; window.__dasumae.done = true; window.__dasumae.error = String(err); } | |
| return; | |
| } | |
| if (seq !== state.seq) return; | |
| if (window.__dasumae) window.__dasumae.lastWords = lastWords; // debug/harness hook | |
| // Merge the per-scale detections, then add. | |
| setStatus(t().statusDetect); | |
| for (const d of mergeDetections(collected)) { | |
| addBox({ class: d.class, text: d.text, bbox: d.bbox, active: true, manual: false, score: d.confidence }); | |
| } | |
| renderList(); | |
| redraw(); | |
| // Face (worker) | |
| if (state.faceEnabled) { | |
| setStatus(t().statusFace); | |
| await runFace(seq); | |
| } | |
| if (seq !== state.seq) return; | |
| setStatus(t().statusDone); | |
| setTimeout(() => { if (seq === state.seq) setStatus(''); }, 1200); | |
| if (window.__dasumae) { window.__dasumae.processing = false; window.__dasumae.done = true; window.__dasumae.ocrMs = Math.round(totalMs); } | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Face worker | |
| // --------------------------------------------------------------------------- | |
| function ensureFaceWorker() { | |
| if (state.faceWorker) return state.faceWorker; | |
| state.faceWorker = new Worker(new URL('./face-worker.js', import.meta.url), { type: 'module' }); | |
| return state.faceWorker; | |
| } | |
| function faceInputRGBA() { | |
| // Resize the natural image to the model's 320x240 input and read RGBA. | |
| const W = 320, H = 240; | |
| const c = document.createElement('canvas'); | |
| c.width = W; c.height = H; | |
| const cctx = c.getContext('2d'); | |
| cctx.drawImage(state.natCanvas, 0, 0, state.natW, state.natH, 0, 0, W, H); | |
| return cctx.getImageData(0, 0, W, H).data.buffer.slice(0); | |
| } | |
| function runFace(seq) { | |
| return new Promise((resolve) => { | |
| let worker; | |
| try { worker = ensureFaceWorker(); } catch (e) { markFaceUnavailable(String(e)); return resolve(); } | |
| const rgba = faceInputRGBA(); | |
| const timer = setTimeout(() => { markFaceUnavailable('timeout'); cleanup(); resolve(); }, 20000); | |
| const onMsg = (e) => { | |
| const msg = e.data || {}; | |
| if (msg.type === 'result') { | |
| state.faceStatus = 'ok'; | |
| if (window.__dasumae) window.__dasumae.faceOk = true; | |
| if (seq === state.seq) { | |
| for (const f of msg.faces) { | |
| addBox({ class: 'face', text: `face ${f.score}`, bbox: { x: f.x, y: f.y, w: f.w, h: f.h }, active: true, manual: false, score: f.score }); | |
| } | |
| renderList(); redraw(); | |
| } | |
| cleanup(); resolve(); | |
| } else if (msg.type === 'error') { | |
| markFaceUnavailable(msg.message); cleanup(); resolve(); | |
| } | |
| }; | |
| const onErr = (e) => { markFaceUnavailable(e.message || 'worker error'); cleanup(); resolve(); }; | |
| function cleanup() { clearTimeout(timer); worker.removeEventListener('message', onMsg); worker.removeEventListener('error', onErr); } | |
| worker.addEventListener('message', onMsg); | |
| worker.addEventListener('error', onErr); | |
| worker.postMessage({ type: 'detect', rgba, origW: state.natW, origH: state.natH, thr: 0.7 }, [rgba]); | |
| }); | |
| } | |
| function markFaceUnavailable(reason) { | |
| console.warn('Face detection unavailable:', reason); | |
| state.faceStatus = 'unavailable'; | |
| state.faceEnabled = false; | |
| faceToggle.checked = false; | |
| faceToggle.disabled = true; | |
| faceToggle.parentElement.classList.add('soon'); | |
| faceToggleLabel.textContent = t().faceToggleSoon; | |
| if (window.__dasumae) { window.__dasumae.faceOk = false; window.__dasumae.faceError = reason; } | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Boxes | |
| // --------------------------------------------------------------------------- | |
| function addBox(b) { | |
| state.boxes.push({ id: nextId++, ...b }); | |
| } | |
| function maskText(cls, text) { | |
| const s = String(text); | |
| if (cls === 'credit_card' || cls === 'my_number') { | |
| const digits = s.replace(/\D/g, ''); | |
| if (digits.length >= 8) { | |
| const groups = []; | |
| for (let i = 0; i < digits.length; i += 4) groups.push(digits.slice(i, i + 4)); | |
| return groups.map((g, i) => (i === 0 || i === groups.length - 1) ? g : '•'.repeat(g.length)).join(' '); | |
| } | |
| } | |
| if (cls === 'email') { | |
| const at = s.indexOf('@'); | |
| if (at > 0) return s[0] + '•••' + s.slice(at); | |
| return s[0] + '•••'; | |
| } | |
| if (cls === 'api_key') { | |
| const m = s.match(/^([A-Za-z]+[-_]?(?:[a-z0-9]+-)?)/); | |
| const head = (m && m[1]) ? m[1] : s.slice(0, 4); | |
| return head + '•••' + s.slice(-4); | |
| } | |
| if (cls === 'ip_address') { | |
| const p = s.split('.'); | |
| return p.length === 4 ? `${p[0]}.${p[1]}.•••.•••` : s; | |
| } | |
| if (cls === 'phone_jp' || cls === 'phone_intl') { | |
| const keepHead = s.slice(0, Math.min(4, s.length)); | |
| return keepHead + '•••' + s.slice(-2); | |
| } | |
| if (cls === 'postal_jp') return s.replace(/\d(?=\d*\d$)/g, '•'); | |
| if (cls === 'face') return cls; | |
| // generic middle mask | |
| if (s.length <= 4) return '•'.repeat(s.length); | |
| return s.slice(0, 2) + '•'.repeat(Math.max(1, s.length - 4)) + s.slice(-2); | |
| } | |
| function classLabel(cls) { | |
| const info = CLASS_INFO[cls] || { ja: cls, en: cls }; | |
| return state.lang === 'ja' ? `${info.ja} / ${info.en}` : `${info.en} / ${info.ja}`; | |
| } | |
| function classColor(cls) { return (CLASS_INFO[cls] || { color: '#495057' }).color; } | |
| // --------------------------------------------------------------------------- | |
| // Detection list | |
| // --------------------------------------------------------------------------- | |
| function renderList() { | |
| if (!detList) return; | |
| detList.innerHTML = ''; | |
| const s = t(); | |
| if (state.boxes.length === 0) { noDet.hidden = false; noDet.textContent = s.noDetections; return; } | |
| noDet.hidden = true; | |
| for (const b of state.boxes) { | |
| const li = document.createElement('li'); | |
| li.className = 'det-item' + (b.active ? '' : ' off') + (b.id === state.selectedId ? ' selected' : ''); | |
| li.dataset.id = b.id; | |
| const sw = document.createElement('div'); | |
| sw.className = 'det-swatch'; | |
| sw.style.background = b.manual ? 'repeating-linear-gradient(45deg,#495057,#495057 3px,#fff 3px,#fff 6px)' : classColor(b.class); | |
| const main = document.createElement('div'); | |
| main.className = 'det-main'; | |
| const cls = document.createElement('div'); | |
| cls.className = 'det-class'; | |
| cls.textContent = b.manual ? classLabel('manual') : classLabel(b.class); | |
| const txt = document.createElement('div'); | |
| txt.className = 'det-text'; | |
| txt.textContent = b.manual ? '' : maskText(b.class, b.text); | |
| main.append(cls, txt); | |
| const ctrls = document.createElement('div'); | |
| ctrls.className = 'det-controls'; | |
| const toggle = document.createElement('button'); | |
| toggle.className = 'icon-btn'; | |
| toggle.textContent = b.active ? s.on : s.off; | |
| toggle.addEventListener('click', (ev) => { ev.stopPropagation(); b.active = !b.active; renderList(); redraw(); }); | |
| const del = document.createElement('button'); | |
| del.className = 'icon-btn'; | |
| del.textContent = s.delete; | |
| del.addEventListener('click', (ev) => { ev.stopPropagation(); state.boxes = state.boxes.filter((x) => x.id !== b.id); if (state.selectedId === b.id) state.selectedId = null; renderList(); redraw(); }); | |
| ctrls.append(toggle, del); | |
| li.append(sw, main, ctrls); | |
| li.addEventListener('click', () => { state.selectedId = b.id; renderList(); redraw(); }); | |
| detList.appendChild(li); | |
| } | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Canvas layout + rendering | |
| // --------------------------------------------------------------------------- | |
| function layout() { | |
| if (!state.img) return; | |
| const wrap = canvas.parentElement; | |
| const availW = Math.max(240, wrap.clientWidth - 24); | |
| const availH = Math.max(240, Math.round(window.innerHeight * 0.72)); | |
| state.viewScale = Math.min(availW / state.natW, availH / state.natH, 1); | |
| const cssW = Math.round(state.natW * state.viewScale); | |
| const cssH = Math.round(state.natH * state.viewScale); | |
| canvas.style.width = cssW + 'px'; | |
| canvas.style.height = cssH + 'px'; | |
| canvas.width = Math.round(cssW * dpr); | |
| canvas.height = Math.round(cssH * dpr); | |
| } | |
| function redraw() { | |
| if (!state.img) return; | |
| const s = state.viewScale * dpr; // original px -> canvas px | |
| ctx.setTransform(1, 0, 0, 1, 0, 0); | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| ctx.drawImage(state.img, 0, 0, state.natW, state.natH, 0, 0, canvas.width, canvas.height); | |
| // active boxes: real redaction preview | |
| for (const b of state.boxes) { | |
| if (b.active) applyRedaction(ctx, state.natCanvas, b.bbox, s, state.style); | |
| } | |
| // outlines + handles | |
| for (const b of state.boxes) { | |
| const x = b.bbox.x * s, y = b.bbox.y * s, w = b.bbox.w * s, h = b.bbox.h * s; | |
| const col = b.manual ? '#495057' : classColor(b.class); | |
| ctx.save(); | |
| ctx.lineWidth = Math.max(1, 2 * dpr); | |
| ctx.strokeStyle = col; | |
| if (!b.active || b.manual) ctx.setLineDash([6 * dpr, 4 * dpr]); | |
| ctx.strokeRect(x, y, w, h); | |
| ctx.setLineDash([]); | |
| if (b.id === state.selectedId) { | |
| // resize handle (bottom-right) | |
| const hs = 9 * dpr; | |
| ctx.fillStyle = '#fff'; ctx.strokeStyle = col; | |
| ctx.fillRect(x + w - hs, y + h - hs, hs, hs); | |
| ctx.strokeRect(x + w - hs, y + h - hs, hs, hs); | |
| } | |
| ctx.restore(); | |
| } | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Pointer interaction: create / move / resize | |
| // --------------------------------------------------------------------------- | |
| let drag = null; // { mode:'create'|'move'|'resize', id, startX, startY, orig } | |
| function toOrig(ev) { | |
| const r = canvas.getBoundingClientRect(); | |
| return { | |
| x: (ev.clientX - r.left) / state.viewScale, | |
| y: (ev.clientY - r.top) / state.viewScale, | |
| }; | |
| } | |
| function hitHandle(b, p) { | |
| const hs = 12 / state.viewScale; // generous in original px | |
| return p.x >= b.bbox.x + b.bbox.w - hs && p.x <= b.bbox.x + b.bbox.w + hs * 0.4 | |
| && p.y >= b.bbox.y + b.bbox.h - hs && p.y <= b.bbox.y + b.bbox.h + hs * 0.4; | |
| } | |
| function hitBox(b, p) { | |
| return p.x >= b.bbox.x && p.x <= b.bbox.x + b.bbox.w && p.y >= b.bbox.y && p.y <= b.bbox.y + b.bbox.h; | |
| } | |
| canvas.addEventListener('pointerdown', (ev) => { | |
| if (!state.img) return; | |
| canvas.setPointerCapture(ev.pointerId); | |
| const p = toOrig(ev); | |
| // selected box handle first | |
| const sel = state.boxes.find((b) => b.id === state.selectedId); | |
| if (sel && hitHandle(sel, p)) { drag = { mode: 'resize', id: sel.id, startX: p.x, startY: p.y, orig: { ...sel.bbox } }; return; } | |
| // topmost box under cursor | |
| for (let i = state.boxes.length - 1; i >= 0; i--) { | |
| const b = state.boxes[i]; | |
| if (hitBox(b, p)) { | |
| state.selectedId = b.id; | |
| drag = { mode: 'move', id: b.id, startX: p.x, startY: p.y, orig: { ...b.bbox } }; | |
| renderList(); redraw(); | |
| return; | |
| } | |
| } | |
| // empty area -> create manual box | |
| state.selectedId = null; | |
| const nb = { id: nextId++, class: 'manual', text: '', active: true, manual: true, bbox: { x: p.x, y: p.y, w: 0, h: 0 } }; | |
| state.boxes.push(nb); | |
| drag = { mode: 'create', id: nb.id, startX: p.x, startY: p.y }; | |
| renderList(); redraw(); | |
| }); | |
| canvas.addEventListener('pointermove', (ev) => { | |
| if (!drag) return; | |
| const p = toOrig(ev); | |
| const b = state.boxes.find((x) => x.id === drag.id); | |
| if (!b) return; | |
| const clampX = (v) => Math.max(0, Math.min(state.natW, v)); | |
| const clampY = (v) => Math.max(0, Math.min(state.natH, v)); | |
| if (drag.mode === 'create') { | |
| const x1 = clampX(Math.min(drag.startX, p.x)), y1 = clampY(Math.min(drag.startY, p.y)); | |
| const x2 = clampX(Math.max(drag.startX, p.x)), y2 = clampY(Math.max(drag.startY, p.y)); | |
| b.bbox = { x: x1, y: y1, w: x2 - x1, h: y2 - y1 }; | |
| } else if (drag.mode === 'move') { | |
| const dx = p.x - drag.startX, dy = p.y - drag.startY; | |
| b.bbox.x = clampX(drag.orig.x + dx); b.bbox.y = clampY(drag.orig.y + dy); | |
| b.bbox.x = Math.min(b.bbox.x, state.natW - b.bbox.w); | |
| b.bbox.y = Math.min(b.bbox.y, state.natH - b.bbox.h); | |
| } else if (drag.mode === 'resize') { | |
| b.bbox.w = Math.max(4, clampX(p.x) - b.bbox.x); | |
| b.bbox.h = Math.max(4, clampY(p.y) - b.bbox.y); | |
| } | |
| redraw(); | |
| }); | |
| function endDrag(ev) { | |
| if (!drag) return; | |
| const b = state.boxes.find((x) => x.id === drag.id); | |
| if (drag.mode === 'create' && b && (b.bbox.w < 6 || b.bbox.h < 6)) { | |
| state.boxes = state.boxes.filter((x) => x.id !== b.id); // too small, discard | |
| state.selectedId = null; | |
| } else if (b) { | |
| state.selectedId = b.id; | |
| } | |
| drag = null; | |
| try { canvas.releasePointerCapture(ev.pointerId); } catch { /* noop */ } | |
| renderList(); redraw(); | |
| } | |
| canvas.addEventListener('pointerup', endDrag); | |
| canvas.addEventListener('pointercancel', endDrag); | |
| // --------------------------------------------------------------------------- | |
| // Download (full resolution) | |
| // --------------------------------------------------------------------------- | |
| function renderRedactedCanvas() { | |
| const out = document.createElement('canvas'); | |
| out.width = state.natW; out.height = state.natH; | |
| const octx = out.getContext('2d'); | |
| octx.drawImage(state.img, 0, 0); | |
| for (const b of state.boxes) { | |
| if (b.active) applyRedaction(octx, state.natCanvas, b.bbox, 1, state.style); | |
| } | |
| return out; | |
| } | |
| function download() { | |
| if (!state.img) return; | |
| const out = renderRedactedCanvas(); | |
| out.toBlob((blob) => { | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; a.download = 'dasumae-redacted.png'; | |
| document.body.appendChild(a); a.click(); a.remove(); | |
| setTimeout(() => URL.revokeObjectURL(url), 2000); | |
| }, 'image/png'); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Style selector | |
| // --------------------------------------------------------------------------- | |
| function updateStyleButtons() { | |
| for (const btn of styleSel.querySelectorAll('.style-btn')) { | |
| btn.classList.toggle('active', btn.dataset.style === state.style); | |
| } | |
| } | |
| styleSel.addEventListener('click', (ev) => { | |
| const btn = ev.target.closest('.style-btn'); | |
| if (!btn) return; | |
| if (!STYLES.includes(btn.dataset.style)) return; | |
| state.style = btn.dataset.style; | |
| updateStyleButtons(); redraw(); | |
| }); | |
| // --------------------------------------------------------------------------- | |
| // Reset / inputs | |
| // --------------------------------------------------------------------------- | |
| function reset() { | |
| state.seq++; | |
| state.img = null; state.natCanvas = null; state.boxes = []; state.selectedId = null; | |
| editor.hidden = true; dropzone.hidden = false; | |
| fileInput.value = ''; | |
| setStatus(''); | |
| if (window.__dasumae) { window.__dasumae.done = false; window.__dasumae.processing = false; } | |
| } | |
| dropzone.addEventListener('click', () => fileInput.click()); | |
| dropzone.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fileInput.click(); } }); | |
| fileInput.addEventListener('change', () => { if (fileInput.files && fileInput.files[0]) loadFromBlob(fileInput.files[0]); }); | |
| ['dragenter', 'dragover'].forEach((t) => dropzone.addEventListener(t, (e) => { e.preventDefault(); dropzone.classList.add('drag'); })); | |
| ['dragleave', 'drop'].forEach((t) => dropzone.addEventListener(t, (e) => { e.preventDefault(); dropzone.classList.remove('drag'); })); | |
| dropzone.addEventListener('drop', (e) => { | |
| const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0]; | |
| if (f && /^image\//.test(f.type)) loadFromBlob(f); | |
| }); | |
| window.addEventListener('paste', (e) => { | |
| const items = e.clipboardData && e.clipboardData.items; | |
| if (!items) return; | |
| for (const it of items) { | |
| if (it.type && it.type.startsWith('image/')) { const f = it.getAsFile(); if (f) { loadFromBlob(f); break; } } | |
| } | |
| }); | |
| downloadBtn.addEventListener('click', download); | |
| resetBtn.addEventListener('click', reset); | |
| faceToggle.addEventListener('change', () => { if (!faceToggle.disabled) state.faceEnabled = faceToggle.checked; }); | |
| langToggle.addEventListener('click', () => { state.lang = state.lang === 'ja' ? 'en' : 'ja'; persistLang(state.lang); applyI18n(); }); | |
| window.addEventListener('resize', () => { if (state.img) { layout(); redraw(); } }); | |
| // --------------------------------------------------------------------------- | |
| // Test hook for the Playwright harness (Task 3). No effect on normal use. | |
| // --------------------------------------------------------------------------- | |
| window.__dasumae = { | |
| ready: false, processing: false, done: false, faceOk: null, | |
| getBoxes: () => state.boxes.map((b) => ({ class: b.class, text: b.text, bbox: { ...b.bbox }, active: b.active, manual: b.manual, score: b.score })), | |
| setStyle: (st) => { if (STYLES.includes(st)) { state.style = st; updateStyleButtons(); redraw(); } }, | |
| setFaceEnabled: (v) => { if (!faceToggle.disabled) { state.faceEnabled = !!v; faceToggle.checked = !!v; } }, | |
| renderPNGDataURL: () => renderRedactedCanvas().toDataURL('image/png'), | |
| loadBlob: (blob) => loadFromBlob(blob), | |
| reset, | |
| }; | |
| // --------------------------------------------------------------------------- | |
| // Init | |
| // --------------------------------------------------------------------------- | |
| state.style = 'solid'; | |
| applyI18n(); | |
| updateStyleButtons(); | |
| window.__dasumae.ready = true; | |