Files
2026-02-10 21:45:34 -07:00

632 lines
24 KiB
PowerShell

<#
.SYNOPSIS
GUI Installer for ComfyUI Models using WinForms.
Reads install_manifest.json and lets user select models to download.
Updated with BITS transfer for responsiveness and cancellation support.
#>
param (
[string]$ManifestPath = "install_manifest.json",
[string]$DefaultModelRoot = "..\..\models"
)
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
# --- Global State ---
$script:cancelRequested = $false
$script:isDownloading = $false
# --- Helper Functions ---
function Get-ModelRoot {
$form = New-Object System.Windows.Forms.Form
$form.Text = "Select ComfyUI Model Root"
$form.Size = New-Object System.Drawing.Size(500, 150)
$form.StartPosition = "CenterScreen"
$form.FormBorderStyle = "FixedDialog"
$form.MaximizeBox = $false
$lbl = New-Object System.Windows.Forms.Label
$lbl.Text = "Please confirm the path to your 'models' directory:"
$lbl.Location = New-Object System.Drawing.Point(10, 10)
$lbl.AutoSize = $true
$form.Controls.Add($lbl)
$txt = New-Object System.Windows.Forms.TextBox
$txt.Text = (Resolve-Path $DefaultModelRoot -ErrorAction SilentlyContinue).Path
if (-not $txt.Text) { $txt.Text = $DefaultModelRoot }
$txt.Location = New-Object System.Drawing.Point(10, 35)
$txt.Size = New-Object System.Drawing.Size(380, 20)
$form.Controls.Add($txt)
$btnBrowse = New-Object System.Windows.Forms.Button
$btnBrowse.Text = "Browse..."
$btnBrowse.Location = New-Object System.Drawing.Point(400, 34)
$btnBrowse.Size = New-Object System.Drawing.Size(75, 23)
$btnBrowse.Add_Click({
$fbd = New-Object System.Windows.Forms.FolderBrowserDialog
$fbd.SelectedPath = $txt.Text
if ($fbd.ShowDialog() -eq "OK") {
$txt.Text = $fbd.SelectedPath
}
})
$form.Controls.Add($btnBrowse)
$btnOK = New-Object System.Windows.Forms.Button
$btnOK.Text = "OK"
$btnOK.DialogResult = "OK"
$btnOK.Location = New-Object System.Drawing.Point(200, 80)
$form.Controls.Add($btnOK)
$form.AcceptButton = $btnOK
if ($form.ShowDialog() -eq "OK") {
return $txt.Text
}
return $null
}
# --- Main Logic ---
if (-not (Test-Path $ManifestPath)) {
[System.Windows.Forms.MessageBox]::Show("Manifest file not found: $ManifestPath", "Error", "OK", "Error")
exit
}
try {
$json = Get-Content -Raw $ManifestPath | ConvertFrom-Json
$items = $json.downloads
} catch {
[System.Windows.Forms.MessageBox]::Show("Error reading manifest JSON.", "Error", "OK", "Error")
exit
}
# --- Load VRAM Map ---
$vramMap = @{}
if (Test-Path "model_registry.json") {
try {
$reg = Get-Content -Raw "model_registry.json" | ConvertFrom-Json
foreach ($p in $reg.PSObject.Properties) {
if ($p.Value -is [System.Array]) {
foreach ($m in $p.Value) {
if ($m.name -and $m.min_vram) {
$vramMap[$m.name] = $m.min_vram
}
}
}
}
} catch {}
}
$rootPath = Get-ModelRoot
if (-not $rootPath) { exit }
# Create UI
$mainForm = New-Object System.Windows.Forms.Form
$mainForm.Text = "ComfyUI Cluster - Model Installer"
$mainForm.Size = New-Object System.Drawing.Size(800, 600)
$mainForm.StartPosition = "CenterScreen"
# Safety: Handle closing while downloading
$mainForm.Add_FormClosing({
if ($script:isDownloading) {
$result = [System.Windows.Forms.MessageBox]::Show("Downloads are in progress. Cancel operation and exit?", "Confirm Exit", [System.Windows.Forms.MessageBoxButtons]::YesNo, [System.Windows.Forms.MessageBoxIcon]::Warning)
if ($result -eq [System.Windows.Forms.DialogResult]::Yes) {
$script:cancelRequested = $true
# Give loops a moment to see the flag
} else {
$_.Cancel = $true
}
}
})
$split = New-Object System.Windows.Forms.SplitContainer
$split.Dock = "Fill"
$split.Orientation = "Horizontal"
$split.SplitterDistance = 400
$mainForm.Controls.Add($split)
# Top: Filter & List
$topPanelUser = New-Object System.Windows.Forms.Panel
$topPanelUser.Dock = "Fill"
$split.Panel1.Controls.Add($topPanelUser)
$filterPanel = New-Object System.Windows.Forms.Panel
$filterPanel.Dock = "Top"
$filterPanel.Height = 35
$topPanelUser.Controls.Add($filterPanel)
$lblVram = New-Object System.Windows.Forms.Label
$lblVram.Text = "Max VRAM:"
$lblVram.AutoSize = $true
$lblVram.Location = New-Object System.Drawing.Point(10, 8)
$filterPanel.Controls.Add($lblVram)
$cmbVram = New-Object System.Windows.Forms.ComboBox
$cmbVram.Location = New-Object System.Drawing.Point(85, 5)
$cmbVram.Width = 100
$cmbVram.DropDownStyle = "DropDownList"
$arr = @("Any", "24 GB", "16 GB", "12 GB", "8 GB", "6 GB")
foreach($a in $arr) { $cmbVram.Items.Add($a) }
$cmbVram.SelectedIndex = 0
$filterPanel.Controls.Add($cmbVram)
$chkList = New-Object System.Windows.Forms.CheckedListBox
$chkList.Dock = "Fill"
$chkList.CheckOnClick = $true
$chkList.Font = New-Object System.Drawing.Font("Consolas", 10)
$topPanelUser.Controls.Add($chkList)
$topPanelUser.Controls.SetChildIndex($filterPanel, 0)
# Bottom: Log
$progressPanel = New-Object System.Windows.Forms.Panel
$progressPanel.Dock = "Bottom"
$progressPanel.Height = 50
$split.Panel2.Controls.Add($progressPanel)
$lblProgress = New-Object System.Windows.Forms.Label
$lblProgress.Text = "Ready."
$lblProgress.AutoSize = $true
$lblProgress.Location = New-Object System.Drawing.Point(5, 5)
$lblProgress.Font = New-Object System.Drawing.Font("Segoe UI", 9)
$progressPanel.Controls.Add($lblProgress)
$progressBar = New-Object System.Windows.Forms.ProgressBar
$progressBar.Location = New-Object System.Drawing.Point(5, 25)
$progressBar.Width = 750
$progressBar.Height = 20
$progressBar.Anchor = "Left, Right, Top"
$progressPanel.Controls.Add($progressBar)
$logBox = New-Object System.Windows.Forms.TextBox
$logBox.Multiline = $true
$logBox.ScrollBars = "Vertical"
$logBox.ReadOnly = $true
$logBox.Dock = "Fill"
$logBox.BackColor = "Black"
$logBox.ForeColor = "Lime"
$logBox.Text = "Ready. Select models to install.`r`n"
$split.Panel2.Controls.Add($logBox)
# Populate List
$globalItems = @()
foreach ($item in $items) {
if (-not $item.filename) { continue }
$subdir = if ($item.subdir) { $item.subdir } else { "checkpoints" }
$targetPath = Join-Path $rootPath $subdir
$fullPath = Join-Path $targetPath $item.filename
$exists = Test-Path $fullPath
# Smart Fallback: Check other common folders if not found in strict location
# Many users (or older ComfyUI setups) mix checkpoints/diffusion_models/unet
if (-not $exists) {
$fallbacks = @()
if ($subdir -in @("diffusion_models", "unet", "checkpoints")) {
$fallbacks += "checkpoints"
$fallbacks += "diffusion_models"
$fallbacks += "unet"
}
foreach ($fb in $fallbacks) {
if ($fb -eq $subdir) { continue }
$altPath = Join-Path (Join-Path $rootPath $fb) $item.filename
if (Test-Path $altPath) {
$exists = $true
$fullPath = $altPath # Use the found path so we don't re-download
break
}
}
}
$prefix = if ($exists) { "[INSTALLED]" } else { "[MISSING] " }
$display = "{0} {1} ({2})" -f $prefix, $item.filename, $item.subdir
$idx = $chkList.Items.Add($display)
# Store custom object for later retrieval
$obj = New-Object PSObject -Property @{
URL = $item.url;
FullPath = $fullPath;
Filename = $item.filename;
DisplayIndex = $idx;
Exists = $exists
}
$globalItems += $obj
}
# --- Install Action ---
$panelButtons = New-Object System.Windows.Forms.FlowLayoutPanel
$panelButtons.Dock = "Bottom"
$panelButtons.Height = 40
$split.Panel1.Controls.Add($panelButtons)
$btnInstall = New-Object System.Windows.Forms.Button
$btnInstall.Text = "Install Selected"
$btnInstall.Width = 120
$btnInstall.Height = 30
$panelButtons.Controls.Add($btnInstall)
$btnCancel = New-Object System.Windows.Forms.Button
$btnCancel.Text = "Cancel"
$btnCancel.Width = 80
$btnCancel.Height = 30
$btnCancel.Enabled = $false
$panelButtons.Controls.Add($btnCancel)
$btnSelectMissing = New-Object System.Windows.Forms.Button
$btnSelectMissing.Text = "Select All Missing"
$btnSelectMissing.Width = 120
$btnSelectMissing.Height = 30
$btnSelectMissing.Add_Click({
for($i=0; $i -lt $chkList.Items.Count; $i++) {
$obj = $globalItems[$i]
if (-not $obj.Exists) {
$chkList.SetItemChecked($i, $true)
}
}
})
$panelButtons.Controls.Add($btnSelectMissing)
$btnExit = New-Object System.Windows.Forms.Button
$btnExit.Text = "Exit"
$btnExit.Width = 80
$btnExit.Height = 30
$btnExit.Add_Click({ $mainForm.Close() })
$panelButtons.Controls.Add($btnExit)
# Event Handlers
$btnCancel.Add_Click({
if ($script:isDownloading) {
$script:cancelRequested = $true
$logBox.AppendText("Cancellation Requested...`r`n")
$btnCancel.Enabled = $false # Prevent double click
}
})
$btnInstall.Add_Click({
$filesToDownload = @()
foreach ($idx in $chkList.CheckedIndices) {
$filesToDownload += $globalItems[$idx]
}
if ($filesToDownload.Count -eq 0) {
[System.Windows.Forms.MessageBox]::Show("No items selected.")
return
}
$logBox.AppendText("Starting Download of $($filesToDownload.Count) items...`r`n")
# Lock UI
$script:isDownloading = $true
$script:cancelRequested = $false
$btnInstall.Enabled = $false
$btnCancel.Enabled = $true
$btnExit.Enabled = $false
$btnSelectMissing.Enabled = $false
$failedDownloads = @()
$errorLogPath = Join-Path $PWD "install_errors.log"
# Append header to log instead of overwriting
$dateStr = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss")
"`r`n[$dateStr] --- Starting New Download Batch ---" | Out-File -FilePath $errorLogPath -Append -Encoding utf8
foreach ($f in $filesToDownload) {
if ($script:cancelRequested) { break }
$progressBar.Value = 0
$lblProgress.Text = "Initializing..."
$url = $f.URL
# Ensure we are using the absolute path or resolved path stored
$dest = $f.FullPath
$dir = Split-Path $dest
if (-not (Test-Path $dir)) {
$logBox.AppendText("Creating directory: $dir`r`n")
New-Item -ItemType Directory -Force -Path $dir | Out-Null
}
if (Test-Path $dest) {
$existingSize = (Get-Item $dest).Length
# Increase threshold to 512KB to catch HTML error pages (often 100-300KB)
if ($existingSize -gt 524288) {
$logBox.AppendText("Skipping (Exists, $existingSize bytes): $($f.Filename)`r`n")
continue
}
$logBox.AppendText("File exists but small ($existingSize bytes). Overwriting: $($f.Filename)`r`n")
}
if (-not $url -or $url.Trim() -eq "") {
$logBox.AppendText("ERROR: No URL for $($f.Filename)`r`n")
continue
}
# --- Auto-Resolve Civitai Model Paths ---
# Checks if URL is a generic model page (e.g. civitai.com/models/12345) and resolves to download URL via API
if ($url -match 'https?://civitai\.com/models/(\d+)') {
$civitaiId = $matches[1]
if ($url -notmatch '/api/download/') {
$logBox.AppendText("Resolving Civitai Model Link (ID: $civitaiId)...`r`n")
$mainForm.Refresh()
try {
$apiUri = "https://civitai.com/api/v1/models/$civitaiId"
# Use WebRequest for Timeout control (10s)
$req = [System.Net.WebRequest]::Create($apiUri)
$req.Timeout = 10000
$resp = $req.GetResponse()
$reader = New-Object System.IO.StreamReader($resp.GetResponseStream())
$jsonContent = $reader.ReadToEnd()
$reader.Close()
$resp.Close()
$meta = $jsonContent | ConvertFrom-Json
# Attempt to find the correct file
if ($meta.modelVersions -and $meta.modelVersions.Count -gt 0) {
# Default to the first file of the first (latest) version
$ver = $meta.modelVersions[0]
if ($ver.files -and $ver.files.Count -gt 0) {
$resolvedUrl = $ver.files[0].downloadUrl
if ($resolvedUrl) {
$url = $resolvedUrl
$logBox.AppendText(" -> Resolved: $url`r`n")
}
}
}
} catch {
$logBox.AppendText("WARNING: Failed to resolve Civitai API for ID $civitaiId. Using original URL.`r`n")
}
}
}
# ----------------------------------------
$logBox.AppendText("Downloading: $($f.Filename)`r`n To: $dest `r`n")
$mainForm.Refresh()
$downloadSuccess = $false
# ----------------------------------------
$isCivitai = $url -match "civitai.com"
# BITS often fails with Civitai (403 Forbidden) due to missing User-Agent/Headers.
# We will skip BITS for Civitai unless we can configure it, favoring the fallback which we can patch.
# --- Method 1: BITS Transfer ---
if (-not $isCivitai) {
try {
# Use BITS Transfer
if (-not (Get-Module -ListAvailable BitsTransfer)) {
$logBox.AppendText("WARNING: BitsTransfer module not found, skipping to fallback...`r`n")
} else {
Import-Module BitsTransfer -ErrorAction SilentlyContinue
# Start job
$job = Start-BitsTransfer -Source $url -Destination $dest -Asynchronous -DisplayName "ComfyUI_Cluster_DL" -Priority Foreground
$bitsLastCheck = [DateTime]::Now
$bitsLastBytes = 0
$bitsMaxStallSec = 60 # Timeout if no progress for 60s
# Monitoring loop
while ($job.JobState -eq "Transferring" -or $job.JobState -eq "Connecting" -or $job.JobState -eq "Queued") {
[System.Windows.Forms.Application]::DoEvents()
if ($script:cancelRequested) {
$logBox.AppendText("Cancellation Requested... stopping BITS job.`r`n")
Remove-BitsTransfer -BitsJob $job -ErrorAction SilentlyContinue
break
}
# Timeout logic
$currentBytes = 0
if ($job.BytesTransferred) { $currentBytes = $job.BytesTransferred }
# Reset timeout if progress made OR if state changed from Connecting/Queued
if ($currentBytes -gt $bitsLastBytes) {
$bitsLastBytes = $currentBytes
$bitsLastCheck = [DateTime]::Now
} elseif ($job.JobState -ne "Connecting" -and $job.JobState -ne "Queued" -and $currentBytes -eq 0) {
# Just started transferring but 0 bytes?
}
if (((Get-Date) - $bitsLastCheck).TotalSeconds -gt $bitsMaxStallSec) {
$logBox.AppendText("BITS Timeout: No progress for $bitsMaxStallSec seconds. Killing job.`r`n")
Remove-BitsTransfer -BitsJob $job -ErrorAction SilentlyContinue
break
}
# Update visual progress if possible
# Update visual progress if possible
if ($job.BytesTotal -gt 0) {
$pct = [math]::Round(($job.BytesTransferred / $job.BytesTotal) * 100)
$progressBar.Value = $pct
$lblProgress.Text = "BITS: {0:N2} MB / {1:N2} MB" -f ($job.BytesTransferred / 1MB), ($job.BytesTotal / 1MB)
}
Start-Sleep -Milliseconds 200
}
if ($script:cancelRequested) { break }
# Finalize
if ($job.JobState -eq "Transferred") {
Complete-BitsTransfer -BitsJob $job
$downloadSuccess = $true
$logBox.AppendText("BITS Download DONE.`r`n")
} else {
$errDesc = $job.ErrorDescription
$logBox.AppendText("BITS Failed ($($job.JobState)): $errDesc`r`n")
Remove-BitsTransfer -BitsJob $job -ErrorAction SilentlyContinue
}
}
} catch {
$logBox.AppendText("BITS Exception: $_`r`n")
}
} else {
$logBox.AppendText("Skipping BITS for Civitai URL (requires User-Agent)...`r`n")
}
# --- Method 2: Fallback (WebClient) ---
if (-not $downloadSuccess -and -not $script:cancelRequested) {
# Force TLS 1.2 which is required by most modern servers (Civitai, HuggingFace)
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$logBox.AppendText(">> Starting Download (WebClient)...`r`n")
$mainForm.Refresh()
try {
$wc = New-Object System.Net.WebClient
$wc.Headers.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
$wc.Headers.Add("Referer", $url)
# Event based async to prevent total freeze, keeping DoEvents active
$paramSync = [hashtable]::Synchronized(@{ "Complete" = $false; "Error" = $null; "BytesIn" = 0; "TotalBytes" = 0 })
$wc.Add_DownloadFileCompleted({
param($sender, $e)
$paramSync["Complete"] = $true
if ($e.Error) { $paramSync["Error"] = $e.Error }
})
$wc.Add_DownloadProgressChanged({
param($sender, $e)
$paramSync["BytesIn"] = $e.BytesReceived
$paramSync["TotalBytes"] = $e.TotalBytesToReceive
})
$wc.DownloadFileAsync($url, $dest)
$webLastCheck = [DateTime]::Now
$webLastBytes = 0
$webMaxStallSec = 60
while (-not $paramSync["Complete"]) {
[System.Windows.Forms.Application]::DoEvents()
# Timeout / Stall Detection
$curr = $paramSync["BytesIn"]
if ($curr -gt $webLastBytes) {
$webLastBytes = $curr
$webLastCheck = [DateTime]::Now
}
if (((Get-Date) - $webLastCheck).TotalSeconds -gt $webMaxStallSec) {
$logBox.AppendText("WebClient Timeout: No progress for $webMaxStallSec seconds.`r`n")
$wc.CancelAsync()
break
}
if ($script:cancelRequested) {
$wc.CancelAsync()
$logBox.AppendText("Download CANCELLED by user.`r`n")
break
}
if ($paramSync["TotalBytes"] -gt 0) {
$curr = $paramSync["BytesIn"]
$tot = $paramSync["TotalBytes"]
$pct = [math]::Round(($curr / $tot) * 100)
if ($pct -gt 100) { $pct = 100 }
$progressBar.Value = $pct
$lblProgress.Text = "WebClient: {0:N2} MB / {1:N2} MB" -f ($curr / 1MB), ($tot / 1MB)
}
Start-Sleep -Milliseconds 100
}
if ($script:cancelRequested) { break }
if ($paramSync["Error"]) {
throw $paramSync["Error"]
}
$downloadSuccess = $true
$progressBar.Value = 100
$logBox.AppendText("Download DONE.`r`n")
} catch {
$err = $_
$logBox.AppendText("Download FAILED: $err`r`n")
# Cleanup failed partial file
if (Test-Path $dest) { Remove-Item $dest -Force -ErrorAction SilentlyContinue }
$msg = "File: $($f.Filename) | URL: $url | Error: $err"
$failedDownloads += $msg
$msg | Out-File -FilePath $errorLogPath -Append -Encoding utf8
}
}
# --- Final Verification ---
if ($downloadSuccess) {
if (Test-Path $dest) {
$finalSize = (Get-Item $dest).Length
$logBox.AppendText("VERIFIED. Size: $finalSize bytes.`r`n")
} else {
$logBox.AppendText("ERROR: Reported success but file is missing!`r`n")
$msg = "File: $($f.Filename) | URL: $url | Error: Verification Failed (Missing)"
$failedDownloads += $msg
$msg | Out-File -FilePath $errorLogPath -Append -Encoding utf8
}
} else {
# Check if we already logged a specific error (via WebClient catch)
# Easier way: check if the last failure matches current file
$alreadyLogged = $false
if ($failedDownloads.Count -gt 0) {
$lastFail = $failedDownloads[$failedDownloads.Count - 1]
if ($lastFail -match [regex]::Escape($f.Filename)) { $alreadyLogged = $true }
}
if (-not $alreadyLogged -and -not $script:cancelRequested) {
$msg = "File: $($f.Filename) | URL: $url | Error: General Failure (BITS/WebClient)"
$failedDownloads += $msg
$msg | Out-File -FilePath $errorLogPath -Append -Encoding utf8
# Final safety cleanup for any leftover 0-byte placeholders
try {
if (Test-Path $dest) { Remove-Item $dest -Force -ErrorAction SilentlyContinue }
} catch {
$logBox.AppendText("WARNING: Cleanup failed for $dest`r`n")
}
}
}
$mainForm.Refresh()
}
if ($failedDownloads.Count -gt 0) {
$logBox.AppendText("`r`nWARNING: $($failedDownloads.Count) downloads failed. See 'install_errors.log'`r`n")
# We already appended errors one-by-one inside the loop, so no need to dump them all again here.
# This prevents double logging and overwriting.
}
if ($script:cancelRequested) {
$logBox.AppendText("Batch Cancelled.`r`n")
} else {
$logBox.AppendText("Batch Completed.`r`n")
[System.Windows.Forms.MessageBox]::Show("Download Batch Complete.", "Done")
}
# Unlock UI
$script:isDownloading = $false
$script:cancelRequested = $false
$btnInstall.Enabled = $true
$btnCancel.Enabled = $false
$btnExit.Enabled = $true
$btnSelectMissing.Enabled = $true
})
$mainForm.Add_Shown({
$mainForm.Activate()
})
[void]$mainForm.ShowDialog()