← back to Wallco Ai
cactus-curator: click-any-image → full-screen heal modal (drag-to-mark numbered red dotted boxes, ⚕ Heal calls Replicate SDXL inpaint with manual_boxes, ↺ Back pops history stack to revert, ✓ Accept promotes via /heal/approve, ✕ Close/Esc discards); by-base family cards add 🕓 created date pill; seam-debug also gains drag-to-add manual heal regions (M1, M2…) merged into heal endpoint via new manual_boxes payload; python normalizes scanner pixel-coords + accepts --manual fraction boxes
09b88ed6ff582c37e86e2b5636c18064eafa4b58 · 2026-05-28 07:20:21 -0700 · Steve Abrams
Files touched
M public/admin/cactus-curator.htmlM public/admin/seam-debug.htmlM scripts/heal-region-inpaint.pyM server.js
Diff
commit 09b88ed6ff582c37e86e2b5636c18064eafa4b58
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu May 28 07:20:21 2026 -0700
cactus-curator: click-any-image → full-screen heal modal (drag-to-mark numbered red dotted boxes, ⚕ Heal calls Replicate SDXL inpaint with manual_boxes, ↺ Back pops history stack to revert, ✓ Accept promotes via /heal/approve, ✕ Close/Esc discards); by-base family cards add 🕓 created date pill; seam-debug also gains drag-to-add manual heal regions (M1, M2…) merged into heal endpoint via new manual_boxes payload; python normalizes scanner pixel-coords + accepts --manual fraction boxes
---
public/admin/cactus-curator.html | 174 +++++++++++++++++++++++++++++++++++++++
public/admin/seam-debug.html | 57 ++++++++++++-
scripts/heal-region-inpaint.py | 24 ++++--
server.js | 22 +++--
4 files changed, 262 insertions(+), 15 deletions(-)
diff --git a/public/admin/cactus-curator.html b/public/admin/cactus-curator.html
index 6bf2807..0c0add9 100644
--- a/public/admin/cactus-curator.html
+++ b/public/admin/cactus-curator.html
@@ -848,6 +848,7 @@ load();
<span class="dot" style="background:${dot}"></span>
<span>${all.length} cw</span>
</div>
+ <div class="when" title="created ${fam.created_at||''}">🕓 ${(typeof fmtDate==='function' ? fmtDate(fam.created_at) : (fam.created_at||'').slice(0,16).replace('T',' '))}</div>
</div>
<div class="bbstrip" style="display:flex;gap:3px;overflow-x:auto;padding:0 8px 9px;scrollbar-width:thin">
${all.map(c => {
@@ -981,6 +982,179 @@ load();
if (localStorage.getItem(LS_KEY) === '1') setMode(true);
}).observe(document.body, { childList: true, subtree: true });
})();
+
+// ===== Click-to-Heal modal ===================================================
+// Click any card image → large lightbox; drag to mark heal regions (numbered);
+// ⚕ Heal → calls inpaint, pushes current onto history, swaps in the new image;
+// ↺ Back reverts to the previous version; ✓ Accept promotes the latest (via
+// /api/seam-debug/heal/approve, original gets user_removed=TRUE + logged); ✕
+// Close discards (heal-children remain as unpublished siblings, recoverable).
+(() => {
+ let modal = null, original = null, current = null, history = [], manual = [];
+ const adm = new URLSearchParams(location.search).get('admin');
+ const q = u => adm ? u + (u.includes('?') ? '&' : '?') + 'admin=' + encodeURIComponent(adm) : u;
+
+ function ensureModal() {
+ if (modal) return modal;
+ modal = document.createElement('div'); modal.id = 'healmodal';
+ modal.style.cssText = 'position:fixed;inset:0;z-index:300;background:rgba(8,9,11,.97);display:none;flex-direction:column;color:#eee;font:13px ui-monospace,monospace';
+ modal.innerHTML = `
+ <header style="display:flex;align-items:center;gap:10px;padding:10px 14px;border-bottom:1px solid #333">
+ <button id="hmback" style="background:#2a2a2a;color:#eee;border:1px solid #444;padding:7px 14px;font-weight:700;border-radius:8px;cursor:pointer" disabled>↺ Back</button>
+ <span id="hmtitle" style="flex:1;text-align:center;color:#aaa">—</span>
+ <span id="hmsel" style="color:#ffd0d0">0 regions</span>
+ <button id="hmheal" style="background:#3b2e08;color:#f0b400;border:1px solid #5a4710;padding:7px 16px;font-weight:800;border-radius:8px;cursor:pointer">⚕ Heal selected</button>
+ <button id="hmaccept" style="background:#2a6b35;color:#fff;border:0;padding:7px 16px;font-weight:800;border-radius:8px;cursor:pointer" disabled>✓ Accept</button>
+ <button id="hmclose" style="background:#3a2222;color:#ffdada;border:1px solid #663;padding:7px 14px;font-weight:700;border-radius:8px;cursor:pointer">✕ Close</button>
+ </header>
+ <main style="flex:1;display:flex;align-items:center;justify-content:center;overflow:hidden;padding:12px">
+ <div id="hmwrap" style="position:relative;background:#000;line-height:0">
+ <img id="hmimg" style="display:block;max-width:88vw;max-height:78vh;object-fit:contain;background:#fff;-webkit-user-drag:none" draggable="false">
+ <div id="hmovl" style="position:absolute;inset:0;cursor:crosshair"></div>
+ </div>
+ </main>
+ <footer id="hmfoot" style="padding:8px 14px;color:#888;border-top:1px solid #333;text-align:center;font-size:11px">drag on image to mark a heal region · click × to remove · ⚕ Heal → ↺ Back to revert or ✓ Accept to replace the original</footer>`;
+ document.body.appendChild(modal);
+ modal.querySelector('#hmclose').onclick = closeModal;
+ modal.querySelector('#hmback').onclick = back;
+ modal.querySelector('#hmheal').onclick = heal;
+ modal.querySelector('#hmaccept').onclick = accept;
+ wireDraw();
+ return modal;
+ }
+
+ function updTitle() {
+ modal.querySelector('#hmtitle').textContent =
+ `#${current} · original #${original} · history ${history.length}${history.length?' (Back to revert)':''}`;
+ modal.querySelector('#hmsel').textContent = `${manual.length} regions`;
+ modal.querySelector('#hmback').disabled = history.length === 0;
+ modal.querySelector('#hmaccept').disabled = current === original; // no heal applied yet
+ }
+ function drawBoxes() {
+ const ovl = modal.querySelector('#hmovl');
+ ovl.innerHTML = manual.map((b, i) =>
+ `<div data-idx="${i}" style="position:absolute;left:${b.x*100}%;top:${b.y*100}%;width:${b.w*100}%;height:${b.h*100}%;border:2px dashed #ff3b3b;background:rgba(255,59,59,.14);box-sizing:border-box;cursor:pointer">
+ <b style="position:absolute;top:-2px;left:-2px;background:#ff3b3b;color:#fff;font:800 11px sans-serif;padding:1px 6px;border-radius:3px 0 3px 0;min-width:18px;text-align:center">${i+1}</b>
+ <span class="rm" style="position:absolute;top:-9px;right:-9px;width:18px;height:18px;line-height:16px;text-align:center;background:#ff3b3b;color:#fff;border-radius:50%;font:800 12px sans-serif;cursor:pointer">×</span>
+ </div>`).join('');
+ ovl.querySelectorAll('.rm').forEach(x => x.onclick = e => { e.stopPropagation(); const i=+x.parentElement.dataset.idx; manual.splice(i,1); drawBoxes(); updTitle(); });
+ }
+ function wireDraw() {
+ const ovl = modal.querySelector('#hmovl');
+ ovl.addEventListener('mousedown', e => {
+ if (e.target.closest('div[data-idx]')) return;
+ const rect = ovl.getBoundingClientRect();
+ const x0 = (e.clientX - rect.left)/rect.width, y0 = (e.clientY - rect.top)/rect.height;
+ if (x0<0||x0>1||y0<0||y0>1) return; e.preventDefault();
+ const g = document.createElement('div');
+ g.style.cssText = 'position:absolute;border:2px dashed #ff3b3b;background:rgba(255,59,59,.22);pointer-events:none;box-sizing:border-box';
+ ovl.appendChild(g);
+ const mv = ev => {
+ const x=(ev.clientX-rect.left)/rect.width, y=(ev.clientY-rect.top)/rect.height;
+ const L=Math.min(x0,x),T=Math.min(y0,y),W=Math.abs(x-x0),H=Math.abs(y-y0);
+ g.style.cssText += `;left:${L*100}%;top:${T*100}%;width:${W*100}%;height:${H*100}%`;
+ };
+ const up = ev => {
+ document.removeEventListener('mousemove',mv); document.removeEventListener('mouseup',up);
+ const x=(ev.clientX-rect.left)/rect.width, y=(ev.clientY-rect.top)/rect.height;
+ const L=Math.max(0,Math.min(x0,x)), T=Math.max(0,Math.min(y0,y));
+ const W=Math.min(1-L,Math.abs(x-x0)), H=Math.min(1-T,Math.abs(y-y0));
+ g.remove();
+ if (W>0.01 && H>0.01) {
+ manual.push({x:+L.toFixed(4),y:+T.toFixed(4),w:+W.toFixed(4),h:+H.toFixed(4),label:'manual-'+(manual.length+1)});
+ drawBoxes(); updTitle();
+ }
+ };
+ document.addEventListener('mousemove',mv); document.addEventListener('mouseup',up);
+ });
+ }
+
+ function showId(id) {
+ current = id;
+ modal.querySelector('#hmimg').src = `/designs/img/by-id/${id}` + (adm?'?admin='+encodeURIComponent(adm):'');
+ manual = []; drawBoxes(); updTitle();
+ }
+ function openModal(id) {
+ ensureModal();
+ original = id; current = id; history = []; manual = [];
+ modal.style.display = 'flex';
+ showId(id);
+ }
+ function closeModal() {
+ if (modal) modal.style.display = 'none';
+ original = current = null; history = []; manual = [];
+ }
+ async function heal() {
+ if (!manual.length) { flash('drag on image to mark a region first'); return; }
+ const btn = modal.querySelector('#hmheal'); const orig = btn.textContent;
+ btn.disabled = true; btn.textContent = `⚕ healing ${manual.length}…`;
+ const status = modal.querySelector('#hmfoot');
+ status.textContent = `inpainting ${manual.length} region(s) on #${current} via Replicate (~3-8s)…`;
+ try {
+ const r = await fetch(q(`/api/seam-debug/${current}/heal`), {
+ method:'POST', headers:{'Content-Type':'application/json'},
+ body: JSON.stringify({ box_ids: [], manual_boxes: manual }),
+ });
+ const j = await r.json();
+ if (!r.ok || !j.ok) throw new Error(j.error || ('HTTP ' + r.status));
+ history.push({ id: current, boxes: manual.slice() });
+ showId(j.new_id);
+ status.textContent = `✓ heal #${j.new_id} ready — ⚕ heal again to refine, ↺ Back to revert, or ✓ Accept to replace original #${original}`;
+ } catch (e) {
+ status.textContent = '✗ heal failed: ' + e.message;
+ } finally {
+ btn.disabled = false; btn.textContent = orig;
+ }
+ }
+ function back() {
+ const prev = history.pop(); if (!prev) return;
+ showId(prev.id);
+ modal.querySelector('#hmfoot').textContent = `reverted to #${prev.id} — draw new regions and ⚕ heal again`;
+ }
+ async function accept() {
+ if (current === original) { flash('no heal applied yet'); return; }
+ const status = modal.querySelector('#hmfoot');
+ status.textContent = `approving #${current} as the replacement for #${original}…`;
+ try {
+ const r = await fetch(q('/api/seam-debug/heal/approve'), {
+ method:'POST', headers:{'Content-Type':'application/json'},
+ body: JSON.stringify({ src_id: original, heal_id: current }),
+ });
+ const j = await r.json();
+ if (!r.ok || !j.ok) throw new Error(j.error || ('HTTP ' + r.status));
+ status.textContent = `✓ accepted — #${original} marked removed, #${current} ${j.promoted_to_published?'now live on storefront':'kept unpublished'}. reloading page…`;
+ setTimeout(() => location.reload(), 700);
+ } catch (e) { status.textContent = '✗ accept failed: ' + e.message; }
+ }
+ function flash(msg) {
+ const f = modal.querySelector('#hmfoot'); const orig = f.textContent;
+ f.textContent = msg; f.style.color = '#ffd0d0';
+ setTimeout(() => { f.textContent = orig; f.style.color = '#888'; }, 2000);
+ }
+
+ // Intercept clicks on any card image in the catalog (flat grid or by-base
+ // layout). Anchor wrappers go to /admin/seam-debug — we open the modal here
+ // instead and let them shift-click for the new-tab nav.
+ addEventListener('click', e => {
+ if (e.shiftKey || e.metaKey || e.ctrlKey) return; // power users open in tab
+ const img = e.target.closest('.card img, .card.bbfam img, .bbstrip img');
+ if (!img) return;
+ const id = (() => {
+ const m = img.closest('a[href*="/admin/seam-debug/"]');
+ if (m) { const x = m.getAttribute('href').match(/\/(\d+)/); if (x) return parseInt(x[1], 10); }
+ const c = img.closest('[data-id],[data-base]');
+ return c ? parseInt(c.dataset.id || c.dataset.base, 10) : null;
+ })();
+ if (!Number.isFinite(id)) return;
+ e.preventDefault(); e.stopPropagation();
+ openModal(id);
+ }, true);
+ addEventListener('keydown', e => {
+ if (!modal || modal.style.display !== 'flex') return;
+ if (e.key === 'Escape') closeModal();
+ else if (e.key === 'Backspace' && history.length) back();
+ });
+})();
</script>
</body>
</html>
diff --git a/public/admin/seam-debug.html b/public/admin/seam-debug.html
index 36180f5..7e5f510 100644
--- a/public/admin/seam-debug.html
+++ b/public/admin/seam-debug.html
@@ -139,7 +139,54 @@ function redraw(){
ovl.appendChild(d);
});
}
+ // Manual user-drawn boxes — always rendered (independent of t_box toggle) so a
+ // hand-marked region is always visible while the user is curating it.
+ MANUAL.forEach((m,i)=>{
+ const d=document.createElement('div'); d.className='box manual';
+ d.style.cssText=`left:${m.x*iw}px;top:${m.y*ih}px;width:${m.w*iw}px;height:${m.h*ih}px;border:2px dashed #ff3b3b;background:rgba(255,59,59,.14);position:absolute;box-sizing:border-box;cursor:pointer;z-index:5`;
+ d.innerHTML=`<span class="lbl" style="background:#ff3b3b;color:#fff;font:800 10px sans-serif;padding:1px 6px;border-radius:3px 0 3px 0;position:absolute;top:-2px;left:-2px">M${i+1}</span>
+ <span class="x" style="position:absolute;top:-9px;right:-9px;width:17px;height:17px;line-height:15px;text-align:center;background:#ff3b3b;color:#fff;border-radius:50%;font:800 12px sans-serif">×</span>`;
+ d.title=`manual mark M${i+1} — click × to remove · always healed`;
+ d.querySelector('.x').onclick=ev=>{ ev.stopPropagation(); MANUAL.splice(i,1); redraw(); updHeal(); };
+ ovl.appendChild(d);
+ });
}
+// Drag-to-draw a manual heal region on the image. Hand-marked boxes are added
+// to MANUAL[] as fractions and always sent to the heal endpoint (don't need to
+// be in SEL — they're a separate channel).
+(function wireManualDraw(){
+ const ovl=$('ovl');
+ ovl.addEventListener('mousedown', e => {
+ if(!CUR) return;
+ if(e.button!==0) return;
+ if(e.target.closest('.box')) return; // clicking an existing box = toggle, not draw
+ const rect=ovl.getBoundingClientRect();
+ const x0=(e.clientX-rect.left)/rect.width, y0=(e.clientY-rect.top)/rect.height;
+ if(x0<0||x0>1||y0<0||y0>1) return;
+ e.preventDefault();
+ const ghost=document.createElement('div');
+ ghost.style.cssText='position:absolute;border:2px dashed #ff3b3b;background:rgba(255,59,59,.22);pointer-events:none;box-sizing:border-box;z-index:6';
+ ovl.appendChild(ghost);
+ const W=rect.width, H=rect.height;
+ const mv=ev=>{
+ const x=(ev.clientX-rect.left)/rect.width, y=(ev.clientY-rect.top)/rect.height;
+ const L=Math.min(x0,x),T=Math.min(y0,y),WW=Math.abs(x-x0),HH=Math.abs(y-y0);
+ ghost.style.cssText+=`;left:${L*W}px;top:${T*H}px;width:${WW*W}px;height:${HH*H}px`;
+ };
+ const up=ev=>{
+ document.removeEventListener('mousemove',mv); document.removeEventListener('mouseup',up);
+ const x=(ev.clientX-rect.left)/rect.width, y=(ev.clientY-rect.top)/rect.height;
+ const L=Math.max(0,Math.min(x0,x)),T=Math.max(0,Math.min(y0,y));
+ const WW=Math.min(1-L,Math.abs(x-x0)),HH=Math.min(1-T,Math.abs(y-y0));
+ ghost.remove();
+ if(WW>0.01 && HH>0.01){
+ MANUAL.push({x:+L.toFixed(4),y:+T.toFixed(4),w:+WW.toFixed(4),h:+HH.toFixed(4),label:'manual-'+(MANUAL.length+1)});
+ redraw(); updHeal();
+ }
+ };
+ document.addEventListener('mousemove',mv); document.addEventListener('mouseup',up);
+ });
+})();
async function load(){
$('err').style.display='none';
@@ -168,15 +215,17 @@ async function load(){
}catch(e){ $('err').style.display='block'; $('err').textContent='Failed: '+e.message; }
}
async function healSelected(boxIds){
- if(!boxIds.length){ alert('Click boxes to select what to heal.'); return; }
+ const manual = MANUAL.slice();
+ if(!boxIds.length && !manual.length){ alert('Click auto-detected boxes OR drag on the image to mark a region, then heal.'); return; }
const id=$('idin').value.trim(); if(!id) return;
- const btn=$('healbtn'); const orig=btn.textContent; btn.disabled=true; btn.textContent=`⚕ healing ${boxIds.length}…`;
+ const total = boxIds.length + manual.length;
+ const btn=$('healbtn'); const orig=btn.textContent; btn.disabled=true; btn.textContent=`⚕ healing ${total}…`;
try{
- const r=await fetch(q(`/api/seam-debug/${id}/heal`),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({box_ids:boxIds})});
+ const r=await fetch(q(`/api/seam-debug/${id}/heal`),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({box_ids:boxIds, manual_boxes:manual})});
const j=await r.json();
if(!r.ok||!j.ok){ alert('Heal failed: '+(j.error||r.status)+(j.stderr?'\n\n'+j.stderr:'')); btn.textContent=orig; updHeal(); return; }
showApproveModal({ kind: 'heal', src_id: id, new_id: j.new_id, scan_before: j.scan_before, scan_after: j.scan_after });
- SEL.clear(); redraw(); updHeal();
+ SEL.clear(); MANUAL = []; redraw(); updHeal();
}catch(e){ alert('Heal failed: '+e.message); btn.textContent=orig; updHeal(); }
}
// === Preview/Approve modal ============================================
diff --git a/scripts/heal-region-inpaint.py b/scripts/heal-region-inpaint.py
index 8b1e451..783003a 100644
--- a/scripts/heal-region-inpaint.py
+++ b/scripts/heal-region-inpaint.py
@@ -135,6 +135,7 @@ def main():
ap = argparse.ArgumentParser()
ap.add_argument('--id', type=int, required=True)
ap.add_argument('--boxes', required=True, help='JSON array of box INDICES into the scanner output')
+ ap.add_argument('--manual', default='[]', help='JSON array of {x,y,w,h,label} in fractions (user-drawn)')
args = ap.parse_args()
src = fetch_design(args.id)
@@ -145,14 +146,27 @@ def main():
# scan once to enumerate defect boxes, then select the requested indices
scan_before = scan(args.id)
all_boxes = scan_before.get('boxes', []) if isinstance(scan_before, dict) else []
+ sw = scan_before.get('width') if isinstance(scan_before, dict) else None
+ sh = scan_before.get('height') if isinstance(scan_before, dict) else None
sel_ix = set(json.loads(args.boxes))
- boxes = [b for i, b in enumerate(all_boxes) if i in sel_ix]
- if not boxes:
- print(json.dumps({'ok': False, 'error': 'no boxes matched the requested indices', 'available': len(all_boxes)}))
- sys.exit(1)
-
src_img = Image.open(src['local_path']).convert('RGB')
W, H = src_img.size
+ # Scanner emits PIXEL coords (x,y,w,h relative to scan_before.width/height).
+ # Manual user-drawn boxes come in as fractions (0..1). Normalize scanner
+ # boxes to fractions so build_mask (fraction-based) works for both, then
+ # merge into one list.
+ def px_to_frac(b):
+ ww = sw or W; hh = sh or H
+ return {'x': b['x']/ww, 'y': b['y']/hh, 'w': b['w']/ww, 'h': b['h']/hh, 'label': b.get('kind','auto')}
+ scanner_sel = [px_to_frac(b) for i, b in enumerate(all_boxes) if i in sel_ix]
+ manual_sel = []
+ try: manual_sel = json.loads(args.manual)
+ except Exception: pass
+ boxes = scanner_sel + manual_sel
+ if not boxes:
+ print(json.dumps({'ok': False, 'error': 'no boxes selected (scanner indices + manual both empty)',
+ 'available_scanner': len(all_boxes)}))
+ sys.exit(1)
mask = build_mask(W, H, boxes)
# persist the mask alongside for debugging / approval review
diff --git a/server.js b/server.js
index 1831754..85cc1c5 100644
--- a/server.js
+++ b/server.js
@@ -1280,17 +1280,27 @@ app.get('/admin/etsy-bucket', (req, res) => {
// mirrored to the wrap-opposite edge so the result stays tileable. Saves the
// heal as a NEW design (parent_design_id=src) so the original survives until
// the curator hits Approve.
-app.post('/api/seam-debug/:id/heal', express.json({ limit: '16kb' }), (req, res) => {
+app.post('/api/seam-debug/:id/heal', express.json({ limit: '32kb' }), (req, res) => {
if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
const id = parseInt(req.params.id, 10);
const boxIds = Array.isArray(req.body && req.body.box_ids) ? req.body.box_ids.map(n => parseInt(n, 10)).filter(Number.isFinite) : [];
+ // Manual user-drawn boxes — fractions 0..1 (x,y,w,h) + optional label. These
+ // are healed alongside the scanner-detected boxes; the curator draws them on
+ // any region the scanner missed (subtle tone-on-tone defects, structural
+ // breaks that don't trip the ΔE detector).
+ const manualRaw = Array.isArray(req.body && req.body.manual_boxes) ? req.body.manual_boxes : [];
+ const clamp01 = n => Math.max(0, Math.min(1, Number(n) || 0));
+ const manualBoxes = manualRaw.slice(0, 32).map(b => ({
+ x: clamp01(b.x), y: clamp01(b.y), w: clamp01(b.w), h: clamp01(b.h),
+ label: String(b.label || 'manual').slice(0, 40),
+ })).filter(b => b.w > 0.005 && b.h > 0.005);
if (!Number.isFinite(id)) return res.status(400).json({ error: 'bad id' });
- if (!boxIds.length) return res.status(400).json({ error: 'no boxes selected' });
+ if (!boxIds.length && !manualBoxes.length) return res.status(400).json({ error: 'no boxes selected' });
try {
- const out = _execFileSyncSeam('python3',
- [path.join(__dirname, 'scripts', 'heal-region-inpaint.py'),
- '--id', String(id), '--boxes', JSON.stringify(boxIds)],
- { encoding: 'utf8', timeout: 180000, maxBuffer: 4 * 1024 * 1024 });
+ const args = [path.join(__dirname, 'scripts', 'heal-region-inpaint.py'),
+ '--id', String(id), '--boxes', JSON.stringify(boxIds)];
+ if (manualBoxes.length) { args.push('--manual', JSON.stringify(manualBoxes)); }
+ const out = _execFileSyncSeam('python3', args, { encoding: 'utf8', timeout: 180000, maxBuffer: 4 * 1024 * 1024 });
res.json(JSON.parse(out));
} catch (e) {
res.status(500).json({ error: e.message, stderr: (e.stderr || '').toString().slice(0, 500) });
← ade5237 cactus-curator by-base: full selection + action parity with
·
back to Wallco Ai
·
etsy activation wizard: /admin/etsy-setup — single-page stat 02f7eab →