studio: Dedupe — one-click removal of exact-duplicate images (recoverable, ⌘Z)

The catalog seeder emits byte-identical copies (found 94 dups in one org's 268
visible assets). POST /v1/library/dedup hashes visible assets and soft-deletes
exact-content duplicates — keeps one per cluster, distinct re-renders (different
seeds) untouched — and returns the moved paths. Frontend "Dedupe" button beside
Select; the whole sweep pushes ONE undo entry so ⌘Z restores it all. no-store on
the response. Verified in-browser: dedup hides dups, Deleted count updates, ⌘Z
restores. v0.17.7
This commit is contained in:
Hanzo AI
2026-07-17 16:19:13 -07:00
parent 9e4d098af2
commit 4ee5adcef9
3 changed files with 50 additions and 1 deletions
+13
View File
@@ -290,6 +290,7 @@ body.immersive #stage{display:block}
<button data-tab="deleted" onclick="tab('deleted')" title="Deleted content — recoverable">🗑 Deleted</button>
<span class="sp"></span>
<button class="btn" id="selbtn" onclick="toggleSel()" style="padding:6px 12px" title="Select many, then delete/recover them together">Select</button>
<button class="btn" onclick="dedup()" style="padding:6px 12px" title="Move exact-duplicate images to Deleted (recoverable, ⌘Z to undo)">Dedupe</button>
<input class="search" id="q" placeholder="Search…" oninput="render()">
</div>
<section id="assets">
@@ -906,6 +907,18 @@ async function quickDel(path){
if(!r.ok)throw 0;}catch(_){a.status=prev;UNDO.pop();render();paintChips();toast('Delete failed — restored');}
}
async function rerun(i){const a=DATA.assets[i];toast('Queueing re-run…');const r=await fetch('/v1/library/rerun',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:a.path})});const j=await r.json();toast(r.ok?`Re-run queued — see Runs`:'Failed');}
// Dedupe: server hashes visible assets, soft-deletes exact byte-identical copies
// (keeps one), returns the moved paths so ⌘Z undoes the whole sweep at once.
async function dedup(){
toast('Scanning for exact duplicates…');
let r; try{ r=await (await fetch('/v1/library/dedup',{method:'POST'})).json(); }catch(_){ toast('Dedup failed'); return; }
const moved=r.deleted||[];
if(!moved.length){ toast('No exact duplicates found'); return; }
pushUndo(moved.map(m=>({path:m.path,prev:m.prev})));
moved.forEach(m=>{const a=DATA.assets.find(x=>x.path===m.path); if(a)a.status='deleted';});
render(); paintChips();
toast('Removed '+moved.length+' duplicate'+(moved.length>1?'s':'')+' → Trash · ⌘Z to undo');
}
// ── Fix surface: base image + up to 4 references (history picks + drag/drop
// uploads). No references → the single-image edit (/v1/library/fix); any
// reference → the multi-input compose (/v1/library/compose) — same proven Qwen
+36
View File
@@ -1226,6 +1226,42 @@ def add_studio_home_routes(routes: web.RouteTableDef, server) -> None:
return web.json_response({"ok": True, "path": rel, "status": status})
return web.json_response({"error": "asset not in library"}, status=404)
@routes.post("/v1/library/dedup")
async def dedup(request: web.Request):
"""Soft-delete exact-content duplicates — keep one per byte-identical cluster,
move the rest to Deleted (recoverable, files kept). Content hash, so only TRUE
duplicates go; distinct re-renders (different seeds) are untouched. Returns the
moved paths so the UI can offer a one-shot undo."""
import hashlib
org = _org_of(request)
root = _library_root(org)
if root is None:
return web.json_response({"removed": 0, "deleted": []},
headers={"Cache-Control": "no-store"})
lib = _load_library(root)
seen: set[str] = set()
moved = []
for a in sorted(lib.get("assets", []), key=lambda x: x.get("path", "")):
if a.get("status") == "deleted":
continue
p = _safe_asset(root, a.get("path", ""))
if p is None:
continue
try:
h = hashlib.md5(p.read_bytes()).hexdigest()
except OSError:
continue
if h in seen:
moved.append({"path": a["path"], "prev": a.get("status", "draft")})
a["status"] = "deleted"
a["updatedAt"] = int(time.time())
else:
seen.add(h)
if moved:
_save_library(root, lib)
return web.json_response({"removed": len(moved), "deleted": moved},
headers={"Cache-Control": "no-store"})
@routes.post("/v1/library/tags")
async def add_tags(request: web.Request):
"""Merge tags onto a set of library assets (the bulk 'Add tags' action). Tags
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "hanzo-studio"
version = "0.17.6"
version = "0.17.7"
readme = "README.md"
license = { file = "LICENSE" }
requires-python = ">=3.10"