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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user