Studio: classify local video pipelines, harden video preflight, and gate custom-root sd.cpp removal on ownership
- _local_model_task now tags a local diffusers pipeline that resolves to a video family (LTX / Wan / Hunyuan) as text-to-video, mirroring the cached-repo _cached_repo_task, so supported local video pipelines surface in the Video On-Device picker instead of being routed to the Images picker where the image loader rejects them. Gated on _local_is_diffusers so only a real loadable pipeline dir reaches the video check. - Video validate_load_request now rejects a local pipeline pick whose directory has no model_index.json before the GPU handoff, mirroring the image loader, so a bad local pipeline can no longer evict the resident model and only then fail deep in from_pretrained. - The custom/env-mode uninstall now removes a sibling stable-diffusion.cpp only when it carries the Studio owner marker. install_sd_cpp_prebuilt writes the canonical .unsloth-studio-owned marker on install; uninstall.sh and uninstall.ps1 keep any unowned checkout (a user's own git clone of stable-diffusion.cpp beside a custom Studio root is no longer deleted). A pre-marker Studio build is left behind rather than a user file removed. Adds regression tests: local video pipeline tagged text-to-video (and a video-named non-pipeline dir stays untagged so it can never trigger a doomed pipeline load), the video local-pipeline preflight rejection, the install ownership marker, and the uninstall keeping an unowned sibling while removing an owned one.
This commit is contained in:
parent
fb94a79337
commit
d36aeb54f3
9 changed files with 98 additions and 4 deletions
|
|
@ -383,10 +383,15 @@ function Uninstall-UnslothStudio {
|
|||
# Native diffusion (stable-diffusion.cpp) for a custom/env-mode Studio installs beside
|
||||
# the root at <parent>\stable-diffusion.cpp -- find_sd_cpp_binary resolves it from
|
||||
# UNSLOTH_STUDIO_HOME.parent (sd_cpp_engine.py) -- so removing only the root leaves the
|
||||
# build behind. Derive and remove the sibling, guarding the parent path the same way.
|
||||
# build behind. Only remove a sibling Studio installed: <parent> is a user-chosen dir
|
||||
# and "stable-diffusion.cpp" is exactly what a git clone of leejet/stable-diffusion.cpp
|
||||
# produces, so require our owner marker (written by install_sd_cpp_prebuilt) before rm,
|
||||
# and keep any unowned checkout. Guard the derived parent path the same way.
|
||||
$customSdCpp = Join-Path (Split-Path -LiteralPath $r -Parent) "stable-diffusion.cpp"
|
||||
if (_IsUnsafeRoot $customSdCpp) {
|
||||
_Substep "refusing to remove unsafe path: $customSdCpp" "Yellow"
|
||||
} elseif ((Test-Path -LiteralPath $customSdCpp) -and -not (Test-Path -LiteralPath (Join-Path $customSdCpp ".unsloth-studio-owned") -PathType Leaf)) {
|
||||
_Substep "keeping sd.cpp without Studio owner marker: $customSdCpp" "Yellow"
|
||||
} else {
|
||||
_RemovePath $customSdCpp
|
||||
}
|
||||
|
|
|
|||
|
|
@ -213,10 +213,16 @@ _custom_studio_roots | while IFS= read -r _custom_root; do
|
|||
# Native diffusion (stable-diffusion.cpp) for a custom/env-mode Studio installs beside
|
||||
# the root at <parent>/stable-diffusion.cpp -- find_sd_cpp_binary resolves it from
|
||||
# UNSLOTH_STUDIO_HOME.parent (sd_cpp_engine.py) -- so removing only the root leaves the
|
||||
# build behind. Derive and remove the sibling, guarding the parent path the same way.
|
||||
# build behind. Only remove a sibling Studio installed: <parent> is a user-chosen dir
|
||||
# and "stable-diffusion.cpp" is exactly what `git clone` of leejet/stable-diffusion.cpp
|
||||
# produces, so require our owner marker (written by install_sd_cpp_prebuilt) before rm,
|
||||
# and keep any unowned checkout. A pre-marker Studio build is left behind, never a user
|
||||
# file deleted. Guard the derived parent path the same way.
|
||||
_custom_sd_cpp="$(dirname "$_custom_root")/stable-diffusion.cpp"
|
||||
if _is_unsafe_root "$_custom_sd_cpp"; then
|
||||
echo " refusing to remove unsafe path: $_custom_sd_cpp" >&2
|
||||
elif [ -e "$_custom_sd_cpp" ] && [ ! -f "$_custom_sd_cpp/.unsloth-studio-owned" ]; then
|
||||
echo " keeping sd.cpp without Studio owner marker: $_custom_sd_cpp" >&2
|
||||
else
|
||||
_remove_path "$_custom_sd_cpp"
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -448,6 +448,13 @@ class VideoBackend:
|
|||
raise ValueError(str(exc)) from exc
|
||||
elif repo_id.startswith(("/", "~", "./", "../")) and not root.is_file():
|
||||
raise ValueError(f"Local model path '{repo_id}' does not exist.")
|
||||
# A local pipeline pick must be a real diffusers directory (model_index.json), or it
|
||||
# would only fail deep in from_pretrained AFTER the route evicted the resident model.
|
||||
# Mirrors the image loader's local-pipeline shape check in diffusion.validate_load_request.
|
||||
if kind == "pipeline":
|
||||
root = Path(repo_id).expanduser()
|
||||
if root.is_dir() and not (root / "model_index.json").is_file():
|
||||
raise ValueError(f"Local pipeline directory has no model_index.json: {repo_id}")
|
||||
# Reject a malformed transformer_quant scheme cheaply, before the GPU handoff
|
||||
# (normalize_transformer_quant raises ValueError on an unknown scheme). It applies
|
||||
# only on pipeline-kind loads (the dense DiT from the base repo); an ignored value
|
||||
|
|
|
|||
|
|
@ -3270,6 +3270,19 @@ def _local_model_task(model: "LocalModelInfo") -> Optional[str]:
|
|||
pass
|
||||
return None
|
||||
if _local_is_diffusers(model):
|
||||
# A local diffusers pipeline can be a VIDEO family (LTX / Wan / Hunyuan), not just an
|
||||
# image one. Tag it text-to-video so it surfaces in the Video On-Device picker instead
|
||||
# of the Images picker (where the image loader would reject it), mirroring the
|
||||
# cached-repo _cached_repo_task. Gated on _local_is_diffusers, so only a real loadable
|
||||
# pipeline dir (model_index.json) or a name-matched checkpoint reaches this check.
|
||||
try:
|
||||
from core.inference.video import _is_trusted_video_repo
|
||||
from core.inference.video_families import detect_video_family
|
||||
for needle in (model.model_id, model.display_name, model.id):
|
||||
if needle and detect_video_family(needle) is not None and _is_trusted_video_repo(path):
|
||||
return _VIDEO_GEN_TASK
|
||||
except Exception:
|
||||
pass
|
||||
return "text-to-image"
|
||||
return None
|
||||
|
||||
|
|
|
|||
|
|
@ -165,3 +165,25 @@ def test_local_task_none_for_plain_llm(tmp_path):
|
|||
_touch(d / "config.json")
|
||||
_touch(d / "model.safetensors")
|
||||
assert models_route._local_model_task(_local(d, model_id = "meta-llama/Llama-3.1-8B")) is None
|
||||
|
||||
|
||||
def test_local_task_tags_video_pipeline_dir(tmp_path):
|
||||
# A local diffusers pipeline whose id resolves to a VIDEO family (LTX / Wan / Hunyuan)
|
||||
# must be tagged text-to-video so it surfaces in the Video On-Device picker, mirroring the
|
||||
# cached-repo path -- not text-to-image, where the image loader would reject it.
|
||||
d = tmp_path / "wan-local"
|
||||
_touch(d / "model_index.json")
|
||||
_touch(d / "transformer" / "diffusion_pytorch_model.safetensors")
|
||||
assert (
|
||||
models_route._local_model_task(_local(d, model_id = "Wan-AI/Wan2.2-TI2V-5B-Diffusers"))
|
||||
== models_route._VIDEO_GEN_TASK
|
||||
)
|
||||
|
||||
|
||||
def test_local_task_video_name_without_pipeline_not_surfaced(tmp_path):
|
||||
# A dir whose name matches a video family but which is NOT a diffusers pipeline (no
|
||||
# model_index.json) is not a loadable pipeline, so it must stay untagged -- never surfaced
|
||||
# to the Video picker, so it can never trigger a pipeline load that evicts then fails.
|
||||
d = tmp_path / "ltx-loose"
|
||||
_touch(d / "ltx-2.safetensors") # loose weights, no model_index.json
|
||||
assert models_route._local_model_task(_local(d, model_id = "Lightricks/LTX-2")) is None
|
||||
|
|
|
|||
|
|
@ -257,6 +257,9 @@ def test_install_downloads_verifies_extracts(tmp_path, monkeypatch):
|
|||
sd_cli = install(install_dir = tmp_path)
|
||||
assert sd_cli.name == "sd-cli" and sd_cli.is_file()
|
||||
assert not (tmp_path / name).exists() # archive cleaned up after extract
|
||||
# Ownership marker lets the uninstaller delete a Studio-installed sd.cpp beside a custom
|
||||
# root while keeping a user's own stable-diffusion.cpp checkout.
|
||||
assert (tmp_path / ".unsloth-studio-owned").is_file()
|
||||
|
||||
|
||||
def test_install_sha256_mismatch_raises_and_cleans_up(tmp_path, monkeypatch):
|
||||
|
|
|
|||
|
|
@ -462,6 +462,22 @@ def test_validate_gates_base_repo_and_local_paths(tmp_path):
|
|||
)
|
||||
|
||||
|
||||
def test_validate_rejects_local_pipeline_without_model_index(tmp_path):
|
||||
backend = VideoBackend()
|
||||
d = tmp_path / "ltx-local"
|
||||
(d / "transformer").mkdir(parents = True)
|
||||
(d / "transformer" / "diffusion_pytorch_model.safetensors").write_bytes(b"x")
|
||||
# A local dir resolved to a video family but missing model_index.json is not a loadable
|
||||
# diffusers pipeline; it must fail preflight BEFORE the route evicts the resident model,
|
||||
# mirroring the image loader's local-pipeline shape check.
|
||||
with pytest.raises(ValueError, match = "model_index.json"):
|
||||
backend.validate_load_request(str(d), family_override = "ltx-2")
|
||||
# With a model_index.json it is a valid local pipeline pick and passes preflight.
|
||||
(d / "model_index.json").write_text("{}")
|
||||
fam = backend.validate_load_request(str(d), family_override = "ltx-2")
|
||||
assert fam.name == "ltx-2"
|
||||
|
||||
|
||||
def test_validate_rejects_gguf_repo_as_pipeline():
|
||||
backend = VideoBackend()
|
||||
# A -GGUF repo with no quant filename resolves to the pipeline kind and would
|
||||
|
|
|
|||
|
|
@ -442,6 +442,13 @@ def install(
|
|||
_make_executable(sd_server)
|
||||
if sd_server is not None:
|
||||
print(f"installed sd-server -> {sd_server}", flush = True)
|
||||
# Ownership marker (the same one setup.sh/_is_studio_root use, and setup.ps1 writes into
|
||||
# the Node sibling dir) so the uninstaller can tell a Studio-installed sd.cpp from a user's
|
||||
# own stable-diffusion.cpp checkout beside a custom Studio root, and delete only ours.
|
||||
try:
|
||||
(target / ".unsloth-studio-owned").touch()
|
||||
except OSError:
|
||||
pass
|
||||
return sd_cli
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -39,13 +39,15 @@ sed -n '/^_custom_studio_roots | while IFS= read -r _custom_root; do/,/^done/p'
|
|||
. "$HELPERS_FILE"
|
||||
|
||||
# make_studio <root> : a valid custom Studio root (share/studio.conf owner marker) plus its
|
||||
# sibling <parent>/stable-diffusion.cpp build, each with a file so removal is observable.
|
||||
# sibling <parent>/stable-diffusion.cpp build carrying the Studio owner marker, each with a
|
||||
# file so removal is observable.
|
||||
make_studio() {
|
||||
mkdir -p "$1/share"
|
||||
: > "$1/share/studio.conf"
|
||||
_sib="$(dirname "$1")/stable-diffusion.cpp"
|
||||
mkdir -p "$_sib"
|
||||
: > "$_sib/sd-cli"
|
||||
: > "$_sib/.unsloth-studio-owned" # written by install_sd_cpp_prebuilt on a real install
|
||||
}
|
||||
run_loop() {
|
||||
# shellcheck disable=SC1090
|
||||
|
|
@ -73,7 +75,20 @@ assert_nodir "shared-parent root B removed" "$p2/studioB"
|
|||
assert_nodir "shared-parent root C removed" "$p2/studioC"
|
||||
assert_nodir "shared sibling stable-diffusion.cpp removed" "$p2/stable-diffusion.cpp"
|
||||
|
||||
# 3. Default-mode sd.cpp (a bare ~/.unsloth/stable-diffusion.cpp with no custom root) is NOT
|
||||
# 3. A sibling stable-diffusion.cpp WITHOUT the Studio owner marker (a user's own checkout,
|
||||
# even a built one, beside a custom root -- or one left when UNSLOTH_SD_CPP_PATH points
|
||||
# Studio elsewhere) is KEPT, though the Studio root itself is still removed.
|
||||
p3="$_TMP_ROOT/inst3"
|
||||
mkdir -p "$p3/studioD/share"; : > "$p3/studioD/share/studio.conf"
|
||||
mkdir -p "$p3/stable-diffusion.cpp/build/bin"
|
||||
: > "$p3/stable-diffusion.cpp/build/bin/sd-cli" # user's own build, no owner marker
|
||||
: > "$p3/stable-diffusion.cpp/main.cpp"
|
||||
_custom_studio_roots() { printf '%s\n' "$p3/studioD"; }
|
||||
run_loop
|
||||
assert_nodir "unowned-sibling: custom root still removed" "$p3/studioD"
|
||||
assert_dir "unowned sibling stable-diffusion.cpp kept" "$p3/stable-diffusion.cpp"
|
||||
|
||||
# 4. Default-mode sd.cpp (a bare ~/.unsloth/stable-diffusion.cpp with no custom root) is NOT
|
||||
# touched by the custom-root loop -- it is removed by the separate default-mode line.
|
||||
mkdir -p "$HOME/.unsloth/stable-diffusion.cpp"
|
||||
_custom_studio_roots() { printf '%s\n' "$p1/studioA"; } # a now-removed root -> guard skips
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue