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:
Daniel Han 2026-07-07 03:15:07 +00:00
commit d36aeb54f3
9 changed files with 98 additions and 4 deletions

View file

@ -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
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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):

View file

@ -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

View file

@ -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

View file

@ -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