From e1f462b10326ed4a638801bcb8a85fd49f98cd24 Mon Sep 17 00:00:00 2001 From: ashish-aesthisia Date: Sat, 8 Aug 2026 06:58:25 +0000 Subject: [PATCH 1/3] pick the right CUDA wheel per GPU, and stop warning on cards that work --- .github/workflows/launcher.yml | 102 ++++++++++ README.md | 46 +++-- core/src/inline_core/device/detect.py | 41 +++- core/src/inline_core/device/probe.py | 76 ++++++++ core/src/inline_core/training/cache.py | 3 +- core/src/inline_core/training/h3.py | 20 +- core/src/inline_core/training/trainer.py | 1 + core/tests/test_device_detect.py | 129 +++++++++++++ core/tests/test_minimaxh3_training.py | 23 +++ core/tests/test_webui_install.py | 91 ++++++++- core/webui.bat | 166 ++++++++++++++-- core/webui.sh | 182 ++++++++++++++++-- .../views/Trainer/TrainerSettingsPanel.tsx | 59 ++++-- src/shared/clipGrid.test.ts | 27 +++ src/shared/clipGrid.ts | 27 +++ src/shared/types.ts | 9 +- 16 files changed, 920 insertions(+), 82 deletions(-) create mode 100644 .github/workflows/launcher.yml create mode 100644 core/src/inline_core/device/probe.py create mode 100644 src/shared/clipGrid.test.ts create mode 100644 src/shared/clipGrid.ts diff --git a/.github/workflows/launcher.yml b/.github/workflows/launcher.yml new file mode 100644 index 0000000..fe5e993 --- /dev/null +++ b/.github/workflows/launcher.yml @@ -0,0 +1,102 @@ +# The launchers pick which PyTorch wheel index a GPU needs, and webui.bat cannot be exercised from +# a Linux dev box, so two field reports shipped before anyone noticed it chose wrong. cmd runs +# natively here and the GPU is stubbable, so the decision is assertable without hardware. +name: launcher + +on: + push: + paths: + - "core/webui.bat" + - "core/webui.sh" + - "core/tests/test_webui_install.py" + - "core/src/inline_core/device/**" + - ".github/workflows/launcher.yml" + pull_request: + paths: + - "core/webui.bat" + - "core/webui.sh" + - "core/tests/test_webui_install.py" + - "core/src/inline_core/device/**" + - ".github/workflows/launcher.yml" + +jobs: + windows-torch-index: + name: "webui.bat picks ${{ matrix.expect }} (${{ matrix.name }})" + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + include: + - name: blackwell on a current driver + probe: "12.0, 610.88" + expect: cu130 + reason: autodetect + # cu128 still serves and was the first index with sm_120, so this machine has exactly one + # workable choice. A hard error here would be worse than what v1.2.64 did. + - name: blackwell on a pre-R580 driver + probe: "12.0, 575.57" + expect: cu128 + reason: driver-floor-cu128 + - name: ada + probe: "8.9, 580.82" + expect: cu126 + reason: autodetect + - name: hopper + probe: "9.0, 580.82" + expect: cu126 + reason: autodetect + # An old driver answers an unknown query with a word. A string comparison would rank it + # above 10 and hand an unknown card cu130, so this asserts the set /a coercion holds. + - name: a driver that cannot answer the query + probe: "Unknown Error" + expect: cu126 + reason: autodetect + steps: + - uses: actions/checkout@v4 + + - name: Put a fake nvidia-smi first on PATH + shell: pwsh + run: | + $dir = Join-Path $env:RUNNER_TEMP 'stub' + New-Item -ItemType Directory -Force -Path $dir | Out-Null + # Answers -L with success (so the GPU reads as present) and the query with the matrix line. + @" + @echo off + echo %* | findstr /c:"compute_cap" >nul && echo ${{ matrix.probe }} + exit /b 0 + "@ | Set-Content -Path (Join-Path $dir 'nvidia-smi.bat') -Encoding ascii + Add-Content -Path $env:GITHUB_PATH -Value $dir + + - name: Report the decision + id: decide + shell: cmd + working-directory: core + run: | + call .\webui.bat --print-torch-index > decision.txt 2>&1 + type decision.txt + + - name: Assert the index and the reason + shell: cmd + working-directory: core + run: | + findstr /c:"torch-index: ${{ matrix.expect }}" decision.txt >nul || (echo EXPECTED torch-index: ${{ matrix.expect }} & exit /b 1) + findstr /c:"reason: ${{ matrix.reason }}" decision.txt >nul || (echo EXPECTED reason: ${{ matrix.reason }} & exit /b 1) + + - name: Assert it installed nothing + shell: cmd + working-directory: core + run: | + if exist .venv (echo --print-torch-index must not create a venv & exit /b 1) + + posix-launcher-tests: + name: webui.sh launcher tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + - name: Install the engine and test tooling + working-directory: core + run: uv venv && uv pip install --python .venv/bin/python -e ".[server,dev]" + - name: Launcher and device tests + working-directory: core + run: .venv/bin/python -m pytest tests/test_webui_install.py tests/test_device_detect.py -q diff --git a/README.md b/README.md index af26c96..a5d08db 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,9 @@ git clone https://github.com/inlineresearch/Inline-Studio.git cd Inline-Studio\core .\webui.bat --install --extra all .\webui.bat + +rem If the CUDA build turns out wrong for your card, name the index yourself: +.\webui.bat --install --extra all --torch-index cu130 ``` That's it: `--install` sets up the environment and installs everything once, then `webui.sh` / `webui.bat` runs the app on one port. See **[Command-line options](#command-line-options)** for every flag (`--listen`, `--port`, `--lowvram`, `--multi-gpu`, …). @@ -82,17 +85,20 @@ Honest status - what's actually been run, versus what has a code path but no one RTX 50-series cards (5060/5070/5080/5090 and the RTX PRO Blackwell line) are compute capability **sm_120**, and no PyTorch wheel built for CUDA 12.4 or 12.6 has kernels for them. `--install` handles this: it reads the compute capability off the driver and picks `cu130`, so a plain `.\webui.bat --install --extra all` is all you need. -Two cases where you may want to say it yourself: +**Old driver?** CUDA 13 needs driver R580 or newer. If yours predates it, `--install` picks `cu128` for you and says so: cu128 still has `sm_120` but is **frozen at torch 2.11** and will never update, so updating the driver and re-running `--install` is worth doing when you can. + +To name an index yourself: ```powershell -rem Blackwell card, but a driver older than CUDA 13 (R580) - cu128 has sm_120 and a lower floor -.\webui.bat --install --extra all --torch-index cu128 +.\webui.bat --install --extra all --torch-index cu130 rem Or set it once for the shell, same effect -set INLINE_TORCH_INDEX=cu128 +set INLINE_TORCH_INDEX=cu130 ``` -`--torch-index` takes a short name (`cu130`, `cu128`, `cu126`), a full index URL, or `cpu` to force the CPU-only build. `webui.sh` takes the same flag. If the installed build turns out to have no kernels for your card, Core says so by name at startup rather than leaving you with PyTorch's own `sm_120 is not compatible` warning. +`--torch-index` takes a short name (`cu130`, `cu128`, `cu126`), a full index URL, or `cpu` to force the CPU-only build. `webui.sh` takes the same flag. Naming it explicitly also **replaces** an already-installed torch, which a plain re-run will not do, so you rarely need `--recreate`. + +Not sure what you have? `.\webui.bat --print-torch-index` prints what the driver reported and which index would be used, and installs nothing. Paste that into a bug report. If the installed build turns out to have no kernels for your card, Core also says so by name at startup rather than leaving you with PyTorch's own `sm_120 is not compatible` warning. #### AMD (ROCm) setup @@ -163,20 +169,22 @@ The friendly launcher (in `core/`) maps flags onto the engine's `INLINE_*` envir
Show all command-line flags -| `webui.sh` / `main.py` flag | Env var | What it does | -| ---------------------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--listen` | `INLINE_HOST=0.0.0.0` | Bind all interfaces so other machines can reach it | -| `--host ADDR` | `INLINE_HOST` | Bind a specific address (default `127.0.0.1`) | -| `--port N` | `INLINE_PORT` | Port to serve on (default `8848`) | -| `--models-dir PATH` | `INLINE_MODELS_DIR` | Where model weights are scanned from (default `./models`) | -| `--data-dir PATH` | `INLINE_DATA_DIR` | Where runs + takes are written (default `./.inline`) | -| `--lowvram` | `INLINE_PROFILE=lowvram` | Tight-VRAM profile (VAE tiling/slicing, attention slicing) | -| `--cpu` | `INLINE_PROFILE=cpu` | Force CPU generation | -| `--profile NAME` | `INLINE_PROFILE` | Set the profile explicitly: `gpu-max` \| `lowvram` \| `cpu` | -| `--vram-budget GB` | `INLINE_VRAM_BUDGET_GB` | Treat the GPU as having GB of usable VRAM | -| `--multi-gpu [SPEC]` | `INLINE_PARALLEL` | Split one image's denoise across GPUs (e.g. `pipefusion=2`); auto with 2+ GPUs | -| `--front-end-root DIR` _(main.py)_ | `INLINE_FRONTEND_ROOT` | Serve a local SPA build instead of the installed UI package (dev) | -| `--rebuild` _(webui.sh)_ | n/a | Force a fresh SPA build (`npm run build:spa`) from source and serve it on the one port; use after UI changes when not running `--dev`. Needs the repo checkout + Node/npm | +| `webui.sh` / `main.py` flag | Env var | What it does | +| ---------------------------------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--listen` | `INLINE_HOST=0.0.0.0` | Bind all interfaces so other machines can reach it | +| `--host ADDR` | `INLINE_HOST` | Bind a specific address (default `127.0.0.1`) | +| `--port N` | `INLINE_PORT` | Port to serve on (default `8848`) | +| `--models-dir PATH` | `INLINE_MODELS_DIR` | Where model weights are scanned from (default `./models`) | +| `--data-dir PATH` | `INLINE_DATA_DIR` | Where runs + takes are written (default `./.inline`) | +| `--lowvram` | `INLINE_PROFILE=lowvram` | Tight-VRAM profile (VAE tiling/slicing, attention slicing) | +| `--cpu` | `INLINE_PROFILE=cpu` | Force CPU generation | +| `--profile NAME` | `INLINE_PROFILE` | Set the profile explicitly: `gpu-max` \| `lowvram` \| `cpu` | +| `--vram-budget GB` | `INLINE_VRAM_BUDGET_GB` | Treat the GPU as having GB of usable VRAM | +| `--multi-gpu [SPEC]` | `INLINE_PARALLEL` | Split one image's denoise across GPUs (e.g. `pipefusion=2`); auto with 2+ GPUs | +| `--front-end-root DIR` _(main.py)_ | `INLINE_FRONTEND_ROOT` | Serve a local SPA build instead of the installed UI package (dev) | +| `--rebuild` _(webui.sh)_ | n/a | Force a fresh SPA build (`npm run build:spa`) from source and serve it on the one port; use after UI changes when not running `--dev`. Needs the repo checkout + Node/npm | +| `--torch-index WHICH` | `INLINE_TORCH_INDEX` | With `--install`, override the PyTorch wheel index picked from your GPU's compute capability. A short name (`cu130`, `cu128`, `cu126`), a full index URL, or `cpu`. Naming it also replaces an already-installed torch | +| `--print-torch-index` | n/a | Print what the GPU probe read and which index would be used, then exit without installing. The one line to paste into a bug report |
diff --git a/core/src/inline_core/device/detect.py b/core/src/inline_core/device/detect.py index ae495e7..df7b3a7 100644 --- a/core/src/inline_core/device/detect.py +++ b/core/src/inline_core/device/detect.py @@ -2,6 +2,9 @@ from __future__ import annotations +from collections.abc import Iterable +from string import ascii_letters + from .types import Device, DeviceKind @@ -80,6 +83,37 @@ def cpu_only_torch_warning() -> str | None: ) +def _parse_arch(arch: str) -> tuple[int, int] | None: + """``sm_120`` to ``(12, 0)``. + + The minor is the **last digit** and the major is everything before it, so ``sm_100`` is (10, 0). + Reading left to right instead gives (1, 20) and puts the bug precisely on the Blackwell parts. + Tuned variants (``sm_90a``) drop their trailing letters. + """ + digits = arch[3:].rstrip(ascii_letters) + if len(digits) < 2 or not digits.isdigit(): + return None + return int(digits[:-1]), int(digits[-1]) + + +def arch_list_covers(arches: Iterable[str], major: int, minor: int) -> bool: + """Whether a wheel's arch list has kernels that will run on a ``(major, minor)`` device. + + CUDA binary compatibility runs **upward within one major only**: an ``sm_8x`` cubin executes on + any ``sm_8y`` where ``y >= x``. So ``sm_86`` covers an sm_89 Ada card, which is why exact + matching told every RTX 40-series owner their install was broken. Crossing a major never works, + so ``sm_90`` does not cover sm_120 and the Blackwell warning stays correct. + + PTX entries (``compute_90``) JIT forward but are not matched here: they do not start with + ``sm_``, so a PTX-carrying wheel still warns. Rare, and warning is the safe way to be wrong. + """ + for arch in arches: + parsed = _parse_arch(arch) + if parsed is not None and parsed[0] == major and parsed[1] <= minor: + return True + return False + + def unsupported_arch_warning() -> str | None: """A warning when the installed torch has no kernels for the GPU it is about to run on. @@ -105,16 +139,15 @@ def unsupported_arch_warning() -> str | None: return None # a CPU-only build; cpu_only_torch_warning owns that case major, minor = get_capability(0) target = f"sm_{major}{minor}" - # startswith, because a wheel lists tuned variants like sm_90a for the same architecture. - if any(arch.startswith(target) for arch in arches): + if arch_list_covers(arches, major, minor): return None name = _device_name(torch) or "The detected NVIDIA GPU" return ( f"{name} is compute capability {target}, but this install's PyTorch only has kernels " f"for {' '.join(arches)}. Generation will fail or fall back to the CPU. Re-run " "`webui.sh --install` (Windows: `.\\webui.bat --install`) to pick the wheel index that " - "matches the card, or force one with `--torch-index cu130` - `cu128` if the driver " - "predates CUDA 13." + "matches the card, or force one with `--torch-index cu130`. On a Blackwell card whose " + "driver predates R580, use `cu128`, which still has sm_120 but is frozen at torch 2.11." ) except Exception: # noqa: BLE001 - a diagnostic must never break startup return None diff --git a/core/src/inline_core/device/probe.py b/core/src/inline_core/device/probe.py new file mode 100644 index 0000000..7a19711 --- /dev/null +++ b/core/src/inline_core/device/probe.py @@ -0,0 +1,76 @@ +"""Install-time probe: which torch is installed, and does it have kernels for this card? + +Run as ``python -m inline_core.device.probe`` by both launchers. They cannot share the +compatibility rule any other way, and restating it in bash and batch is how the two drift. + +Always prints one JSON object and always exits 0, so a shell branches on ``status`` rather than +parsing stderr. A **nonzero exit means the probe itself failed**, which callers must treat as +unknown and never as covered. +""" + +from __future__ import annotations + +import json +import re +import sys +from typing import Any + +from .detect import arch_list_covers + +#: A local version tag we recognise as a pytorch.org build, e.g. ``2.13.0+cu130`` or ``2.9.0+cpu``. +#: Only these are safe to replace automatically: a ROCm build, a nightly or a hand-built wheel will +#: also fail the arch check, and silently reinstalling over someone's deliberate choice is worse +#: than the wrong wheel. +_REPLACEABLE_LOCAL = re.compile(r"\+(?:cpu|cu\d+)$") + + +def probe() -> dict[str, Any]: + """What the launcher needs to decide whether to replace torch. Never raises.""" + out: dict[str, Any] = { + "status": "unknown", + "torch": None, + "cuda": None, + "archList": [], + "capability": None, + "replaceable": False, + } + try: + import torch + except Exception: # noqa: BLE001 - no torch yet is a normal first install + out["status"] = "no-torch" + return out + + try: + out["torch"] = str(torch.__version__) + out["cuda"] = getattr(torch.version, "cuda", None) + out["replaceable"] = bool(_REPLACEABLE_LOCAL.search(out["torch"])) + # HIP reports gfx arches through the sm_ call, so the rule does not apply. Never + # auto-replace a ROCm build. + if getattr(torch.version, "hip", None): + out["status"] = "rocm" + out["replaceable"] = False + return out + arches = [str(a) for a in torch.cuda.get_arch_list() if str(a).startswith("sm_")] + out["archList"] = arches + if not arches: + out["status"] = "cpu-only" + return out + if torch.cuda.device_count() < 1: + out["status"] = "no-gpu" + return out + major, minor = torch.cuda.get_device_capability(0) + out["capability"] = [major, minor] + out["status"] = "covered" if arch_list_covers(arches, major, minor) else "uncovered" + except Exception: # noqa: BLE001 - a broken torch is exactly what we are here to detect + out["status"] = "unknown" + return out + + +def main() -> int: + json.dump(probe(), sys.stdout) + sys.stdout.write("\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/core/src/inline_core/training/cache.py b/core/src/inline_core/training/cache.py index a314ece..1285aa1 100644 --- a/core/src/inline_core/training/cache.py +++ b/core/src/inline_core/training/cache.py @@ -30,6 +30,7 @@ def build( flip: bool = False, dropout: float = 0.0, clip_frames: int = 1, + clip_window: str = "start", on_status: Callable[[str], None] | None = None, ) -> tuple[list[dict[str, Any]], dict[str, Any] | None, float]: """Return ``(items, unconditional, shift)``, all as CPU tensors, with the encoders freed. @@ -43,7 +44,7 @@ def build( items, unconditional = h3.precache( dataset_dir, models_dir, device, dtype, resolution, flip, dropout > 0, clip_frames, - on_status=on_status, + clip_window=clip_window, on_status=on_status, ) return items, unconditional, _H3_SHIFT diff --git a/core/src/inline_core/training/h3.py b/core/src/inline_core/training/h3.py index a32c55c..0cdf5ca 100644 --- a/core/src/inline_core/training/h3.py +++ b/core/src/inline_core/training/h3.py @@ -44,6 +44,7 @@ def precache( flip: bool, want_unconditional: bool, clip_frames: int = 1, + clip_window: str = "start", on_status: Callable[[str], None] | None = None, ) -> tuple[list[dict[str, Any]], dict[str, Any] | None]: """Every image as a latent and every caption as conditioning, as CPU tensors.""" @@ -57,7 +58,9 @@ def precache( root = Path(models_dir) # Only the clips that survived encoding carry captions, or every caption after the first skip # would be paired with the wrong latent. - latents, kept = _encode_pixels(root, pairs, device, resolution, flip, clip_frames, say) + latents, kept = _encode_pixels( + root, pairs, device, resolution, flip, clip_frames, say, clip_window + ) if not kept: raise RuntimeError( f"None of the {len(pairs)} dataset items could be encoded. For clips, each must be at " @@ -89,6 +92,7 @@ def precache( def _encode_pixels( root: Path, pairs: list[tuple[Path, str]], device: str, resolution: int, flip: bool, clip_frames: int = 1, say: Callable[[str], None] = lambda _text: None, + clip_window: str = "start", ) -> tuple[list[Any], list[tuple[Path, str]]]: """Pass one: the video VAE, then dropped. Returns the latents and the pairs they came from.""" import numpy @@ -114,7 +118,9 @@ def _encode_pixels( for index, (path, _caption) in enumerate(pairs, start=1): clip = ds.is_video(path) try: - frames = _clip_frames(path, clip_frames) if clip else [Image.open(path)] + frames = ( + _clip_frames(path, clip_frames, clip_window) if clip else [Image.open(path)] + ) except ShortClipError as exc: skipped.append(path.name) say(f"skipped {exc}") @@ -151,11 +157,14 @@ class ShortClipError(RuntimeError): not throw away a precache that takes many minutes.""" -def _clip_frames(path: Path, clip_frames: int) -> list[Any]: - """A clip as PIL frames on H3's 24fps, 17n+5 grid, taken from the start. +def _clip_frames(path: Path, clip_frames: int, window: str = "start") -> list[Any]: + """A clip as PIL frames on H3's 24fps, 17n+5 grid. Trimmed rather than sampled: a fixed window keeps the precache to one encode per clip, and re-encoding a different window every step would defeat caching the latents at all. + + The grid only snaps **down**, so some of every clip is always dropped. ``window="end"`` takes + the tail instead of the head, for footage whose action is at the finish. """ from PIL import Image @@ -173,7 +182,8 @@ def _clip_frames(path: Path, clip_frames: int) -> list[Any]: f"{path.name} is {frames.shape[0]} frames once resampled to {_H3_FPS}fps, below H3's " f"{keep}-frame minimum ({keep / _H3_FPS:.2f}s). Skipped." ) - return [Image.fromarray(frame) for frame in frames[:keep]] + kept = frames[-keep:] if window == "end" else frames[:keep] + return [Image.fromarray(frame) for frame in kept] def _encode_captions( diff --git a/core/src/inline_core/training/trainer.py b/core/src/inline_core/training/trainer.py index a0352f6..df542ba 100644 --- a/core/src/inline_core/training/trainer.py +++ b/core/src/inline_core/training/trainer.py @@ -184,6 +184,7 @@ def train(manifest: dict[str, Any]) -> str | None: manifest["datasetDir"], manifest["modelsDir"], arch.key, str(device), dtype, resolution, flip=bool(hp.get("flipAugment")), dropout=dropout, clip_frames=archs.clip_frames(arch, hp.get("clipSeconds")), + clip_window=str(hp.get("clipWindow") or "start"), on_status=lambda text: protocol.progress(0, steps, status=text), ) diff --git a/core/tests/test_device_detect.py b/core/tests/test_device_detect.py index 1f45b6e..12d11e3 100644 --- a/core/tests/test_device_detect.py +++ b/core/tests/test_device_detect.py @@ -192,3 +192,132 @@ def _no_torch(name: str, *args: object, **kwargs: object) -> object: monkeypatch.setattr(builtins, "__import__", _no_torch) monkeypatch.delitem(__import__("sys").modules, "torch", raising=False) assert detect.cpu_only_torch_warning() is None + + +# --- CUDA within-major binary compatibility ----------------------------------------------------- +# +# A cubin built for sm_8x runs on any sm_8y where y >= x, so sm_86 covers an sm_89 Ada card. Exact +# per-minor matching told every RTX 40-series owner their install was broken. + +_CU130_ARCHES = ["sm_75", "sm_80", "sm_86", "sm_90", "sm_100", "sm_120"] + + +def test_arch_parse_reads_the_minor_as_the_last_digit() -> None: + """sm_120 is (12, 0), not (1, 20). Left-to-right puts the bug on the Blackwell parts.""" + assert detect._parse_arch("sm_120") == (12, 0) + assert detect._parse_arch("sm_100") == (10, 0) + assert detect._parse_arch("sm_90a") == (9, 0) # tuned variant + assert detect._parse_arch("compute_90") is None + + +def test_ada_is_covered_by_ampere_kernels(monkeypatch) -> None: + """The RTX 4080 false positive: sm_89 against a wheel whose newest 8.x is sm_86.""" + monkeypatch.setitem( + __import__("sys").modules, + "torch", + _fake_arch_torch(capability=(8, 9), arches=_CU124_ARCHES), + ) + assert detect.unsupported_arch_warning() is None + + +def test_compatibility_runs_upward_only(monkeypatch) -> None: + """An sm_86 cubin does NOT run on an sm_80 A100, so that must still warn.""" + monkeypatch.setitem( + __import__("sys").modules, + "torch", + _fake_arch_torch(capability=(8, 0), arches=["sm_86"]), + ) + assert detect.unsupported_arch_warning() is not None + + +def test_a_dropped_architecture_still_warns(monkeypatch) -> None: + """cu130 dropped Volta; sm_70 has no same-major kernel at or below it.""" + monkeypatch.setitem( + __import__("sys").modules, + "torch", + _fake_arch_torch(capability=(7, 0), arches=_CU130_ARCHES), + ) + assert detect.unsupported_arch_warning() is not None + + +def test_blackwell_majors_do_not_cover_each_other() -> None: + """sm_100 and sm_120 are both Blackwell but different majors, so neither covers the other.""" + assert detect.arch_list_covers(["sm_100"], 12, 0) is False + assert detect.arch_list_covers(["sm_120"], 10, 0) is False + assert detect.arch_list_covers(_CU130_ARCHES, 10, 0) is True + assert detect.arch_list_covers(_CU130_ARCHES, 12, 0) is True + + +# --- the install-time probe -------------------------------------------------------------------- + + +def _fake_probe_torch( + *, version: str, arches: list[str], capability: tuple[int, int] | None = (12, 0), + hip: str | None = None, devices: int = 1, +) -> types.SimpleNamespace: + return types.SimpleNamespace( + __version__=version, + version=types.SimpleNamespace(cuda=None if "+cpu" in version else "12.8", hip=hip), + cuda=types.SimpleNamespace( + device_count=lambda: devices, + get_arch_list=lambda: arches, + get_device_capability=lambda i=0: capability, + ), + ) + + +def _probe_with(monkeypatch, torch_stub) -> dict: + from inline_core.device import probe as probe_mod + + monkeypatch.setitem(__import__("sys").modules, "torch", torch_stub) + return probe_mod.probe() + + +def test_probe_reports_an_uncovered_wheel_as_replaceable(monkeypatch) -> None: + """The 5060 Ti case: a cu126 wheel on sm_120, and the +cuXXX tag makes it safe to replace.""" + got = _probe_with( + monkeypatch, _fake_probe_torch(version="2.9.0+cu126", arches=_CU124_ARCHES) + ) + assert got["status"] == "uncovered" + assert got["replaceable"] is True + + +def test_probe_reports_ada_as_covered(monkeypatch) -> None: + got = _probe_with( + monkeypatch, + _fake_probe_torch(version="2.13.0+cu130", arches=_CU130_ARCHES, capability=(8, 9)), + ) + assert got["status"] == "covered" + + +def test_probe_never_offers_to_replace_a_rocm_build(monkeypatch) -> None: + """The safety gate. A ROCm build fails the sm_ rule too, and reinstalling over someone's + deliberate choice is worse than the wrong wheel.""" + got = _probe_with( + monkeypatch, + _fake_probe_torch(version="2.9.0+rocm6.2", arches=[], hip="6.2.0"), + ) + assert got["status"] == "rocm" + assert got["replaceable"] is False + + +def test_probe_never_offers_to_replace_an_untagged_build(monkeypatch) -> None: + """A nightly or hand-built wheel carries no +cpu/+cuXXX tag, so leave it alone.""" + got = _probe_with( + monkeypatch, _fake_probe_torch(version="2.14.0.dev20260101", arches=_CU124_ARCHES) + ) + assert got["status"] == "uncovered" + assert got["replaceable"] is False + + +def test_probe_survives_a_broken_torch(monkeypatch) -> None: + """Never covered on uncertainty: a torch that raises must read as unknown.""" + class Boom: + __version__ = "2.13.0+cu130" + + def __getattr__(self, _name: str): + raise RuntimeError("broken install") + + got = _probe_with(monkeypatch, Boom()) + assert got["status"] == "unknown" + assert got["status"] != "covered" diff --git a/core/tests/test_minimaxh3_training.py b/core/tests/test_minimaxh3_training.py index 8093155..4450386 100644 --- a/core/tests/test_minimaxh3_training.py +++ b/core/tests/test_minimaxh3_training.py @@ -452,3 +452,26 @@ def test_long_enough_clip_encodes_on_the_frame_grid(tmp_path) -> None: frames = h3._clip_frames(clip, clip_frames=24) # Snapped down onto H3's 17n+5 grid rather than taking all 40. assert len(frames) == 22 + + +def test_clip_window_takes_the_tail_when_asked(tmp_path) -> None: + """The 17n+5 grid only snaps down, so part of every clip is dropped. A user whose action sits in + the last half second needs the tail rather than the head.""" + from inline_core.training import h3 + + clip = _write_clip(tmp_path / "ramp.mp4", frames=40) + head = h3._clip_frames(clip, clip_frames=24, window="start") + tail = h3._clip_frames(clip, clip_frames=24, window="end") + + assert len(head) == len(tail) == 22 + # The fixture ramps brightness per frame, so the two windows cannot be the same footage. + assert head[0].getpixel((0, 0)) != tail[0].getpixel((0, 0)) + + +def test_clip_window_defaults_to_the_start(tmp_path) -> None: + from inline_core.training import h3 + + clip = _write_clip(tmp_path / "ramp2.mp4", frames=40) + assert h3._clip_frames(clip, clip_frames=24)[0].getpixel((0, 0)) == ( + h3._clip_frames(clip, clip_frames=24, window="start")[0].getpixel((0, 0)) + ) diff --git a/core/tests/test_webui_install.py b/core/tests/test_webui_install.py index 53e6512..9df7a87 100644 --- a/core/tests/test_webui_install.py +++ b/core/tests/test_webui_install.py @@ -60,15 +60,27 @@ def make_venv_python(self) -> None: self.venv_python.parent.mkdir(parents=True, exist_ok=True) _stub(self.venv_python, 'echo "$*" >> "$PYTHON_LOG"\nexit 0\n') - def pretend_nvidia_gpu(self, *compute_caps: str) -> None: + def pretend_nvidia_gpu(self, *compute_caps: str, driver: str = "") -> None: """A driver that answers `-L` and the compute_cap query. Pass no caps for an older driver - that does not know the query, which is the case the fallback index exists for.""" - answer = "".join(f"printf '{cap}\\n'\n" for cap in compute_caps) or "exit 1\n" + that does not know the query, which is the case the fallback index exists for. + + `driver` fills the second CSV column; the launcher reads it for the R580 floor that decides + cu130 against frozen cu128.""" + suffix = f", {driver}" if driver else "" + answer = "".join(f"printf '{cap}{suffix}\\n'\n" for cap in compute_caps) or "exit 1\n" _stub( self.stubs / "nvidia-smi", f'case "$*" in\n *compute_cap*) {answer.strip()} ;;\nesac\nexit 0\n', ) + def pretend_nvidia_probe_errors(self) -> None: + """A driver that lists GPUs but answers the query with an error string. The word must never + be coerced into a capability.""" + _stub( + self.stubs / "nvidia-smi", + 'case "$*" in\n *compute_cap*) printf \'Unknown Error\\n\' ;;\nesac\nexit 0\n', + ) + def pretend_windows(self) -> None: """Only Windows needs an explicit CUDA index; this script runs there under Git Bash.""" _stub(self.stubs / "uname", "printf 'MINGW64_NT-10.0-22631\\n'\nexit 0\n") @@ -251,3 +263,76 @@ def test_launch_prefers_our_venv_over_the_active_environment(sandbox: Sandbox) - assert "FOREIGN" not in ran assert "-m inline_core.server" in ran assert "not the environment active in this shell" in done.stdout + + +# --- the detect-only flag, and the decisions it reports ----------------------------------------- +# +# Two field reports were unfalsifiable because the launcher printed a conclusion and never the +# evidence. --print-torch-index prints both and installs nothing. + + +def _decision(sandbox: Sandbox, *args: str) -> dict[str, str]: + result = sandbox.run("--print-torch-index", *args) + assert result.returncode == 0, result.stderr + out = {} + for line in result.stdout.splitlines(): + key, _, value = line.partition(": ") + out[key] = value + return out + + +def test_print_torch_index_installs_nothing(sandbox: Sandbox) -> None: + sandbox.pretend_windows() + sandbox.pretend_nvidia_gpu("12.0", driver="610.88") + _decision(sandbox) + assert sandbox.uv_calls() == [] + + +def test_blackwell_on_a_current_driver_gets_cu130(sandbox: Sandbox) -> None: + sandbox.pretend_windows() + sandbox.pretend_nvidia_gpu("12.0", driver="610.88") + got = _decision(sandbox) + assert got["torch-index"] == "cu130" + assert got["capability-major"] == "12" + assert got["reason"] == "autodetect" + + +def test_blackwell_on_an_old_driver_gets_frozen_cu128(sandbox: Sandbox) -> None: + """CUDA 13 needs R580. cu128 still serves and was the first index with sm_120, so that machine + has exactly one workable choice and should not be made to type it back to us.""" + sandbox.pretend_windows() + sandbox.pretend_nvidia_gpu("12.0", driver="575.57") + got = _decision(sandbox) + assert got["torch-index"] == "cu128" + assert got["reason"] == "driver-floor-cu128" + + +def test_ada_gets_cu126(sandbox: Sandbox) -> None: + sandbox.pretend_windows() + sandbox.pretend_nvidia_gpu("8.9", driver="580.00") + assert _decision(sandbox)["torch-index"] == "cu126" + + +def test_a_garbage_probe_falls_back_and_shows_its_working(sandbox: Sandbox) -> None: + """`Unknown Error` must not become a capability. A string comparison would rank it above 10 and + hand an unknown card cu130.""" + sandbox.pretend_windows() + sandbox.pretend_nvidia_probe_errors() + got = _decision(sandbox) + assert got["torch-index"] == "cu126" + assert got["capability-major"] == "unknown" + assert "Unknown Error" in got["probe"] # the raw line is echoed, not swallowed + + +def test_no_gpu_reports_why(sandbox: Sandbox) -> None: + got = _decision(sandbox) + assert got["torch-index"] == "cpu" + assert got["reason"] == "no-gpu" + + +def test_an_explicit_index_is_reported_as_an_override(sandbox: Sandbox) -> None: + sandbox.pretend_windows() + sandbox.pretend_nvidia_gpu("12.0", driver="610.88") + got = _decision(sandbox, "--torch-index", "cu126") + assert got["torch-index"] == "cu126" + assert got["reason"] == "override" diff --git a/core/webui.bat b/core/webui.bat index 47bd38e..2862961 100644 --- a/core/webui.bat +++ b/core/webui.bat @@ -29,6 +29,9 @@ set "FORCE_REBUILD=0" set "SMART_MEMORY=0" set "USE_ACTIVE_ENV=0" set "RECREATE=0" +set "PRINT_TORCH_INDEX=0" +set "TORCH_EXPLICIT=0" +set "TORCH_INDEX_REASON=autodetect" rem Empty means "decide from the GPU's compute capability at install time" (see :install_torch_args). set "TORCH_CHOICE=%INLINE_TORCH_INDEX%" @@ -48,7 +51,8 @@ if /i "%~1"=="--models-dir" ( set "INLINE_MODELS_DIR=%~2" & shift & shift & go if /i "%~1"=="--data-dir" ( set "INLINE_DATA_DIR=%~2" & shift & shift & goto parse ) if /i "%~1"=="--install" ( set "RUN_INSTALL=1" & shift & goto parse ) if /i "%~1"=="--extra" ( set "EXTRAS=!EXTRAS!,%~2" & shift & shift & goto parse ) -if /i "%~1"=="--torch-index" ( set "TORCH_CHOICE=%~2" & shift & shift & goto parse ) +if /i "%~1"=="--torch-index" ( set "TORCH_CHOICE=%~2" & set "TORCH_EXPLICIT=1" & shift & shift & goto parse ) +if /i "%~1"=="--print-torch-index" ( set "PRINT_TORCH_INDEX=1" & shift & goto parse ) if /i "%~1"=="--recreate" ( set "RECREATE=1" & shift & goto parse ) if /i "%~1"=="--use-active-env" ( set "USE_ACTIVE_ENV=1" & shift & goto parse ) if /i "%~1"=="--dev" ( set "DEV_MODE=1" & shift & goto parse ) @@ -93,6 +97,7 @@ if defined CONDA_PREFIX set "ACTIVE_ENV=%CONDA_PREFIX%" if defined VIRTUAL_ENV set "ACTIVE_ENV=%VIRTUAL_ENV%" call :foreign_env +if "%PRINT_TORCH_INDEX%"=="1" goto print_torch_index if "%RUN_INSTALL%"=="1" goto do_install goto pick_python @@ -131,15 +136,25 @@ rem PyPI's default torch is CPU-only on Windows, so installing blind generates o rem slower with no error. Which CUDA index is right depends on the card: Blackwell (sm_120) has no rem wheels before cu128, while cu126 is the last index still built for Maxwell..Volta (sm_50..sm_70). set "TORCH_ARGS=" -if defined TORCH_CHOICE goto install_torch_index +if defined TORCH_CHOICE ( set "TORCH_INDEX_REASON=override" & goto install_torch_index ) +set "NO_GPU_WHY=nvidia-smi is not on PATH" where nvidia-smi >nul 2>nul || goto install_cpu +set "NO_GPU_WHY=nvidia-smi ran but listed no GPU" nvidia-smi -L >nul 2>nul || goto install_cpu -rem compute_cap reads "12.0" on an RTX 50-series card. A driver too old to know the query leaves -rem CAP_MAJOR unset and lands on cu126, which covers the widest range of what people actually own. -set "CAP_MAJOR=" -for /f "usebackq tokens=1 delims=." %%c in (`nvidia-smi --query-gpu=compute_cap --format=csv,noheader 2^>nul`) do call :cap_max %%c +call :read_gpu_probe +echo GPU probe (compute_cap, driver_version): !GPU_PROBE_RAW! set "TORCH_CHOICE=cu126" -if defined CAP_MAJOR if !CAP_MAJOR! GEQ 10 set "TORCH_CHOICE=cu130" +if !CAP_MAJOR! GEQ 10 set "TORCH_CHOICE=cu130" +rem Verified 2026-02 and load-bearing: cu128 still SERVES but is frozen at torch 2.11.0, and it was +rem the first index with sm_120. So a Blackwell card on a pre-R580 driver (CUDA 13's floor) has +rem exactly one workable choice; hand it that rather than erroring. Re-check the ceiling in a year. +if !CAP_MAJOR! GEQ 10 if !DRIVER_MAJOR! GTR 0 if !DRIVER_MAJOR! LSS 580 ( + set "TORCH_CHOICE=cu128" + set "TORCH_INDEX_REASON=driver-floor-cu128" + echo Driver !DRIVER_MAJOR!.xx predates R580, which CUDA 13 needs. Installing from cu128, + echo which is frozen at torch 2.11 and will never update. Updating the NVIDIA driver and + echo re-running --install gets you current torch via cu130. +) :install_torch_index if /i "!TORCH_CHOICE!"=="cpu" goto install_cpu_forced @@ -160,29 +175,92 @@ goto install_pkgs :install_cpu set "TORCH_CHOICE=cpu" -echo No NVIDIA GPU detected - installing the default (CPU) build of PyTorch. +set "TORCH_INDEX_REASON=no-gpu" +echo No NVIDIA GPU detected (!NO_GPU_WHY!) - installing the default (CPU) build of PyTorch. +echo On an AMD or Intel GPU this is not what you want: pass a full index URL, e.g. +echo .\webui.bat --install --torch-index https://download.pytorch.org/whl/rocm6.2 +echo See the README section "AMD (ROCm) setup". :install_pkgs +rem A venv reused from a bad install keeps its torch: uv leaves a satisfying version alone, so +rem neither a corrected detector nor --torch-index would replace it. Ask the installed torch whether +rem it actually has kernels for this card. +set "TORCH_FORCE=!TORCH_EXPLICIT!" +if "!TORCH_FORCE!"=="1" goto install_run +if /i "!TORCH_CHOICE!"=="cpu" goto install_run +if not defined TORCH_CHOICE goto install_run +set "PROBE_STATUS=unknown" +set "PROBE_REPLACEABLE=0" +for /f "usebackq tokens=1,2" %%a in (`"!TARGET_PY!" -c "from inline_core.device.probe import probe; p = probe(); print(p['status'], int(p['replaceable']))" 2^>nul`) do ( + set "PROBE_STATUS=%%a" + set "PROBE_REPLACEABLE=%%b" +) +if /i "!PROBE_STATUS!"=="uncovered" ( + if "!PROBE_REPLACEABLE!"=="1" ( + set "TORCH_FORCE=1" + echo The installed torch has no kernels for this GPU; replacing it from !TORCH_CHOICE!. + ) else ( + rem Not a pytorch.org build: a ROCm wheel, a nightly or something hand-built also fails the arch + rem rule, and reinstalling over a deliberate choice is worse than the wrong wheel. + echo WARNING: the installed torch has no kernels for this GPU, but it is not a pytorch.org + echo build, so it is left alone. To rebuild the environment from scratch: + echo .\webui.bat --install --extra !EXTRAS! --recreate + ) +) + +:install_run +echo + uv pip install --python "!TARGET_PY!" !TORCH_ARGS! -e ".[!EXTRAS!]" uv pip install --python "!TARGET_PY!" !TORCH_ARGS! -e ".[!EXTRAS!]" || goto fail +rem Torch LAST, and through --index-url (exclusive), when the index was named or the installed wheel +rem is wrong. It cannot ride on the project install: [tool.uv.sources] pins torch to the cu126 index +rem on win32, and --extra-index-url with unsafe-best-match picks the highest version ACROSS indexes, +rem which lands back on PyPI's CPU wheel whenever PyPI leads. +if "!TORCH_FORCE!"=="1" if defined TORCH_CHOICE ( + set "TORCH_PINS=torch torchvision" + rem cu128 is frozen, so the current pair does not exist there. Pin the last one it has. + if /i "!TORCH_CHOICE!"=="cu128" set "TORCH_PINS=torch==2.11.0 torchvision==0.26.0" + set "TORCH_URL=https://download.pytorch.org/whl/!TORCH_CHOICE!" + if /i "!TORCH_CHOICE:~0,4!"=="http" set "TORCH_URL=!TORCH_CHOICE!" + echo + uv pip install --python "!TARGET_PY!" --index-url !TORCH_URL! --reinstall !TORCH_PINS! + uv pip install --python "!TARGET_PY!" --index-url !TORCH_URL! --reinstall !TORCH_PINS! || goto fail +) uv pip install --python "!TARGET_PY!" inline-studio-frontend >nul 2>nul && echo Installed the prebuilt web UI (inline-studio-frontend). || echo Note: inline-studio-frontend not installed; the UI will build from source or run API-only. +call :write_install_record rem A CPU-only wheel on a GPU box is silent at runtime and ~100x slower, so say it here rather than rem let it through: it can still happen if PyPI ever outranks the CUDA index on version. if /i "!TORCH_CHOICE!"=="cpu" goto install_done "!TARGET_PY!" -c "import importlib, importlib.util, sys; spec = importlib.util.find_spec('torch'); sys.exit(0 if spec is None or importlib.import_module('torch').version.cuda else 1)" 2>nul && goto install_done echo WARNING: the torch that got installed is a CPU-ONLY build. Generation would run on the echo CPU, roughly 100x slower. Re-run with an explicit index, e.g. -echo .\webui.bat --install --torch-index cu126 +echo .\webui.bat --install --torch-index !TORCH_CHOICE! --recreate :install_done echo Installed extras: !EXTRAS!. Start with: .\webui.bat exit /b 0 -rem Keeps the highest compute-capability major seen. Anything not a plain number is ignored: an -rem older driver answers an unknown query with an error string instead of failing outright. -:cap_max -echo(%~1| findstr /r /c:"^[0-9][0-9]*$" >nul || exit /b 0 -if not defined CAP_MAJOR set "CAP_MAJOR=%~1" -if %~1 GTR %CAP_MAJOR% set "CAP_MAJOR=%~1" +rem Reads compute_cap and driver_version in one query, keeping the highest capability seen. +rem set /a rather than a findstr guard: an old driver answers an unknown query with an error string, +rem and `if LSS` would STRING-compare it, so "Unknown" would rank above 10 and win. set /a reads a +rem bare word as an undefined variable and yields 0, which is exactly what we want here, so do not +rem "fix" it back into a guard later. +:read_gpu_probe +set "CAP_MAJOR=0" +set "DRIVER_MAJOR=0" +set "GPU_PROBE_RAW=" +for /f "usebackq tokens=1,2 delims=., " %%c in (`nvidia-smi --query-gpu^=compute_cap^,driver_version --format^=csv^,noheader 2^>nul`) do ( + rem Reset every iteration: set /a errors on garbage and would otherwise leave the previous line's + rem value in place, double-counting a good line followed by a bad one. + set "CAP_TRY=0" + set "DRV_TRY=0" + set /a "CAP_TRY=%%c" 2>nul + set /a "DRV_TRY=%%d" 2>nul + if not defined GPU_PROBE_RAW ( set "GPU_PROBE_RAW=%%c.x, %%d" ) else ( set "GPU_PROBE_RAW=!GPU_PROBE_RAW!; %%c.x, %%d" ) + if !CAP_TRY! GTR !CAP_MAJOR! ( + set "CAP_MAJOR=!CAP_TRY!" + set "DRIVER_MAJOR=!DRV_TRY!" + ) +) +if not defined GPU_PROBE_RAW set "GPU_PROBE_RAW=" exit /b 0 rem --- Pick the Python interpreter (and matching pip), in priority order ------------------------- @@ -316,6 +394,57 @@ call npm run dev:web popd exit /b 0 +rem Detect-only: report the probe and the decision, change nothing. This is what CI asserts against +rem and what a bug report should paste, instead of a whole reinstall log. +:print_torch_index +if defined TORCH_CHOICE ( + set "TORCH_INDEX_REASON=override" + set "GPU_PROBE_RAW=" + set "CAP_MAJOR=0" + set "DRIVER_MAJOR=0" + where nvidia-smi >nul 2>nul && call :read_gpu_probe +) else ( + set "GPU_PROBE_RAW=" + set "CAP_MAJOR=0" + set "DRIVER_MAJOR=0" + set "TORCH_CHOICE=cpu" + set "TORCH_INDEX_REASON=no-gpu" + where nvidia-smi >nul 2>nul && nvidia-smi -L >nul 2>nul && call :decide_print_index +) +echo probe: !GPU_PROBE_RAW! +if "!CAP_MAJOR!"=="0" ( echo capability-major: unknown ) else ( echo capability-major: !CAP_MAJOR! ) +if "!DRIVER_MAJOR!"=="0" ( echo driver-major: unknown ) else ( echo driver-major: !DRIVER_MAJOR! ) +echo torch-index: !TORCH_CHOICE! +echo reason: !TORCH_INDEX_REASON! +exit /b 0 + +:decide_print_index +call :read_gpu_probe +set "TORCH_CHOICE=cu126" +set "TORCH_INDEX_REASON=autodetect" +if !CAP_MAJOR! GEQ 10 set "TORCH_CHOICE=cu130" +if !CAP_MAJOR! GEQ 10 if !DRIVER_MAJOR! GTR 0 if !DRIVER_MAJOR! LSS 580 ( + set "TORCH_CHOICE=cu128" + set "TORCH_INDEX_REASON=driver-floor-cu128" +) +exit /b 0 + +rem What the installer intended versus what landed. Nothing reads this yet; it exists so the next +rem bug report carries its own diagnosis. Beside the target interpreter, not a hardcoded .venv, +rem because --use-active-env means there may not be one. +:write_install_record +for %%I in ("!TARGET_PY!") do set "RECORD_DIR=%%~dpI.." +set "INLINE_RECORD=!RECORD_DIR!\.inline-install.json" +set "INLINE_RECORD_INDEX=!TORCH_CHOICE!" +if not defined INLINE_RECORD_INDEX set "INLINE_RECORD_INDEX=default" +set "INLINE_RECORD_REASON=!TORCH_INDEX_REASON!" +set "INLINE_RECORD_CAP=!CAP_MAJOR!" +set "INLINE_RECORD_DRIVER=!DRIVER_MAJOR!" +set "INLINE_RECORD_RAW=!GPU_PROBE_RAW!" +set "INLINE_RECORD_EXTRAS=!EXTRAS!" +"!TARGET_PY!" -c "import json, os; from inline_core.device.probe import probe; rec = {'index': os.environ['INLINE_RECORD_INDEX'], 'reason': os.environ['INLINE_RECORD_REASON'], 'capabilityMajor': os.environ.get('INLINE_RECORD_CAP') or None, 'driverMajor': os.environ.get('INLINE_RECORD_DRIVER') or None, 'probeRaw': os.environ.get('INLINE_RECORD_RAW') or None, 'extras': os.environ['INLINE_RECORD_EXTRAS'], 'installed': probe()}; open(os.environ['INLINE_RECORD'], 'w', encoding='utf-8').write(json.dumps(rec, indent=2))" 2>nul +exit /b 0 + :usage echo Usage: .\webui.bat [options] echo. @@ -348,8 +477,11 @@ echo --extra NAME add an install extra (repeatable): runtime, parall echo --torch-index WHICH with --install, override the PyTorch wheel index picked from your GPU's echo compute capability. A short name (cu130, cu128, cu126), a full index echo URL, or "cpu" to force the CPU-only build. Also settable as -echo INLINE_TORCH_INDEX. Use cu128 on a Blackwell card whose driver -echo predates CUDA 13. +echo INLINE_TORCH_INDEX. cu128 is frozen at torch 2.11 and exists only for +echo Blackwell cards whose driver predates R580; --install picks it for +echo you in that case. +echo --print-torch-index print what the GPU probe read and which index would be used, then +echo exit without installing anything echo --recreate with --install, rebuild .venv from scratch (discards anything installed echo into it by hand) echo --use-active-env install into / run from the environment activated in this shell instead diff --git a/core/webui.sh b/core/webui.sh index 47fe665..b9c760b 100755 --- a/core/webui.sh +++ b/core/webui.sh @@ -29,6 +29,7 @@ FORCE_REBUILD=0 SMART_MEMORY=0 USE_ACTIVE_ENV=0 RECREATE=0 +PRINT_TORCH_INDEX=0 TORCH_INDEX_CHOICE="${INLINE_TORCH_INDEX:-}" usage() { @@ -66,7 +67,10 @@ Setup --torch-index WHICH with --install, override the PyTorch wheel index picked from your GPU's compute capability. A short name (cu130, cu128, cu126), a full index URL, or "cpu" to force the CPU-only build. Also settable as INLINE_TORCH_INDEX. - Use cu128 on a Blackwell card whose driver predates CUDA 13. + cu128 is frozen at torch 2.11 and exists only for Blackwell cards whose + driver predates R580; --install picks it for you in that case. + --print-torch-index print what the GPU probe read and which index would be used, then exit + without installing anything --recreate with --install, rebuild ./.venv from scratch (discards anything installed into it by hand, e.g. a ROCm build of PyTorch) --use-active-env install into / run from the environment activated in this shell instead of @@ -116,6 +120,7 @@ while [[ $# -gt 0 ]]; do --install) RUN_INSTALL=1; shift ;; --extra) EXTRAS="$EXTRAS,${2:?--extra needs a name}"; shift 2 ;; --torch-index) TORCH_INDEX_CHOICE="${2:?--torch-index needs a name, URL or 'cpu'}"; shift 2 ;; + --print-torch-index) PRINT_TORCH_INDEX=1; shift ;; --recreate) RECREATE=1; shift ;; --use-active-env) USE_ACTIVE_ENV=1; shift ;; --dev) DEV_MODE=1; shift ;; @@ -169,27 +174,65 @@ is_windows() { esac } -# The highest compute-capability major across the installed GPUs (12 for an RTX 50-series card). -# Fails when the driver is too old to answer the query, which the caller treats as "unknown". -gpu_compute_cap_major() { - local caps cap major best="" - caps="$(nvidia-smi --query-gpu=compute_cap --format=csv,noheader 2>/dev/null)" || return 1 - while read -r cap; do +# One query for both facts, cached, so the raw line can be echoed verbatim. A silent guess is what +# made two field reports unfalsifiable: whatever we decide, the input is printed alongside it. +GPU_PROBE_RAW="" +GPU_CAP_MAJOR="" +GPU_DRIVER_MAJOR="" + +read_gpu_probe() { + local line cap driver major + GPU_PROBE_RAW="$(nvidia-smi --query-gpu=compute_cap,driver_version --format=csv,noheader 2>/dev/null || true)" + while IFS=, read -r cap driver; do + cap="${cap// /}"; driver="${driver// /}" major="${cap%%.*}" + # Numeric guard, not a coercion: an old driver answers an unknown query with an error string, + # and a bare word must never become a capability. [[ "$major" =~ ^[0-9]+$ ]] || continue - if [[ -z "$best" || "$major" -gt "$best" ]]; then best="$major"; fi - done <<<"$caps" - [[ -n "$best" ]] || return 1 - printf '%s\n' "$best" + if [[ -z "$GPU_CAP_MAJOR" || "$major" -gt "$GPU_CAP_MAJOR" ]]; then + GPU_CAP_MAJOR="$major" + GPU_DRIVER_MAJOR="${driver%%.*}" + fi + done <<<"$GPU_PROBE_RAW" } +#: Why the index was chosen, for the message and the install record: autodetect | driver-floor-cu128 +#: | override | no-gpu. +TORCH_INDEX_REASON="autodetect" + # No single index covers every card: Blackwell (sm_100/sm_120) exists only from cu128 on, while cu126 # is the last index still built for Maxwell..Volta (sm_50..sm_70). Unknown cards get cu126, the one # that covers the widest range of what people actually own. +# +# Verified 2026-02 and load-bearing: cu128 still SERVES but is frozen at torch 2.11.0, and it was the +# first index with sm_120. So a Blackwell card on a pre-R580 driver (CUDA 13's floor) gets cu128 +# rather than an error, because it has exactly one workable choice. Re-check that ceiling before +# trusting this comment in a year. +# Assigns TORCH_CHOICE and TORCH_INDEX_REASON rather than printing them. It must NOT be called +# through $(...): a command substitution is a subshell, so the probe globals and the reason would be +# discarded and only the index would survive. That is what the tests caught. pick_torch_index() { - local major - major="$(gpu_compute_cap_major)" || { printf 'cu126\n'; return 0; } - if [[ "$major" -ge 10 ]]; then printf 'cu130\n'; else printf 'cu126\n'; fi + read_gpu_probe + if [[ -z "$GPU_CAP_MAJOR" ]]; then TORCH_CHOICE="cu126"; return 0; fi + if [[ "$GPU_CAP_MAJOR" -ge 10 ]]; then + if [[ -n "$GPU_DRIVER_MAJOR" && "$GPU_DRIVER_MAJOR" -lt 580 ]]; then + TORCH_CHOICE="cu128" + TORCH_INDEX_REASON="driver-floor-cu128" + return 0 + fi + TORCH_CHOICE="cu130" + return 0 + fi + TORCH_CHOICE="cu126" +} + +# cu128 is frozen, so the current torch/torchvision pair does not exist there. Pin the last one it +# has instead of resolving, or uv picks whatever is newest on an index that stopped moving. +torch_pins_for() { + case "$1" in + cu128) printf 'torch==2.11.0 torchvision==0.26.0\n' ;; + *) printf 'torch torchvision\n' ;; + esac } torch_index_url() { @@ -199,6 +242,32 @@ torch_index_url() { esac } +# Detect-only: report the probe and the decision, change nothing. This is what CI asserts against +# and what a bug report should paste, instead of a whole reinstall log. +if [[ "$PRINT_TORCH_INDEX" -eq 1 ]]; then + TORCH_CHOICE="" + if [[ -n "$TORCH_INDEX_CHOICE" ]]; then + read_gpu_probe + PRINT_CHOICE="$TORCH_INDEX_CHOICE" + TORCH_INDEX_REASON="override" + elif command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi -L >/dev/null 2>&1; then + pick_torch_index + PRINT_CHOICE="$TORCH_CHOICE" + # Linux PyPI wheels already bundle CUDA, so the installer names no index there at all. + if ! is_windows; then PRINT_CHOICE="$PRINT_CHOICE (linux: installer uses the default PyPI wheels)"; fi + else + read_gpu_probe + PRINT_CHOICE="cpu" + TORCH_INDEX_REASON="no-gpu" + fi + echo "probe: ${GPU_PROBE_RAW:-}" + echo "capability-major: ${GPU_CAP_MAJOR:-unknown}" + echo "driver-major: ${GPU_DRIVER_MAJOR:-unknown}" + echo "torch-index: $PRINT_CHOICE" + echo "reason: $TORCH_INDEX_REASON" + exit 0 +fi + if [[ "$RUN_INSTALL" -eq 1 ]]; then command -v uv >/dev/null 2>&1 || { echo "uv not found: https://docs.astral.sh/uv/" >&2; exit 1; } normalize_extras @@ -232,17 +301,40 @@ if [[ "$RUN_INSTALL" -eq 1 ]]; then # slower, with no error. Which CUDA index is right depends on the card - see pick_torch_index. TORCH_INDEX=() TORCH_CHOICE="$TORCH_INDEX_CHOICE" + TORCH_EXPLICIT=0 + if [[ -n "$TORCH_CHOICE" ]]; then TORCH_EXPLICIT=1; TORCH_INDEX_REASON="override"; fi GPU_PRESENT=0 - if command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi -L >/dev/null 2>&1; then GPU_PRESENT=1; fi + NO_GPU_WHY="" + if ! command -v nvidia-smi >/dev/null 2>&1; then + NO_GPU_WHY="nvidia-smi is not on PATH" + elif ! nvidia-smi -L >/dev/null 2>&1; then + NO_GPU_WHY="nvidia-smi ran but listed no GPU" + else + GPU_PRESENT=1 + fi if [[ -z "$TORCH_CHOICE" ]]; then if [[ "$GPU_PRESENT" -eq 1 ]]; then - if is_windows; then TORCH_CHOICE="$(pick_torch_index)"; fi + if is_windows; then pick_torch_index; fi else TORCH_CHOICE="cpu" + TORCH_INDEX_REASON="no-gpu" fi fi + # Whatever we decided, show the input it came from. Both field reports were unfalsifiable because + # the launcher printed a conclusion and never the evidence. + if [[ "$GPU_PRESENT" -eq 1 && -n "$GPU_PROBE_RAW" ]]; then + echo "GPU probe (compute_cap, driver_version): $(printf '%s' "$GPU_PROBE_RAW" | tr '\n' ';')" + fi + if [[ "$TORCH_INDEX_REASON" == "driver-floor-cu128" ]]; then + echo "Driver ${GPU_DRIVER_MAJOR}.xx predates R580, which CUDA 13 needs. Installing from cu128," + echo "which is frozen at torch 2.11 and will never update. Updating the NVIDIA driver and" + echo "re-running --install gets you current torch via cu130." + fi if [[ "$TORCH_CHOICE" == "cpu" && "$GPU_PRESENT" -eq 0 ]]; then - echo "No NVIDIA GPU detected - installing the default (CPU) build of PyTorch." + echo "No NVIDIA GPU detected ($NO_GPU_WHY) - installing the default (CPU) build of PyTorch." + echo " On an AMD or Intel GPU this is not what you want: pass a full index URL, e.g." + echo " ./webui.sh --install --torch-index https://download.pytorch.org/whl/rocm6.2" + echo " See the README section \"AMD (ROCm) setup\"." elif [[ "$TORCH_CHOICE" == "cpu" ]]; then echo "Installing the default (CPU) build of PyTorch (--torch-index cpu)." elif [[ -z "$TORCH_CHOICE" ]]; then @@ -258,7 +350,38 @@ if [[ "$RUN_INSTALL" -eq 1 ]]; then --index-strategy unsafe-best-match --no-sources-package torch) echo "NVIDIA GPU detected - installing the CUDA build of PyTorch ($TORCH_CHOICE)." fi + # A venv reused from a bad install keeps its torch: uv leaves a satisfying version alone, so + # neither a corrected detector nor --torch-index would replace it. Ask the installed torch whether + # it actually covers this card. + TORCH_FORCE="$TORCH_EXPLICIT" + if [[ "$TORCH_FORCE" -eq 0 && "$GPU_PRESENT" -eq 1 && "$TORCH_CHOICE" != "cpu" && -n "$TORCH_CHOICE" ]]; then + PROBE_OUT="$("$TARGET_PY" -c 'from inline_core.device.probe import probe; p = probe(); print(p["status"], int(p["replaceable"]))' 2>/dev/null || true)" + read -r PROBE_STATUS PROBE_REPLACEABLE <<<"${PROBE_OUT:-unknown 0}" + if [[ "$PROBE_STATUS" == "uncovered" && "$PROBE_REPLACEABLE" == "1" ]]; then + TORCH_FORCE=1 + echo "The installed torch has no kernels for this GPU; replacing it from $TORCH_CHOICE." + elif [[ "$PROBE_STATUS" == "uncovered" ]]; then + # Not a pytorch.org build: a ROCm wheel, a nightly or something hand-built also fails the arch + # rule, and reinstalling over a deliberate choice is worse than the wrong wheel. + echo "WARNING: the installed torch has no kernels for this GPU, but it is not a pytorch.org" + echo " build, so it is left alone. To rebuild the environment from scratch:" + echo " ./webui.sh --install --extra $EXTRAS --recreate" + fi + fi + + echo "+ uv pip install --python $TARGET_PY ${TORCH_INDEX[*]} -e .[$EXTRAS]" uv pip install --python "$TARGET_PY" "${TORCH_INDEX[@]}" -e ".[$EXTRAS]" + # Torch LAST, and through --index-url (exclusive), when the index was named or the installed wheel + # is wrong. Two reasons it cannot ride on the project install: [tool.uv.sources] pins torch to the + # cu126 index on win32, and --extra-index-url with unsafe-best-match picks the highest version + # ACROSS indexes, which lands back on PyPI's CPU wheel whenever PyPI leads. Costs one possibly + # wasted download on a path that is rare and deliberate. + if [[ "$TORCH_FORCE" -eq 1 && -n "$TORCH_CHOICE" ]]; then + read -r -a TORCH_PINS <<<"$(torch_pins_for "$TORCH_CHOICE")" + TORCH_URL="$(torch_index_url "$TORCH_CHOICE")" + echo "+ uv pip install --python $TARGET_PY --index-url $TORCH_URL --reinstall ${TORCH_PINS[*]}" + uv pip install --python "$TARGET_PY" --index-url "$TORCH_URL" --reinstall "${TORCH_PINS[@]}" + fi # Pull the prebuilt web UI so there's no Node build (best-effort - it may not be published yet). uv pip install --python "$TARGET_PY" inline-studio-frontend >/dev/null 2>&1 \ && echo "Installed the prebuilt web UI (inline-studio-frontend)." \ @@ -274,6 +397,31 @@ sys.exit(0 if torch.version.cuda else 1)' 2>/dev/null; then echo " CPU, roughly 100x slower. Re-run with an explicit index, e.g." echo " ./webui.sh --install --torch-index cu126" fi + # What the installer intended versus what landed. Nothing reads this yet; it exists so the next + # bug report carries its own diagnosis instead of a guess. Beside the target interpreter, not a + # hardcoded .venv, because --use-active-env means there may not be one. + INLINE_RECORD_DIR="$(dirname "$(dirname "$TARGET_PY")")" + INLINE_RECORD="$INLINE_RECORD_DIR/.inline-install.json" \ + INLINE_RECORD_INDEX="${TORCH_CHOICE:-default}" \ + INLINE_RECORD_REASON="$TORCH_INDEX_REASON" \ + INLINE_RECORD_CAP="${GPU_CAP_MAJOR:-}" \ + INLINE_RECORD_DRIVER="${GPU_DRIVER_MAJOR:-}" \ + INLINE_RECORD_RAW="${GPU_PROBE_RAW:-}" \ + INLINE_RECORD_EXTRAS="$EXTRAS" \ + "$TARGET_PY" -c 'import json, os +from inline_core.device.probe import probe +rec = { + "index": os.environ["INLINE_RECORD_INDEX"], + "reason": os.environ["INLINE_RECORD_REASON"], + "capabilityMajor": os.environ.get("INLINE_RECORD_CAP") or None, + "driverMajor": os.environ.get("INLINE_RECORD_DRIVER") or None, + "probeRaw": os.environ.get("INLINE_RECORD_RAW") or None, + "extras": os.environ["INLINE_RECORD_EXTRAS"], + "installed": probe(), +} +with open(os.environ["INLINE_RECORD"], "w", encoding="utf-8") as fh: + json.dump(rec, fh, indent=2) +' 2>/dev/null || true echo "Installed extras: $EXTRAS. Start with: ./webui.sh" exit 0 fi diff --git a/src/renderer/views/Trainer/TrainerSettingsPanel.tsx b/src/renderer/views/Trainer/TrainerSettingsPanel.tsx index 9c454b7..4796b76 100644 --- a/src/renderer/views/Trainer/TrainerSettingsPanel.tsx +++ b/src/renderer/views/Trainer/TrainerSettingsPanel.tsx @@ -8,6 +8,7 @@ * resumable run asks first, then discards that run's checkpoints. */ import { useEffect, useState } from 'react' +import { resolveClipLength } from '@shared/clipGrid' import type { TrainingArch, @@ -168,6 +169,9 @@ export function TrainerSettingsPanel({ itemId }: { itemId: string }): React.JSX. const setArch = (next: TrainingArch): void => setHp((current) => ({ ...current, arch: next, baseMode: BASES[next][0].value })) + // What the typed seconds actually resolve to on H3's frame grid, shown under the field. + const resolved = resolveClipLength(hp.clipSeconds ?? 1) + const toggleGpu = (index: number): void => set( 'gpuIds', @@ -281,20 +285,47 @@ export function TrainerSettingsPanel({ itemId }: { itemId: string }): React.JSX. {arch === 'minimax-h3' && ( - + <> + + + + )} {QUANTIZABLE.includes(arch) && ( diff --git a/src/shared/clipGrid.test.ts b/src/shared/clipGrid.test.ts new file mode 100644 index 0000000..b23b4e1 --- /dev/null +++ b/src/shared/clipGrid.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { H3_MIN_CLIP_FRAMES, resolveClipLength, snapClipFrames } from './clipGrid' + +describe('H3 clip grid', () => { + // Pinned against Core's trim_reference_num_frames, which is the authority. If these drift, the + // Trainer shows a duration the run will not honour. + it('snaps down onto 17n+5, never up', () => { + expect([5, 22, 39, 56, 73, 90, 107, 124, 141].map(snapClipFrames)).toEqual([ + 22, 22, 39, 56, 73, 90, 107, 124, 141, + ]) + expect(snapClipFrames(120)).toBe(107) + expect(snapClipFrames(23)).toBe(22) + }) + + it('never returns less than the floor', () => { + expect(snapClipFrames(1)).toBe(H3_MIN_CLIP_FRAMES) + expect(snapClipFrames(0)).toBe(H3_MIN_CLIP_FRAMES) + expect(snapClipFrames(Number.NaN)).toBe(H3_MIN_CLIP_FRAMES) + }) + + it('resolves the seconds a user typed to what actually trains', () => { + // The case that prompted this: 5s of source trains on 4.458s, losing the last half second. + expect(resolveClipLength(5)).toEqual({ frames: 107, seconds: 107 / 24 }) + expect(resolveClipLength(1)).toEqual({ frames: 22, seconds: 22 / 24 }) + expect(resolveClipLength(5.2)).toEqual({ frames: 124, seconds: 124 / 24 }) + }) +}) diff --git a/src/shared/clipGrid.ts b/src/shared/clipGrid.ts new file mode 100644 index 0000000..829a0aa --- /dev/null +++ b/src/shared/clipGrid.ts @@ -0,0 +1,27 @@ +/** + * MiniMax H3's clip frame grid, mirrored from Core so the Trainer can show what a clip length + * actually resolves to before a run starts. + * + * H3's video VAE only encodes `17n + 5` frames at 24fps, and Core snaps **down** onto that grid + * (`trim_reference_num_frames`). Asking for 5s therefore trains on 4.458s, and the shortfall comes + * off the end. That was silent until the number appeared next to the field, and a user lost the + * action in the last half second of their clips to it. + */ + +/** H3's fixed frame rate. Never a parameter: it is what the grid is defined against. */ +export const H3_FPS = 24 + +/** The shortest clip the video VAE encodes: the first `17n + 5` above a single frame. */ +export const H3_MIN_CLIP_FRAMES = 22 + +/** The largest `17n + 5` that fits `frames`, or `H3_MIN_CLIP_FRAMES` when nothing does. */ +export function snapClipFrames(frames: number): number { + if (!Number.isFinite(frames) || frames < H3_MIN_CLIP_FRAMES) return H3_MIN_CLIP_FRAMES + return Math.floor((frames - 5) / 17) * 17 + 5 +} + +/** What `seconds` of clip actually trains on, as `{frames, seconds}` on the grid. */ +export function resolveClipLength(seconds: number): { frames: number; seconds: number } { + const frames = snapClipFrames(Math.round(seconds * H3_FPS)) + return { frames, seconds: frames / H3_FPS } +} diff --git a/src/shared/types.ts b/src/shared/types.ts index f4a3f67..e4bd8d7 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -520,10 +520,15 @@ export interface TrainingHyperparams { /** Square training resolution in px (e.g. 1024). */ resolution: number /** - * Seconds of each video clip to train on, for archs that accept clips. Snapped to the model's - * frame grid, and ignored entirely for stills. MiniMax H3's floor is 0.92s. + * Seconds of each video clip to train on, for archs that accept clips. Snapped **down** to the + * model's frame grid, and ignored entirely for stills. MiniMax H3's floor is 0.92s. */ clipSeconds?: number + /** + * Which end of a clip the training window is taken from. Snapping down always discards some of + * the clip, so `end` exists for footage whose action is at the finish. Defaults to `start`. + */ + clipWindow?: 'start' | 'end' /** Checkpoint every N steps. */ saveEvery: number /** GPU indices to train on; `[]` = auto (first available). */ From 405d337fac13179110175e83b8e56e174d53ca57 Mon Sep 17 00:00:00 2001 From: ashish-aesthisia Date: Sat, 8 Aug 2026 07:37:39 +0000 Subject: [PATCH 2/3] action test fix --- .github/workflows/launcher.yml | 13 +++++++++++-- core/webui.bat | 17 +++++++++++------ 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/.github/workflows/launcher.yml b/.github/workflows/launcher.yml index fe5e993..9d2e568 100644 --- a/.github/workflows/launcher.yml +++ b/.github/workflows/launcher.yml @@ -67,20 +67,29 @@ jobs: "@ | Set-Content -Path (Join-Path $dir 'nvidia-smi.bat') -Encoding ascii Add-Content -Path $env:GITHUB_PATH -Value $dir + - name: Check the stub is reachable and returns + shell: cmd + run: | + where nvidia-smi || (echo stub not on PATH & exit /b 1) + call nvidia-smi --query-gpu=compute_cap,driver_version --format=csv,noheader + echo stub returned control, errorlevel %errorlevel% + - name: Report the decision id: decide shell: cmd working-directory: core run: | call .\webui.bat --print-torch-index > decision.txt 2>&1 + echo ---------- decision.txt ---------- type decision.txt + echo ---------------------------------- - name: Assert the index and the reason shell: cmd working-directory: core run: | - findstr /c:"torch-index: ${{ matrix.expect }}" decision.txt >nul || (echo EXPECTED torch-index: ${{ matrix.expect }} & exit /b 1) - findstr /c:"reason: ${{ matrix.reason }}" decision.txt >nul || (echo EXPECTED reason: ${{ matrix.reason }} & exit /b 1) + findstr /c:"torch-index: ${{ matrix.expect }}" decision.txt >nul || (echo EXPECTED "torch-index: ${{ matrix.expect }}" but got: & type decision.txt & exit /b 1) + findstr /c:"reason: ${{ matrix.reason }}" decision.txt >nul || (echo EXPECTED "reason: ${{ matrix.reason }}" but got: & type decision.txt & exit /b 1) - name: Assert it installed nothing shell: cmd diff --git a/core/webui.bat b/core/webui.bat index 2862961..102a35e 100644 --- a/core/webui.bat +++ b/core/webui.bat @@ -140,7 +140,9 @@ if defined TORCH_CHOICE ( set "TORCH_INDEX_REASON=override" & goto install_torch set "NO_GPU_WHY=nvidia-smi is not on PATH" where nvidia-smi >nul 2>nul || goto install_cpu set "NO_GPU_WHY=nvidia-smi ran but listed no GPU" -nvidia-smi -L >nul 2>nul || goto install_cpu +rem `call`, because nvidia-smi on PATH is not always an .exe. A .bat or .cmd shim would otherwise +rem take over this script and never return, leaving no output and no error. +call nvidia-smi -L >nul 2>nul || goto install_cpu call :read_gpu_probe echo GPU probe (compute_cap, driver_version): !GPU_PROBE_RAW! set "TORCH_CHOICE=cu126" @@ -247,14 +249,17 @@ rem "fix" it back into a guard later. set "CAP_MAJOR=0" set "DRIVER_MAJOR=0" set "GPU_PROBE_RAW=" -for /f "usebackq tokens=1,2 delims=., " %%c in (`nvidia-smi --query-gpu^=compute_cap^,driver_version --format^=csv^,noheader 2^>nul`) do ( +rem Split on the comma ONLY. Including "." here would cut "12.0, 610.88" into 12 / 0 / 610 / 88, so +rem token 2 would be the capability's minor rather than the driver, and the R580 floor could never +rem fire. The majors are taken off each field by the inner loops. +for /f "usebackq tokens=1,2 delims=," %%c in (`nvidia-smi --query-gpu^=compute_cap^,driver_version --format^=csv^,noheader 2^>nul`) do ( rem Reset every iteration: set /a errors on garbage and would otherwise leave the previous line's rem value in place, double-counting a good line followed by a bad one. set "CAP_TRY=0" set "DRV_TRY=0" - set /a "CAP_TRY=%%c" 2>nul - set /a "DRV_TRY=%%d" 2>nul - if not defined GPU_PROBE_RAW ( set "GPU_PROBE_RAW=%%c.x, %%d" ) else ( set "GPU_PROBE_RAW=!GPU_PROBE_RAW!; %%c.x, %%d" ) + for /f "tokens=1 delims=. " %%m in ("%%c") do set /a "CAP_TRY=%%m" 2>nul + for /f "tokens=1 delims=. " %%n in ("%%d") do set /a "DRV_TRY=%%n" 2>nul + if not defined GPU_PROBE_RAW ( set "GPU_PROBE_RAW=%%c,%%d" ) else ( set "GPU_PROBE_RAW=!GPU_PROBE_RAW!; %%c,%%d" ) if !CAP_TRY! GTR !CAP_MAJOR! ( set "CAP_MAJOR=!CAP_TRY!" set "DRIVER_MAJOR=!DRV_TRY!" @@ -409,7 +414,7 @@ if defined TORCH_CHOICE ( set "DRIVER_MAJOR=0" set "TORCH_CHOICE=cpu" set "TORCH_INDEX_REASON=no-gpu" - where nvidia-smi >nul 2>nul && nvidia-smi -L >nul 2>nul && call :decide_print_index + where nvidia-smi >nul 2>nul && call nvidia-smi -L >nul 2>nul && call :decide_print_index ) echo probe: !GPU_PROBE_RAW! if "!CAP_MAJOR!"=="0" ( echo capability-major: unknown ) else ( echo capability-major: !CAP_MAJOR! ) From 0808b6311fb2412190b579c1a448f459b69f7042 Mon Sep 17 00:00:00 2001 From: ashish-aesthisia Date: Sat, 8 Aug 2026 07:56:51 +0000 Subject: [PATCH 3/3] bumped version to 1.2.65 --- core/pyproject.toml | 2 +- package.json | 2 +- packages/frontend/pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/core/pyproject.toml b/core/pyproject.toml index 649434f..e116f5b 100644 --- a/core/pyproject.toml +++ b/core/pyproject.toml @@ -1,7 +1,7 @@ [project] # PyPI name; the import package is `inline_core` (src/inline_core). name = "inline-core" -version = "1.2.64" +version = "1.2.65" description = "The generation engine behind Inline Studio." readme = "README.md" license = "GPL-3.0-or-later" diff --git a/package.json b/package.json index 56b231e..032ef07 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "inline-studio", - "version": "1.2.64", + "version": "1.2.65", "description": "AI filmmaking on a node canvas. Generate locally on your own GPU and train your own LoRAs on the same canvas, with the built-in Inline Core engine and hosted models. Every render is kept as a versioned take.", "keywords": [ "ai-filmmaking", diff --git a/packages/frontend/pyproject.toml b/packages/frontend/pyproject.toml index c32cf61..bbb38cc 100644 --- a/packages/frontend/pyproject.toml +++ b/packages/frontend/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "inline-studio-frontend" -version = "1.2.64" +version = "1.2.65" description = "Prebuilt Inline Studio web UI (SPA), served by Inline Core. Mirrors comfyui-frontend-package." requires-python = ">=3.9" readme = "README.md"