Studio diffusion: image workflows (safetensors, image-conditioned, editing) + Images UI
Backend: - Load non-GGUF safetensors models: full bnb-4bit pipelines and single-file fp8 transformers, gated to the unsloth org plus a curated allowlist. - Image-conditioned workflows built with Pipeline.from_pipe so they reuse the loaded transformer/VAE/text-encoder with no extra VRAM: img2img, inpaint, outpaint, and a hires-fix upscale pass. - Instruction editing as its own family kind (Qwen-Image-Edit-2511, FLUX.1-Kontext-dev) and FLUX.2-klein reference conditioning (single and multi-reference) plus klein inpaint. - Auto-resize odd-sized inputs to a multiple of 16 (and resize the matched mask) so img2img/inpaint/edit no longer reject non-/16 uploads. Bound the decoded image size and cap upscale output to avoid OOM on large inputs. - Fixes: from_pipe defaulting to a float32 recast that crashed torchao quantized transformers; image-conditioned calls forcing the slider size onto the input image. Native sd.cpp engine rejects image-conditioned and reference requests it cannot serve. Frontend: - Redesigned Images page with capability-gated workflow tabs (Create, Transform, Inpaint, Extend, Upscale, Reference, Edit), a brush mask editor, client-side outpaint, and a multi-reference picker. - Advanced options moved to a right-docked panel mirroring Chat: closed by default, toggled by a single fixed top-bar button that stays in place. sd.cpp installer: pin the release, verify each download's sha256, add a download timeout, and make the source repo configurable for a future mirror.
This commit is contained in:
parent
f24384b4e9
commit
b14e2f9be7
14 changed files with 2920 additions and 183 deletions
|
|
@ -25,18 +25,40 @@ Usage:
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import stat
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Optional, Sequence
|
||||
|
||||
REPO = "leejet/stable-diffusion.cpp"
|
||||
RELEASES_API = f"https://api.github.com/repos/{REPO}/releases/latest"
|
||||
# Default upstream source. Overridable with UNSLOTH_SD_CPP_REPO so a pinned unslothai
|
||||
# mirror (built the same way as unslothai/llama.cpp's prebuilts) can be used without a
|
||||
# code change once it exists; otherwise this falls back to leejet upstream.
|
||||
DEFAULT_REPO = "leejet/stable-diffusion.cpp"
|
||||
# Pinned release tag for REPRODUCIBILITY: "releases/latest" silently swaps the binary
|
||||
# under users on every upstream push. Override with UNSLOTH_SD_CPP_TAG; set it empty to
|
||||
# track latest. If the pinned tag is gone upstream, install falls back to latest.
|
||||
DEFAULT_TAG = "master-737-3b6c9ca"
|
||||
|
||||
# Back-compat alias (some callers/tests import REPO).
|
||||
REPO = DEFAULT_REPO
|
||||
|
||||
|
||||
def _repo() -> str:
|
||||
return (os.environ.get("UNSLOTH_SD_CPP_REPO") or DEFAULT_REPO).strip() or DEFAULT_REPO
|
||||
|
||||
|
||||
def _pinned_tag() -> Optional[str]:
|
||||
"""The release tag to install: env override, else the pinned default; '' = latest."""
|
||||
val = os.environ.get("UNSLOTH_SD_CPP_TAG", DEFAULT_TAG).strip()
|
||||
return val or None
|
||||
|
||||
# accelerator -> the token that must appear in a Linux/Windows asset name.
|
||||
_LINUX_ACCEL_TOKEN = {"rocm": "rocm", "vulkan": "vulkan"}
|
||||
|
|
@ -108,14 +130,59 @@ def resolve_release_asset(
|
|||
return sel[0] if sel else None
|
||||
|
||||
|
||||
def _fetch_latest_release(*, token: Optional[str] = None, timeout: float = 30.0) -> dict:
|
||||
"""GET the latest-release JSON from GitHub (token optional, lifts rate limit)."""
|
||||
req = urllib.request.Request(RELEASES_API, headers = {"Accept": "application/vnd.github+json"})
|
||||
def _fetch_release(
|
||||
tag: Optional[str] = None, *, repo: Optional[str] = None,
|
||||
token: Optional[str] = None, timeout: float = 30.0,
|
||||
) -> dict:
|
||||
"""GET a release JSON from GitHub. With ``tag`` set, fetch that exact release (and fall
|
||||
back to latest if the tag is gone upstream); otherwise fetch latest. ``token`` is
|
||||
optional and lifts the API rate limit."""
|
||||
repo = repo or _repo()
|
||||
token = token or os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN")
|
||||
if token:
|
||||
req.add_header("Authorization", f"Bearer {token}")
|
||||
with urllib.request.urlopen(req, timeout = timeout) as resp: # noqa: S310 (fixed https host)
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
def _get(url: str) -> dict:
|
||||
req = urllib.request.Request(url, headers = {"Accept": "application/vnd.github+json"})
|
||||
if token:
|
||||
req.add_header("Authorization", f"Bearer {token}")
|
||||
with urllib.request.urlopen(req, timeout = timeout) as resp: # noqa: S310 (fixed https host)
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
base = f"https://api.github.com/repos/{repo}/releases"
|
||||
if tag:
|
||||
try:
|
||||
return _get(f"{base}/tags/{tag}")
|
||||
except urllib.error.HTTPError as exc: # pinned tag removed upstream -> latest
|
||||
if exc.code != 404:
|
||||
raise
|
||||
print(f"sd-cli: pinned tag {tag} not found on {repo}; falling back to latest", flush = True)
|
||||
return _get(f"{base}/latest")
|
||||
|
||||
|
||||
# Back-compat alias: the old name fetched latest.
|
||||
def _fetch_latest_release(*, token: Optional[str] = None, timeout: float = 30.0) -> dict:
|
||||
return _fetch_release(None, token = token, timeout = timeout)
|
||||
|
||||
|
||||
def _verify_sha256(path: Path, expected_digest: Optional[str]) -> None:
|
||||
"""Verify ``path`` against a GitHub asset ``digest`` ('sha256:<hex>'). Integrity check
|
||||
against a corrupted/tampered download before we extract + execute the binary. When the
|
||||
release publishes no digest (older releases), warn and proceed rather than hard-fail."""
|
||||
if not expected_digest:
|
||||
print(f"sd-cli: WARNING no digest for {path.name}; cannot verify integrity", flush = True)
|
||||
return
|
||||
algo, _, want = expected_digest.partition(":")
|
||||
if algo.lower() != "sha256" or not want:
|
||||
print(f"sd-cli: WARNING unrecognised digest {expected_digest!r}; skipping check", flush = True)
|
||||
return
|
||||
h = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(1 << 20), b""):
|
||||
h.update(chunk)
|
||||
got = h.hexdigest()
|
||||
if got != want.lower():
|
||||
raise RuntimeError(
|
||||
f"sha256 mismatch for {path.name}: expected {want.lower()}, got {got}"
|
||||
)
|
||||
|
||||
|
||||
def default_install_dir() -> Path:
|
||||
|
|
@ -132,6 +199,14 @@ def _make_executable(path: Path) -> None:
|
|||
path.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
|
||||
|
||||
def _download(url: str, dest: Path, *, timeout: float = 300.0) -> None:
|
||||
"""Stream a release asset to ``dest`` with a timeout. ``urlretrieve`` has no timeout,
|
||||
so a stalled connection would hang the lazy first-load (ensure_sd_cpp_binary) forever.
|
||||
Anonymous, matching the public release URL -- the API fetch carries any token."""
|
||||
with urllib.request.urlopen(url, timeout = timeout) as resp, open(dest, "wb") as f: # noqa: S310
|
||||
shutil.copyfileobj(resp, f)
|
||||
|
||||
|
||||
def _locate_sd_cli(root: Path) -> Optional[Path]:
|
||||
name = "sd-cli.exe" if sys.platform == "win32" else "sd-cli"
|
||||
for p in root.rglob(name):
|
||||
|
|
@ -152,7 +227,8 @@ def install(
|
|||
build from source) or the archive has no ``sd-cli``.
|
||||
"""
|
||||
target = install_dir or default_install_dir()
|
||||
release = _fetch_latest_release(token = token)
|
||||
release = _fetch_release(_pinned_tag(), token = token)
|
||||
print(f"sd-cli: source {_repo()} release {release.get('tag_name', '?')}", flush = True)
|
||||
names = [a["name"] for a in release.get("assets", [])]
|
||||
chosen = resolve_release_asset(
|
||||
names,
|
||||
|
|
@ -164,17 +240,24 @@ def install(
|
|||
raise RuntimeError(
|
||||
f"No prebuilt sd-cli for {platform.system()}/{platform.machine()} "
|
||||
f"(accelerator={accelerator}). Build from source: "
|
||||
f"https://github.com/{REPO}"
|
||||
f"https://github.com/{_repo()}"
|
||||
)
|
||||
url = next(a["browser_download_url"] for a in release["assets"] if a["name"] == chosen)
|
||||
asset = next(a for a in release["assets"] if a["name"] == chosen)
|
||||
url = asset["browser_download_url"]
|
||||
target.mkdir(parents = True, exist_ok = True)
|
||||
archive = target / chosen
|
||||
print(f"downloading {chosen} -> {archive}", flush = True)
|
||||
urllib.request.urlretrieve(url, archive) # noqa: S310 (github release URL)
|
||||
print("extracting ...", flush = True)
|
||||
with zipfile.ZipFile(archive) as zf:
|
||||
zf.extractall(target)
|
||||
archive.unlink(missing_ok = True)
|
||||
try:
|
||||
_download(url, archive)
|
||||
# Verify integrity BEFORE extracting + executing.
|
||||
_verify_sha256(archive, asset.get("digest"))
|
||||
print("extracting ...", flush = True)
|
||||
with zipfile.ZipFile(archive) as zf:
|
||||
zf.extractall(target)
|
||||
finally:
|
||||
# Always drop the archive: on a sha256 mismatch / corrupt zip / network error it
|
||||
# must not linger (and a stale partial would defeat a later retry).
|
||||
archive.unlink(missing_ok = True)
|
||||
sd_cli = _locate_sd_cli(target)
|
||||
if not sd_cli:
|
||||
raise RuntimeError(f"archive {chosen} contained no sd-cli binary")
|
||||
|
|
@ -196,7 +279,7 @@ def main(argv: Optional[list[str]] = None) -> int:
|
|||
args = p.parse_args(argv)
|
||||
|
||||
if args.print_asset:
|
||||
release = _fetch_latest_release()
|
||||
release = _fetch_release(_pinned_tag())
|
||||
names = [a["name"] for a in release.get("assets", [])]
|
||||
chosen = resolve_release_asset(
|
||||
names,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue