Skip to content
Open
39 changes: 23 additions & 16 deletions sd_installer/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,12 @@
"""

import argparse
import os
import sys
from pathlib import Path


def find_base_folder() -> Path:
"""
r"""
Find the StreamDiffusion base folder (where setup.py lives).

Runtime structure:
Expand All @@ -47,9 +46,9 @@ def find_base_folder() -> Path:
# __file__ = .../StreamDiffusion-installer/sd_installer/cli.py
# We want: .../StreamDiffusion/
this_file = Path(__file__).resolve()
sd_installer_pkg = this_file.parent # sd_installer/
installer_repo = sd_installer_pkg.parent # StreamDiffusion-installer/
base = installer_repo.parent # StreamDiffusion/
sd_installer_pkg = this_file.parent # sd_installer/
installer_repo = sd_installer_pkg.parent # StreamDiffusion-installer/
base = installer_repo.parent # StreamDiffusion/
if (base / "setup.py").exists():
return base

Expand Down Expand Up @@ -85,7 +84,7 @@ def cmd_check(args):
if venv_path.exists():
print(f"Venv: Found at {venv_path}")
else:
print(f"Venv: Not found (will be created during install)")
print("Venv: Not found (will be created during install)")

# Check StreamDiffusion setup.py (base folder IS StreamDiffusion)
setup_py = base / "setup.py"
Expand Down Expand Up @@ -197,15 +196,15 @@ def cmd_diagnose(args):
print(f" [{status}] {check['name']}")
if check["error"]:
# Print just the last line of the error
error_line = check["error"].split('\n')[-1][:60]
error_line = check["error"].split("\n")[-1][:60]
print(f" {error_line}")

return 0


def cmd_repair(args):
"""Auto-fix known issues."""
from .verifier import Verifier, KNOWN_ERRORS
from .verifier import Verifier

try:
base = Path(args.base_folder) if args.base_folder else find_base_folder()
Expand Down Expand Up @@ -236,6 +235,7 @@ def cmd_repair(args):
for check in info["checks"]:
if not check["passed"] and check["error"]:
from .verifier import match_known_error

fix = match_known_error(check["error"])
if fix:
fixes_needed.append((check["name"], fix))
Expand All @@ -245,10 +245,15 @@ def cmd_repair(args):
# numpy 2.x
numpy_ver = info["versions"].get("numpy", "")
if numpy_ver.startswith("2."):
fixes_needed.append(("numpy version", {
"cause": f"numpy {numpy_ver} detected (2.x breaks things)",
"fix": "pip install numpy==1.26.4 --force-reinstall"
}))
fixes_needed.append(
(
"numpy version",
{
"cause": f"numpy {numpy_ver} detected (2.x breaks things)",
"fix": "pip install numpy==1.26.4 --force-reinstall",
},
)
)

if not fixes_needed:
print("No known issues detected that can be auto-fixed.")
Expand All @@ -264,18 +269,19 @@ def cmd_repair(args):

if not args.yes:
response = input("Apply fixes? [y/N]: ")
if response.lower() != 'y':
if response.lower() != "y":
print("Aborted.")
return 0

# Apply fixes
import subprocess

for name, fix in fixes_needed:
print(f"Applying fix for {name}...")
cmd = [str(python_exe), "-m", "pip"] + fix["fix"].replace("pip ", "").split()
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode == 0:
print(f" OK")
print(" OK")
else:
print(f" FAILED: {result.stderr}")

Expand Down Expand Up @@ -314,7 +320,7 @@ def cmd_generate_bat(args):

def cmd_install_tensorrt(args):
"""Install TensorRT packages."""
from .tensorrt import install, get_cuda_version_from_torch
from .tensorrt import get_cuda_version_from_torch, install

print("StreamDiffusionTD TensorRT Installation")
print("=" * 40)
Expand Down Expand Up @@ -381,7 +387,8 @@ def main():
# repair command
repair_parser = subparsers.add_parser("repair", help="Auto-fix known issues")
repair_parser.add_argument(
"-y", "--yes",
"-y",
"--yes",
action="store_true",
help="Apply fixes without prompting",
)
Expand Down
153 changes: 131 additions & 22 deletions sd_installer/installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,10 @@
5. Verify imports - Catch failures immediately
"""

import os
import sys
import subprocess
import shutil
import sys
from pathlib import Path
from typing import Optional, Callable
from typing import Callable, Optional

# Version pins - packages NOT in setup.py that must be manually pinned
MANUAL_PINS = {
Expand All @@ -26,6 +24,10 @@
"python-osc": "", # Required for TouchDesigner OSC communication
"peft": "0.17.1", # Required for Cached Attention (StreamV2V) - enables USE_PEFT_BACKEND
"protobuf": "4.25.8", # Required by mediapipe, onnx/TensorRT - protobuf 6.x breaks serialization, setup.py requires >=4.25.8
# Security floor pins (transitive deps — pip resolves these on fresh install, but floor ensures upgrade on update)
"idna": ">=3.16", # CVE-2026-45409: punycode resource exhaustion
"Mako": ">=1.3.12", # CVE-2026-44307: Windows backslash path traversal
"urllib3": ">=2.7.0", # CVE-2026-44432/44431: response over-decompression; cross-origin redirect
}

# Pre-built insightface wheels for Windows (PyPI has no Windows wheels, requires C++ build tools)
Expand All @@ -36,6 +38,17 @@
(3, 12): "https://github.com/Gourieff/Assets/raw/main/Insightface/insightface-0.7.3-cp312-cp312-win_amd64.whl",
}

# Pre-built cuda-link wheel (CUDA-IPC zero-copy transport). setup.py's cuda-link pin lives only in
# the optional cuda_ipc extra as a git ref (compiled cp311 extension) — installing that extra would
# force an MSVC/nvcc source build. Install the prebuilt wheel directly instead, --no-deps, so this
# extra is never triggered. Only a cp311 wheel is published.
CUDA_LINK_WHEELS = {
(
3,
11,
): "https://github.com/forkni/cuda-link/releases/download/v1.12.1/cuda_link-1.12.1-cp311-cp311-win_amd64.whl",
}

