diff --git a/scripts/uninstall.ps1 b/scripts/uninstall.ps1 index 10c71ecb21..604539f662 100644 --- a/scripts/uninstall.ps1 +++ b/scripts/uninstall.ps1 @@ -383,10 +383,15 @@ function Uninstall-UnslothStudio { # Native diffusion (stable-diffusion.cpp) for a custom/env-mode Studio installs beside # the root at \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: 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 } diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh index 4e3e34136b..6bd8e5e2f8 100755 --- a/scripts/uninstall.sh +++ b/scripts/uninstall.sh @@ -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 /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: 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 diff --git a/studio/backend/core/inference/video.py b/studio/backend/core/inference/video.py index 3d1e6e88d4..692a9d22a0 100644 --- a/studio/backend/core/inference/video.py +++ b/studio/backend/core/inference/video.py @@ -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 diff --git a/studio/backend/routes/models.py b/studio/backend/routes/models.py index bd1af06801..005e3f7120 100644 --- a/studio/backend/routes/models.py +++ b/studio/backend/routes/models.py @@ -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 diff --git a/studio/backend/tests/test_local_model_format.py b/studio/backend/tests/test_local_model_format.py index f9184774d6..f466ab99a5 100644 --- a/studio/backend/tests/test_local_model_format.py +++ b/studio/backend/tests/test_local_model_format.py @@ -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 diff --git a/studio/backend/tests/test_sd_cpp_install.py b/studio/backend/tests/test_sd_cpp_install.py index cc5051e784..07d52a0b02 100644 --- a/studio/backend/tests/test_sd_cpp_install.py +++ b/studio/backend/tests/test_sd_cpp_install.py @@ -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): diff --git a/studio/backend/tests/test_video_backend.py b/studio/backend/tests/test_video_backend.py index 3e15984061..68a23b3ce9 100644 --- a/studio/backend/tests/test_video_backend.py +++ b/studio/backend/tests/test_video_backend.py @@ -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 diff --git a/studio/install_sd_cpp_prebuilt.py b/studio/install_sd_cpp_prebuilt.py index 52229d5f60..8533fac871 100644 --- a/studio/install_sd_cpp_prebuilt.py +++ b/studio/install_sd_cpp_prebuilt.py @@ -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 diff --git a/tests/sh/test_uninstall_sd_cpp_custom_root.sh b/tests/sh/test_uninstall_sd_cpp_custom_root.sh index 202b046ecf..0c55f9f53a 100755 --- a/tests/sh/test_uninstall_sd_cpp_custom_root.sh +++ b/tests/sh/test_uninstall_sd_cpp_custom_root.sh @@ -39,13 +39,15 @@ sed -n '/^_custom_studio_roots | while IFS= read -r _custom_root; do/,/^done/p' . "$HELPERS_FILE" # make_studio : a valid custom Studio root (share/studio.conf owner marker) plus its -# sibling /stable-diffusion.cpp build, each with a file so removal is observable. +# sibling /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