"""Gradio Space: prune adaln_proj layers out of H3 LoRAs.
Flow: point it at a LoRA, the app reads just the file header (a couple of range
requests, even for multi-GB files) and shows exactly what it would remove, then
one button prunes it and hands back a download.
See h3_prune.py for the actual surgery.
"""
import hashlib
import html
import os
import shutil
import tempfile
import time
import gradio as gr
import h3_prune as H
WORK_ROOT = os.path.join(tempfile.gettempdir(), "h3_pruner")
MAX_OUTPUT_AGE = 60 * 60 # keep finished results around for an hour
CSS = """
/* flat, square, 1px borders — everything lives in a bordered panel */
:root { --h3-lit: #fb923c; --h3-mid: #f97316; --h3-deep: #ea580c; }
/* Spaces sizes its iframe from the height the app reports, inside a shell that
does not itself scroll. So the document must stay pinned to the frame and
scroll internally — if it is allowed to grow to its content height the frame
grows with it and everything past the fold becomes unreachable. */
html, body { height: 100% !important; max-height: 100% !important; }
body { overflow-y: auto !important; }
.gradio-container { overflow-y: visible !important; max-height: none !important; }
/* without an explicit width the container shrink-wraps its content, so a short
status line collapses the whole page and a long filename snaps it back out */
.gradio-container { width: 100% !important; max-width: 820px !important; margin: 0 auto !important; }
.gradio-container .h3panel, .gradio-container .h3body, .gradio-container .gr-accordion,
.gradio-container .tabs, .gradio-container .tabitem { width: 100% !important; }
.card, .stats { width: 100%; box-sizing: border-box; }
.gradio-container button, .gradio-container input, .gradio-container textarea,
.gradio-container .block, .gradio-container .form, .gradio-container select { border-radius: 0 !important; }
/* --- panels ---------------------------------------------------------- */
.h3panel { border: 1px solid var(--border-color-primary, #e4e4e7) !important;
background: var(--background-fill-primary, #fff) !important;
padding: 0 !important; gap: 0 !important; margin-bottom: 16px !important; }
.h3head { padding: 13px 16px !important; margin: 0 !important;
border-bottom: 1px solid var(--border-color-primary, #e4e4e7);
background: var(--background-fill-secondary, #fafafa); }
.h3head .h { display: flex; align-items: baseline; gap: 10px;
font-weight: 600; font-size: .95rem; letter-spacing: -0.01em;
color: var(--body-text-color, #09090b); }
.h3head .n { flex: none; width: 20px; height: 20px; font-size: .72rem; font-weight: 600;
display: inline-flex; align-items: center; justify-content: center;
color: #fff; background: var(--h3-mid); align-self: center; }
.h3head .hint { font-weight: 400; font-size: .8rem;
color: var(--body-text-color-subdued, #71717a); }
.h3body { padding: 16px !important; gap: 12px !important; }
/* gradio stamps elem_classes on both the block and an inner div, so the header
would otherwise get its padding and bottom border twice */
.h3head .h3head { padding: 0 !important; border: none !important; background: none !important; }
/* --- hero ------------------------------------------------------------ */
#hero { padding: 18px 16px; text-align: center;
border: 1px solid var(--border-color-primary, #e4e4e7); margin-bottom: 16px; }
#hero .title { font-size: 1.6rem; font-weight: 650; letter-spacing: -0.03em; margin: 0;
color: var(--body-text-color, #09090b); }
#hero .grad { color: var(--h3-deep); }
#hero .sub { color: var(--body-text-color-subdued, #71717a); margin: 7px 0 0; font-size: .88rem; }
#hero code, .card code { background: var(--background-fill-secondary, #f4f4f5); padding: 1px 5px;
border: 1px solid var(--border-color-primary, #e4e4e7); font-size: .85em; }
/* --- state cards ----------------------------------------------------- */
.card { border: 1px solid var(--border-color-primary, #e4e4e7); border-left-width: 3px;
padding: 13px 15px; background: var(--background-fill-secondary, #fafafa); }
.card.idle { border: 1px dashed var(--border-color-primary, #e4e4e7); background: transparent;
color: var(--body-text-color-subdued, #71717a); text-align: center;
padding: 20px 15px; font-size: .9rem; }
/* amber would read as the brand colour now, so "nothing to do" goes neutral and
only real failures get a hot colour */
.card.ok { border-left-color: var(--h3-mid); }
.card.warn { border-left-color: #a1a1aa; }
.card.err { border-left-color: #dc2626; }
.card.busy { border-left-color: var(--h3-lit); display: flex; align-items: center; gap: 12px; }
.card .name { font-weight: 600; word-break: break-all; color: var(--body-text-color, #09090b);
font-size: .92rem; }
.card .msg { color: var(--body-text-color, #09090b); font-size: .92rem; }
.card .msg b { font-weight: 600; }
.card .note { margin-top: 5px; font-size: .83rem; color: var(--body-text-color-subdued, #71717a); }
.spinner { flex: none; width: 16px; height: 16px; border-radius: 50% !important;
border: 2px solid var(--border-color-primary, #e4e4e7); border-top-color: var(--h3-mid);
animation: h3spin .7s linear infinite; }
@keyframes h3spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .spinner { animation-duration: 2.2s; } }
/* --- stat row -------------------------------------------------------- */
.stats { display: flex; flex-wrap: wrap; gap: 10px 24px; margin-top: 11px; }
.stat b { display: block; font-size: 1.05rem; font-weight: 600;
color: var(--body-text-color, #09090b); font-variant-numeric: tabular-nums; }
.stat span { font-size: .69rem; letter-spacing: .06em; text-transform: uppercase;
color: var(--body-text-color-subdued, #71717a); }
.stat.hl b { color: var(--h3-deep); }
.stat .arrow { color: var(--body-text-color-subdued, #71717a); font-weight: 400; margin: 0 3px; }
/* --- buttons --------------------------------------------------------- */
#go { font-weight: 550 !important; }
/* inverted ink so the payoff reads differently from the action above it:
dark-on-light in light mode, light-on-dark in dark */
#dl { background: var(--body-text-color, #09090b) !important;
color: var(--body-background-fill, #fff) !important;
border: 1px solid var(--body-text-color, #09090b) !important;
font-weight: 550 !important; margin-top: 12px; }
#dl:hover { opacity: .88; }
/* --- accordions as boxes --------------------------------------------- */
.gradio-container .gr-accordion {
border: 1px solid var(--border-color-primary, #e4e4e7) !important;
background: var(--background-fill-primary, #fff) !important;
margin-bottom: 16px !important; }
.gradio-container .gr-accordion .label-wrap { font-weight: 500; }
/* gradio fills form wrappers with the secondary tone, which reads as a grey
slab sitting inside the panel */
.gradio-container .form { background: transparent !important; border: none !important; }
/* flattening the blocks took the inputs' boxes with it; put them back */
.gradio-container textarea,
.gradio-container input[type="text"],
.gradio-container input[type="password"] {
border: 1px solid var(--border-color-primary, #e4e4e7) !important;
background: var(--background-fill-primary, #fff) !important;
padding: 8px 10px !important; }
.gradio-container textarea:focus,
.gradio-container input[type="text"]:focus,
.gradio-container input[type="password"]:focus {
border-color: var(--h3-mid) !important; outline: none !important; }
/* the dropdown's own input is .border-none — its box comes from the wrapper */
.h3drop .wrap { border: 1px solid var(--border-color-primary, #e4e4e7) !important;
background: var(--background-fill-primary, #fff) !important; }
.h3drop input { border: none !important; padding: 8px 10px !important; }
.h3drop .wrap:focus-within { border-color: var(--h3-mid) !important; }
footer { display: none !important; }
"""
IDLE = '
Paste a Hugging Face path above and press Enter to inspect it.
'
IDLE_UPLOAD = 'Drop a .safetensors LoRA above to inspect it.
'
# (repo, revision, filename, token-hash) -> (header, size), so retyping a pattern
# or a stray blur event never re-fetches anything.
_peek_cache = {}
_list_cache = {}
def _tokey(token):
return hashlib.sha256((token or "").encode()).hexdigest()[:16] if token else ""
def _bounded(cache):
if len(cache) > 32:
cache.clear()
return cache
def _card(kind, inner):
return f'{inner}
'
def _head(n, title, hint=""):
hint = f'{hint}' if hint else ""
return f'{n} {title} {hint}
'
def _error(msg):
return _card("err", f'❌ {html.escape(str(msg))}
')
def _busy(msg, note=""):
note = f'{html.escape(note)}
' if note else ""
return _card("busy", f'{html.escape(msg)}'
f'
{note}
')
def _stat(value, label, hl=False):
return (f'{value}{html.escape(label)}
')
def _workdir():
"""A fresh output directory, sweeping stale ones on the way in."""
os.makedirs(WORK_ROOT, exist_ok=True)
cutoff = time.time() - MAX_OUTPUT_AGE
for name in os.listdir(WORK_ROOT):
path = os.path.join(WORK_ROOT, name)
try:
if os.path.getmtime(path) < cutoff:
shutil.rmtree(path, ignore_errors=True)
except OSError:
pass
return tempfile.mkdtemp(dir=WORK_ROOT)
def _list_files(repo_id, revision, token):
key = (repo_id, revision, _tokey(token))
if key not in _list_cache:
_bounded(_list_cache)[key] = H.list_safetensors(repo_id, revision, token)
return _list_cache[key]
def _peek(repo_id, revision, filename, token):
key = (repo_id, revision, filename, _tokey(token))
if key not in _peek_cache:
_bounded(_peek_cache)[key] = H.remote_header(repo_id, filename, revision, token)
return _peek_cache[key]
def inspect(mode, ref, choice, upload, pattern, use_regex, token):
"""Look at the chosen LoRA and describe what pruning it would do.
Runs on every change to the source or the options. Returns updates for the
file dropdown, the preview card, and the hidden result widgets (a new
inspection invalidates any previous run).
"""
pattern = (pattern or "").strip() or H.DEFAULT_PATTERN
token = (token or "").strip() or os.environ.get("HF_TOKEN") or None
hide = (gr.update(visible=False), gr.update(visible=False), gr.update(visible=False))
keep_dropdown = gr.update()
try:
if mode == "upload":
if not upload:
return keep_dropdown, IDLE_UPLOAD, *hide
path = upload if isinstance(upload, str) else upload.name
name = os.path.basename(path)
header, _ = H.read_header(path)
summary = H.summarize(header, os.path.getsize(path), pattern, use_regex)
else:
if not (ref or "").strip():
return gr.update(choices=[], value=None, visible=False), IDLE, *hide
repo_id, revision, filename = H.parse_hf_ref(ref)
files = _list_files(repo_id, revision, token)
if not files:
return (gr.update(choices=[], value=None, visible=False),
_error(f"no .safetensors files in {repo_id}"), *hide)
# A selection left over from another repo is meaningless here.
if choice not in files:
choice = filename if filename in files else (files[0] if len(files) == 1 else None)
multi = len(files) > 1
keep_dropdown = gr.update(choices=files, value=choice, visible=multi)
if choice is None:
return (keep_dropdown,
_card("warn", '%d LoRA files in this repo — '
'pick the one you want above.
' % len(files)), *hide)
name = os.path.basename(choice)
header, size = _peek(repo_id, revision, choice, token)
summary = H.summarize(header, size, pattern, use_regex)
except H.PruneError as exc:
return keep_dropdown, _error(exc), *hide
except Exception as exc:
return keep_dropdown, _error(f"{type(exc).__name__}: {exc}"), *hide
total = len(summary["drop"]) + len(summary["keep"])
label = html.escape(pattern)
if not summary["drop"]:
body = (f'{html.escape(name)}
'
f'Nothing to do — no {label} layers in this LoRA '
f'({total} layers, all kept). It should already load in H3.
')
return keep_dropdown, _card("warn", body), *hide
stats = (
_stat(f"{len(summary['drop'])}", f"{pattern} layers to remove", hl=True)
+ _stat(f"{len(summary['keep'])} / {total}", "layers kept")
+ _stat(f"{H.human_size(summary['total_size'])} "
f'→ {H.human_size(summary["output_size"])}',
"size after")
)
body = f'{html.escape(name)}
{stats}
'
return keep_dropdown, _card("ok", body), *hide
def inspect_source(mode, ref, choice, upload, pattern, use_regex, token):
"""inspect(), but shows a spinner first.
Wired to the triggers that can hit the network (a new path, a different
file, a token). Option changes call inspect() directly instead — those are
answered from the cached header, and a spinner that flashes on every
keystroke just looks broken.
"""
empty = (not upload) if mode == "upload" else not (ref or "").strip()
if not empty:
hide = (gr.update(visible=False),) * 3
yield gr.update(), _busy("Reading the file header…"), *hide
yield inspect(mode, ref, choice, upload, pattern, use_regex, token)
def _resolve_source(mode, ref, choice, upload, token, progress):
"""Return (local_path, display_name) for whichever tab the user is on."""
if mode == "upload":
if not upload:
raise H.PruneError("drop a .safetensors file in first")
path = upload if isinstance(upload, str) else upload.name
if not path.endswith(".safetensors"):
raise H.PruneError("that is not a .safetensors file")
return path, os.path.basename(path)
repo_id, revision, filename = H.parse_hf_ref(ref)
filename = choice or filename
if not filename:
progress(0.05, desc=f"listing {repo_id}")
files = _list_files(repo_id, revision, token)
if not files:
raise H.PruneError(f"no .safetensors files in {repo_id}")
if len(files) > 1:
raise H.PruneError(
f"this repo has {len(files)} LoRA files — pick the one you want from the dropdown"
)
filename = files[0]
progress(0.1, desc=f"downloading {os.path.basename(filename)}")
local = H.download(repo_id, filename, revision, token)
return local, os.path.basename(filename)
def prune(mode, ref, choice, upload, pattern, use_regex, token, progress=gr.Progress()):
"""Resolve the source, strip the matching keys, hand back a download."""
pattern = (pattern or "").strip() or H.DEFAULT_PATTERN
token = (token or "").strip() or os.environ.get("HF_TOKEN") or None
# download button, removed-keys box, its accordion
hidden = (gr.update(visible=False), gr.update(value=""), gr.update(visible=False))
yield gr.update(value=_busy("Working on it…", "large LoRAs take a moment to download"),
visible=True), *hidden
try:
src, name = _resolve_source(mode, ref, choice, upload, token, progress)
progress(0.35, desc="reading header")
info = H.plan(src, pattern, use_regex)
if not info["drop"]:
card = _card("warn", f'Nothing to do — no {html.escape(pattern)}'
f' layers in {html.escape(name)}.
')
yield gr.update(value=card, visible=True), *hidden
return
out_path = os.path.join(_workdir(), H.default_output_name(name, pattern))
H.write_stripped(src, out_path, info, lambda f: progress(0.4 + 0.55 * f, desc="writing"))
progress(0.97, desc="verifying")
H.verify(out_path, info["keep"])
size = os.path.getsize(out_path)
except H.PruneError as exc:
yield gr.update(value=_error(exc), visible=True), *hidden
return
except Exception as exc:
yield gr.update(value=_error(f"pruning failed — {type(exc).__name__}: {exc}"),
visible=True), *hidden
return
meta = info["metadata"]
stats = (
_stat(f"{len(info['drop'])}", f"{pattern} layers removed", hl=True)
+ _stat(f"{len(info['keep'])}", "layers kept")
+ _stat(f"{H.human_size(info['input_size'])} "
f'→ {H.human_size(size)}', "size")
+ _stat(f"{len(meta)}" if meta else "—", "metadata entries kept")
)
body = (f'✅ Pruned {html.escape(os.path.basename(out_path))}
'
f'{stats}
'
f'Verified: the output re-opens cleanly and every kept layer is '
f'byte-identical to the source.
')
yield (
gr.update(value=_card("ok", body), visible=True),
gr.update(value=out_path, visible=True),
gr.update(value="\n".join(info["drop"])),
gr.update(visible=True),
)
with gr.Blocks(title="H3 LoRA Pruner") as demo:
gr.HTML(
'✂️ H3 LoRA Pruner
'
'
Strips the adaln_proj layers out of a LoRA so it loads in H3.
'
)
mode = gr.State("hub")
with gr.Column(elem_classes="h3panel"):
gr.HTML(_head(1, "Pick your LoRA"), elem_classes="h3head")
with gr.Column(elem_classes="h3body"):
with gr.Tabs():
with gr.Tab("From Hugging Face") as tab_hub:
ref = gr.Textbox(
label="Model path or link",
placeholder="owner/repo or "
"https://huggingface.co/owner/repo/blob/main/lora.safetensors",
info="Press Enter, or click away, to inspect it.",
autofocus=True,
)
choice = gr.Dropdown(label="Which file?", choices=[], visible=False,
interactive=True, elem_classes="h3drop")
with gr.Tab("Upload a file") as tab_upload:
upload = gr.File(label="LoRA (.safetensors)", file_types=[".safetensors"],
type="filepath")
with gr.Column(elem_classes="h3panel"):
gr.HTML(_head(2, "Review & prune"), elem_classes="h3head")
with gr.Column(elem_classes="h3body"):
preview = gr.HTML(IDLE)
go = gr.Button("Prune LoRA", variant="primary", size="lg", elem_id="go")
result = gr.HTML(visible=False)
download_btn = gr.DownloadButton("Download pruned LoRA", visible=False,
size="lg", elem_id="dl")
with gr.Accordion("Removed layers", open=False, visible=False) as removed_acc:
removed = gr.Textbox(show_label=False, lines=10, max_lines=14,
buttons=["copy"], interactive=False)
with gr.Accordion("Options", open=False):
with gr.Row():
pattern = gr.Textbox(
label="Remove layers whose name contains",
value=H.DEFAULT_PATTERN,
info="Leave this alone for H3.",
)
use_regex = gr.Checkbox(label="Treat as a regex", value=False)
token = gr.Textbox(
label="Hugging Face token",
type="password",
placeholder="hf_… — only for private or gated repos",
info="Used for this request only, never stored.",
)
with gr.Accordion("Help & notes", open=False):
gr.Markdown(
"**What goes in the path box** — anything you can copy off the hub:\n"
"`owner/repo`, `owner/repo@branch`, a full `.../blob/main/lora.safetensors` link, "
"or a path to a file in a subfolder. If the repo holds more than one LoRA a picker "
"appears so you can choose.\n\n"
"**Private or gated repos** — paste a read token from "
"[your HF settings](https://huggingface.co/settings/tokens) under **Options**. It is "
"used for that one request and never stored.\n\n"
"**What actually happens** — the safetensors header is rewritten and the surviving "
"tensors are copied across as raw bytes. Weights are never decoded, so dtypes "
"(bf16, fp8, …) come out exactly as they went in, and `__metadata__` is preserved. "
"Every output is re-opened and checked before you get it.\n\n"
"**Privacy** — pruned files live in this Space's temporary storage and are deleted "
"after an hour."
)
# --- wiring -----------------------------------------------------------
src_inputs = [mode, ref, choice, upload, pattern, use_regex, token]
src_outputs = [choice, preview, result, download_btn, removed_acc]
# Anything that changes what would be pruned re-runs the preview. Header
# reads are cached, so repeated/duplicate triggers cost nothing. Deliberately
# not on ref.change — that would hit the network on every keystroke.
# show_progress must stay "hidden": the dropdown is one of these outputs, and
# gradio's built-in loading state blanks an output's contents while the call
# runs — so picking a file would wipe its own label. The busy card covers it.
gr.on(
[ref.submit, ref.blur, choice.change, upload.change, token.blur],
inspect_source, src_inputs, src_outputs, show_progress="hidden",
)
# Answered from the cached header, so these repaint instantly and want no spinner.
gr.on([pattern.change, use_regex.change], inspect, src_inputs, src_outputs,
show_progress="hidden")
tab_hub.select(lambda: "hub", None, mode).then(inspect_source, src_inputs, src_outputs)
tab_upload.select(lambda: "upload", None, mode).then(inspect_source, src_inputs, src_outputs)
go.click(prune, src_inputs, [result, download_btn, removed, removed_acc])
# Flat, square, neutral surfaces with one orange accent. Base rather than Soft:
# Soft ships gradients, shadows and tinted labels, all of which fight this.
THEME = gr.themes.Base(
primary_hue="orange",
neutral_hue="zinc",
radius_size=gr.themes.sizes.radius_none,
font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui", "sans-serif"],
).set(
body_background_fill="#ffffff",
body_background_fill_dark="#09090b",
background_fill_primary="#ffffff",
background_fill_primary_dark="#09090b",
background_fill_secondary="#fafafa",
background_fill_secondary_dark="#131316",
border_color_primary="#e4e4e7",
border_color_primary_dark="#27272a",
# panels supply the boxes, so gradio's own blocks stay flat inside them
block_background_fill="transparent",
block_background_fill_dark="transparent",
block_border_width="0px",
block_shadow="none",
block_label_background_fill="transparent",
block_label_background_fill_dark="transparent",
block_label_text_color="#71717a",
block_label_text_color_dark="#a1a1aa",
block_label_text_weight="500",
block_title_text_color="#71717a",
block_title_text_color_dark="#a1a1aa",
input_background_fill="#ffffff",
input_background_fill_dark="#09090b",
input_border_color="#e4e4e7",
input_border_color_dark="#27272a",
input_shadow="none",
button_border_width="1px",
button_primary_background_fill="#f97316",
button_primary_background_fill_dark="#f97316",
button_primary_background_fill_hover="#ea580c",
button_primary_background_fill_hover_dark="#ea580c",
button_primary_border_color="#f97316",
button_primary_border_color_dark="#f97316",
button_primary_text_color="#ffffff",
button_primary_text_color_dark="#ffffff",
)
if __name__ == "__main__":
demo.queue(default_concurrency_limit=2, max_size=20).launch(theme=THEME, css=CSS)