# PyTorch configurations by CUDA version
PYTORCH_CONFIGS = {
"cu118": {
Expand Down Expand Up @@ -63,9 +76,9 @@
"xformers": None, # Skip - causes conflicts
},
"cu128": {
"torch": "2.7.0",
"torchvision": "0.22.0",
"torchaudio": "2.7.0",
"torch": "2.8.0",
"torchvision": "0.23.0",
"torchaudio": None,
"index_url": "https://download.pytorch.org/whl/cu128",
"cuda_python": "12.9.0",
"xformers": None, # Not needed - PyTorch 2.7+ has native SDPA
Expand Down Expand Up @@ -103,10 +116,7 @@ def __init__(

# Validate CUDA version
if cuda_version not in PYTORCH_CONFIGS:
raise ValueError(
f"Unsupported CUDA version: {cuda_version}. "
f"Supported: {list(PYTORCH_CONFIGS.keys())}"
)
raise ValueError(f"Unsupported CUDA version: {cuda_version}. Supported: {list(PYTORCH_CONFIGS.keys())}")

self.pytorch_config = PYTORCH_CONFIGS[cuda_version]

Expand Down Expand Up @@ -238,7 +248,7 @@ def phase3b_insightface(self):

version_str = result.stdout.strip()
try:
major, minor = map(int, version_str.split('.'))
major, minor = map(int, version_str.split("."))
py_version = (major, minor)
except ValueError:
print(f" WARNING: Could not parse Python version '{version_str}', skipping insightface pre-install")
Expand All @@ -260,6 +270,92 @@ def phase4_streamdiffusion(self):
# The -e flag makes it editable, setup.py handles all pinned versions
self._run_pip(["-e", ".[tensorrt,controlnet,ipadapter]"], check=True, cwd=self.streamdiffusion_path)

def phase4b_cuda_link(self):
"""Phase 4b: Install cuda-link from pre-built wheel (CUDA-IPC zero-copy transport).

Not covered by any setup.py extra actually installed above (cuda_ipc is intentionally
skipped to avoid a source build) — install the compiled wheel directly. Non-fatal: if no
wheel exists for this venv's Python, CUDA-IPC falls back to the mirror-DAT transport.
"""
result = self._run_python("import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')")
if result.returncode != 0:
print(" WARNING: Could not detect venv Python version, skipping cuda-link pre-install")
return

version_str = result.stdout.strip()
try:
major, minor = map(int, version_str.split("."))
py_version = (major, minor)
except ValueError:
print(f" WARNING: Could not parse Python version '{version_str}', skipping cuda-link pre-install")
return

wheel_url = CUDA_LINK_WHEELS.get(py_version)
if wheel_url:
self._report_progress(f"Installing cuda-link 1.12.1 from pre-built wheel (Python {version_str})...", 4, 8)
self._run_pip(["--no-deps", wheel_url], check=False)
else:
print(f" WARNING: No pre-built cuda-link wheel for Python {version_str}")
print(" CUDA-IPC zero-copy export will fall back to the mirror-DAT transport")

def phase4c_cuda_link_env(self):
"""Phase 4c: Persist CUDALINK_LIB_PATH and CUDALINK_DOORBELL (Windows only).

CUDALINK_LIB_PATH -> this venv's site-packages:
TouchDesigner's CUDALinkBootstrap.py reads CUDALINK_LIB_PATH at Text DAT import time to
enable "library mode" (sys.path injection of the installed cuda_link package, aliasing the
14 mirror DAT names). Persisting it here via `setx` means every TD process launched after
this install inherits it automatically -- no manual env-var step.

CUDALINK_DOORBELL=1:
Enables the Win32 named-event doorbell so the cuda-link native wait backend reaches its
low-latency target instead of silently falling back to poll-sleep. The SD<->TD topology is
bidirectional -- TD's Sender and SD's Exporter are each a producer on their own IPC leg --
and the doorbell event is only created by a producer whose CUDALINK_DOORBELL=1. TD's Sender
runs inside TD's own bundled-Python *process*, which reads its environment from user/system
scope only; a runtime `os.environ.setdefault` (as used for
CUDALINK_TORCH_GPU_WAIT_ADAPTIVE in td_manager.py) cannot reach a separate process, so this
must be persisted here instead. CUDALINK_WAIT_BACKEND is deliberately left unset -- its
default "auto" already selects the native path.

setx writes to HKCU\\Environment (user scope) and only affects processes started
*after* it runs, so TD must be (re)started after installation to pick it up. This
intentionally overwrites any prior manual value (e.g. an older cuda_link_lib\\ target).
Non-fatal: if setx fails or this isn't Windows, TD simply falls back to the mirror-DAT
classic mode (for CUDALINK_LIB_PATH) or the poll-sleep wait backend (for CUDALINK_DOORBELL).
"""
if sys.platform != "win32":
return # setx is a Windows-only mechanism; non-Windows TD launches are unaffected

result = self._run_python("import sysconfig; print(sysconfig.get_paths()['purelib'])")
if result.returncode != 0 or not result.stdout.strip():
print(" WARNING: Could not resolve venv site-packages path, skipping CUDALINK_LIB_PATH setup")
else:
site_packages = result.stdout.strip()
self._report_progress(f"Persisting CUDALINK_LIB_PATH -> {site_packages}", 4, 8)
setx_result = subprocess.run(
["setx", "CUDALINK_LIB_PATH", site_packages],
capture_output=True,
text=True,
)
if setx_result.returncode != 0:
print(f" WARNING: setx failed to persist CUDALINK_LIB_PATH: {setx_result.stderr.strip()}")
else:
print(" CUDALINK_LIB_PATH persisted for this user account.")
print(" Restart TouchDesigner (and any open shells) to pick up the new environment variable.")

# CUDALINK_DOORBELL=1 enables the Win32 named-event doorbell so the cuda-link native wait
# backend reaches its low-latency target. Must be set on the *producer* side, and SD's TD
# topology is bidirectional (TD Sender + SD Exporter are both producers). TD's Sender runs
# in TD's own bundled-Python *process*, which reads env from user/system scope only -- a
# runtime os.environ.setdefault in td_manager.py can't reach it, so it must be persisted
# here. Independent of the site-packages resolution above, so it runs even if that warned.
db_result = subprocess.run(["setx", "CUDALINK_DOORBELL", "1"], capture_output=True, text=True)
if db_result.returncode != 0:
print(f" WARNING: setx failed to persist CUDALINK_DOORBELL: {db_result.stderr.strip()}")
else:
print(" CUDALINK_DOORBELL=1 persisted (enables doorbell/native-wait IPC fast path).")

def phase5_missing_pins(self):
"""Phase 5: Install packages not pinned in setup.py and fix diffusers."""
self._report_progress("Installing packages not in setup.py (timm, python-osc, peft)...", 5, 8)
Expand All @@ -269,33 +365,44 @@ def phase5_missing_pins(self):

# Force reinstall varshith15 diffusers (other deps may have overwritten it)
self._report_progress("Ensuring varshith15 diffusers fork with kvo_cache support...", 5, 8)
self._run_pip([
"--force-reinstall", "--no-deps",
"diffusers @ git+https://github.com/varshith15/diffusers.git@3e3b72f557e91546894340edabc845e894f00922"
])
self._run_pip(
[
"--force-reinstall",
"--no-deps",
"diffusers @ git+https://github.com/varshith15/diffusers.git@3e3b72f557e91546894340edabc845e894f00922",
]
)

def phase6_conflict_prone(self):
"""Phase 6: Fix conflict-prone packages with --no-deps."""
self._report_progress("Fixing conflict-prone packages...", 6, 8)

# Remove conflicting opencv variants
subprocess.run(
[str(self.python_exe), "-m", "pip", "uninstall", "-y",
"opencv-python-headless", "opencv-contrib-python"],
[str(self.python_exe), "-m", "pip", "uninstall", "-y", "opencv-python-headless", "opencv-contrib-python"],
capture_output=True,
)

# Install correct opencv
self._run_pip(["--no-deps", f"opencv-python=={MANUAL_PINS['opencv-python']}"])

def phase7_numpy_lock(self):
"""Phase 7: Final numpy and protobuf lock (other packages may have upgraded them)."""
"""Phase 7: Final numpy/protobuf lock + security floor pins."""
self._report_progress(f"Final numpy lock (numpy=={MANUAL_PINS['numpy']})...", 7, 8)
self._run_pip([f"numpy=={MANUAL_PINS['numpy']}", "--force-reinstall"])

self._report_progress(f"Final protobuf lock (protobuf=={MANUAL_PINS['protobuf']})...", 7, 8)
self._run_pip([f"protobuf=={MANUAL_PINS['protobuf']}", "--force-reinstall"])

self._report_progress("Applying security floor pins (idna, Mako, urllib3)...", 7, 8)
self._run_pip(
[
f"idna{MANUAL_PINS['idna']}",
f"Mako{MANUAL_PINS['Mako']}",
f"urllib3{MANUAL_PINS['urllib3']}",
]
)

def phase8_verify(self) -> bool:
"""Phase 8: Verify installation with import tests."""
from .verifier import Verifier
Expand All @@ -315,7 +422,7 @@ def install(self, python_exe: Optional[str] = None) -> bool:
True if installation and verification succeeded.
"""
print("=" * 50)
print(" StreamDiffusionTD v0.3.1 Installation")
print(" StreamDiffusionTD v0.3.2 Installation")
print(" Daydream Fork with StreamV2V")
print("=" * 50)
print()
Expand All @@ -332,6 +439,8 @@ def install(self, python_exe: Optional[str] = None) -> bool:
self.phase3_xformers()
self.phase3b_insightface() # Pre-install insightface from wheel (Windows)
self.phase4_streamdiffusion()
self.phase4b_cuda_link() # Pre-install cuda-link from wheel (CUDA-IPC transport)
self.phase4c_cuda_link_env() # Persist CUDALINK_LIB_PATH -> venv (TD library mode)
self.phase5_missing_pins()
self.phase6_conflict_prone()
self.phase7_numpy_lock()
Expand Down Expand Up @@ -374,7 +483,7 @@ def generate_batch_file(self, output_path: Optional[str] = None, python_exe: Opt

content = f'''@echo off
echo ========================================
echo StreamDiffusionTD v0.3.1 Installation
echo StreamDiffusionTD v0.3.2 Installation
echo Daydream Fork with StreamV2V
echo ========================================

Expand All @@ -386,7 +495,7 @@ def generate_batch_file(self, output_path: Optional[str] = None, python_exe: Opt
pause
'''

with open(output_path, 'w', encoding='utf-8') as f:
with open(output_path, "w", encoding="utf-8") as f:
f.write(content)

print(f"Generated batch file: {output_path}")
Expand Down
Loading