@echo off set "AIZG_SELF=%~f0" powershell -NoProfile -ExecutionPolicy Bypass -Command "$s=[IO.File]::ReadAllText($env:AIZG_SELF,[Text.Encoding]::UTF8);$i=$s.IndexOf('#PS'+'START');Invoke-Expression $s.Substring($i)" exit /b #PSSTART # AI追光 · 本地引擎安装程序(出图:stable-diffusion.cpp + FLUX.2 klein 4B / Z-Image;视频:WanGP + LTX-2.5) $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' trap { Write-Host ''; Write-Host "出错了:$_" -ForegroundColor Red; Write-Host '可以再双击一次安装程序重试(已下载的部分会接着下)。还不行就把这个窗口截图发到 AI追光。'; Read-Host '按回车关闭'; exit 1 } try { $Host.UI.RawUI.WindowTitle = 'AI追光 · 本地引擎安装' } catch {} Write-Host '' Write-Host ' ==============================================' -ForegroundColor Cyan Write-Host ' AI追光 · 追光本地引擎 安装程序' -ForegroundColor Cyan Write-Host ' ==============================================' -ForegroundColor Cyan Write-Host ' 用你自己的显卡出图、改图、做视频:不花积分、不限次数、作品不上传。' Write-Host ' 会装这些模型(只下一次,中途断了再双击安装程序会接着下):' Write-Host ' 必装 FLUX.2 klein 4B 文生图、参考图改图、多图合成(约 7GB)' -ForegroundColor Green Write-Host ' 可选 Z-Image Turbo 在图里写中文字、真实照片感(约 4.5GB)' -ForegroundColor DarkGray Write-Host ' 可选 LTX-2.5 视频 文生视频、首尾帧、带声音(视频引擎 WanGP,约 35GB,要 NVIDIA 显卡 + 32GB 内存)' -ForegroundColor DarkGray Write-Host ' 顺序:选安装位置 → 选下载源 → 下载必装的 FLUX → 再逐个问你要不要装两个可选的(回车 = 装,n = 不装)。' Write-Host '' # ---- 1. 安装位置 ---- $prev = $null try { $prev = (Get-ItemProperty -Path 'HKCU:\Software\AIZG\Engine' -Name InstallDir -ErrorAction Stop).InstallDir } catch {} # 默认位置:优先放在剩余空间最大、且够 12GB 的非系统盘(D/E/F…);都没有就放 C 盘 $disks = @(Get-CimInstance Win32_LogicalDisk -Filter 'DriveType=3' -ErrorAction SilentlyContinue | Sort-Object FreeSpace -Descending) $sys = $env:SystemDrive $pick = $disks | Where-Object { $_.DeviceID -ne $sys -and $_.FreeSpace -gt 12GB } | Select-Object -First 1 if (-not $pick) { $pick = $disks | Where-Object { $_.DeviceID -eq $sys } | Select-Object -First 1 } if (-not $pick) { $pick = $disks | Select-Object -First 1 } $root = if ($pick) { $pick.DeviceID } else { $sys } if ($prev) { $def = $prev } else { $def = "$root\AIZG-FLUX.2-klein" } if ($disks.Count) { Write-Host '你电脑上的硬盘:' -ForegroundColor DarkGray foreach ($d in $disks) { Write-Host (' {0} 剩余 {1:N0} GB' -f $d.DeviceID, ($d.FreeSpace / 1GB)) -ForegroundColor DarkGray } } Write-Host '出图需要约 10GB 空间;选装 Z-Image 再加 5GB;视频(WanGP)另装在同一个盘的 \Wan2GP,约 35GB。想换位置就输入完整路径,例如 E:\AIZG-FLUX.2-klein' -ForegroundColor DarkGray while ($true) { $in = Read-Host "安装位置(直接回车 = $def )" $dir = if ($in) { $in.Trim().Trim('"') } else { $def } if ($dir -match '[^\x20-\x7E]') { Write-Host '路径里不能有中文或全角字符(引擎读不了),请换一个全英文的路径,例如 E:\AIZG-FLUX.2-klein' -ForegroundColor Yellow; $def = "$root\AIZG-FLUX.2-klein"; continue } if ($dir -notmatch '^[A-Za-z]:\\') { Write-Host '请输入完整路径,要带盘符,例如 E:\AIZG-FLUX.2-klein' -ForegroundColor Yellow; continue } break } $M = 'models\FLUX.2-klein-4B' foreach ($d in 'engine', $M, 'models\upscalers') { New-Item -ItemType Directory -Force -Path (Join-Path $dir $d) | Out-Null } $dir = (Resolve-Path -LiteralPath $dir).Path $drive = (Get-Item -LiteralPath $dir).PSDrive if ($drive -and $drive.Free -lt 9GB) { Write-Host "提醒:$($drive.Name): 盘只剩 $([math]::Round($drive.Free/1GB,1)) GB,全新安装需要约 10GB,可能装不下。" -ForegroundColor Yellow if ((Read-Host '还要装在这里吗?直接回车 = 继续;输入 n 回车 = 退出,换个位置再装') -eq 'n') { exit } } Write-Host "安装到:$dir" -ForegroundColor Green # ---- 2. 下载源 ---- Write-Host '' $c = Read-Host '下载源:直接回车 = 自动(先用国内镜像,失败再换官方);输入 1 回车 = 只用官方 Hugging Face' $onlyOfficial = ($c -eq '1') $hfs = if ($c -eq '1') { @('https://huggingface.co') } else { @('https://hf-mirror.com', 'https://huggingface.co') } # 连不上的来源(国内连 huggingface.co 常超时):记下来,后面的文件不再白等它 $script:deadHost = @{} function Src-Host($u) { try { ([uri]$u).Host } catch { $u } } function Mark-Src($u, $code) { if ($code -in 6, 7, 28, 35) { $h = Src-Host $u; $script:deadHost[$h] = 1 + [int]$script:deadHost[$h]; if ($script:deadHost[$h] -eq 2) { Write-Host " $h 连不上,后面先跳过它" -ForegroundColor DarkGray } } } function Src-Dead($u, $urls) { if ([int]$script:deadHost[(Src-Host $u)] -lt 2) { return $false } # 所有来源都连不上时就不跳了,照常再试 foreach ($x in $urls) { if ([int]$script:deadHost[(Src-Host $x)] -lt 2) { return $true } } return $false } # 校验文件指纹(SHA256):大小对但内容坏了也能发现;镜像下来的文件只要指纹对,就和 GitHub 原版一模一样 function Sha-Ok($p, $sha) { if (-not $sha) { return $true }; try { return ((Get-FileHash -LiteralPath $p -Algorithm SHA256).Hash -eq $sha.ToUpper()) } catch { return $false } } # GitHub 国内常连不上:后面加几个公共加速镜像(文件有 SHA256 校验,镜像给的内容不对会被丢掉,不会装进电脑) function GH($u) { @($u, "https://ghfast.top/$u", "https://gh-proxy.com/$u", "https://ghproxy.net/$u") } function Get-File($urls, $out, $size, $sha = '') { $name = Split-Path $out -Leaf New-Item -ItemType Directory -Force -Path (Split-Path $out -Parent) | Out-Null if ($size -le 0) { # 大小未知的文件:下完做个 .ok 标记 if ((Test-Path -LiteralPath $out) -and (Test-Path -LiteralPath "$out.ok")) { Write-Host " 已有,跳过:$name" -ForegroundColor DarkGray; return } for ($round = 1; $round -le 4; $round++) { foreach ($u in $urls) { if (Src-Dead $u $urls) { continue } Write-Host " 下载:$name$(if ($round -gt 1) { "(第 $round 轮重试)" })" -ForegroundColor Yellow Write-Host " 来源:$u" -ForegroundColor DarkGray & curl.exe --ssl-no-revoke -L --fail --show-error --retry 5 --retry-delay 3 --retry-all-errors --connect-timeout 15 -C - -o $out $u Mark-Src $u $LASTEXITCODE if ($LASTEXITCODE -eq 0 -or $LASTEXITCODE -eq 33) { New-Item -ItemType File -Force -Path "$out.ok" | Out-Null; return } Write-Host ' 网络断了,换个来源接着下……' -ForegroundColor DarkYellow; Start-Sleep -Seconds 3 } } throw "下载失败:$name(网络不稳。再双击一次安装程序会接着下)" } if ((Test-Path -LiteralPath $out) -and ((Get-Item -LiteralPath $out).Length -eq $size)) { if (Sha-Ok $out $sha) { Write-Host " 已有,跳过:$name" -ForegroundColor DarkGray; return } Write-Host " 已有的 $name 是坏的,删掉重下……" -ForegroundColor DarkYellow; Remove-Item -LiteralPath $out -Force } # 网络断了会自动重试:每个来源轮流试,一共 4 轮;已下的部分接着下,不会从头来 for ($round = 1; $round -le 4; $round++) { foreach ($u in $urls) { if (Src-Dead $u $urls) { continue } Write-Host " 下载:$name$(if ($round -gt 1) { "(第 $round 轮重试)" })" -ForegroundColor Yellow Write-Host " 来源:$u" -ForegroundColor DarkGray & curl.exe --ssl-no-revoke -L --fail --show-error --retry 5 --retry-delay 3 --retry-all-errors --connect-timeout 15 -C - -o $out $u Mark-Src $u $LASTEXITCODE if ((Test-Path -LiteralPath $out) -and (Get-Item -LiteralPath $out).Length -eq $size) { if (Sha-Ok $out $sha) { return } Write-Host ' 下载的文件校验不对(内容坏了),删掉换个来源重下……' -ForegroundColor DarkYellow; Remove-Item -LiteralPath $out -Force; continue } if ((Test-Path -LiteralPath $out) -and (Get-Item -LiteralPath $out).Length -gt $size) { Remove-Item -LiteralPath $out -Force } Write-Host ' 网络断了,换个来源接着下……' -ForegroundColor DarkYellow Start-Sleep -Seconds 3 } } throw "下载失败:$name(网络不稳。再双击一次安装程序会接着下)" } # 解压:压缩包下坏了(报「块的长度与它的补集不匹配」之类)→ 删掉重下,最多 3 次 function Expand-Zip($zip, $dest, [scriptblock]$get) { for ($i = 1; $i -le 3; $i++) { try { Expand-Archive -LiteralPath $zip -DestinationPath $dest -Force -ErrorAction Stop; return } catch { Write-Host " 压缩包下载得不完整($($_.Exception.Message)),删掉重新下……(第 $i 次)" -ForegroundColor DarkYellow Remove-Item -LiteralPath $zip, "$zip.ok" -Force -ErrorAction SilentlyContinue if ($i -eq 3) { throw "解压失败:$(Split-Path $zip -Leaf)。网络不稳导致文件损坏,换个网络或开加速器后再双击安装程序。" } Start-Sleep -Seconds 3; & $get } } } function HF($repoPath) { $hfs | ForEach-Object { "$_/$repoPath" } } # ---- 3. 引擎程序:N 卡用 CUDA 版(快),其他显卡用 Vulkan 版 ---- $rel = 'https://github.com/leejet/stable-diffusion.cpp/releases/download/master-872-cc515a0' $gpus = @(Get-CimInstance Win32_VideoController -ErrorAction SilentlyContinue | ForEach-Object { $_.Name }) $nv = @($gpus | Where-Object { $_ -match 'NVIDIA' }) Write-Host '' Write-Host "检测到显卡:$($gpus -join ' / ')" -ForegroundColor Cyan # 只有 CPU 自带的核显(Intel UHD / Iris、AMD Radeon Graphics 之类):显存只能分出几十 MB,载入模型就会报错退出 → 先说清楚,别白下 7GB $weak = 'Intel.*(UHD|HD Graphics|Iris)|AMD Radeon\(TM\) Graphics|Radeon\(TM\) Vega|Microsoft Basic|Remote Display|Virtual' $good = @($gpus | Where-Object { $_ -and $_ -notmatch $weak }) if ($gpus.Count -gt 0 -and $good.Count -eq 0) { Write-Host '' Write-Host " 这台电脑只有处理器自带的核显($($gpus -join ' / ')),没有独立显卡。" -ForegroundColor Yellow Write-Host ' 本地引擎至少要 6GB 显存的独立显卡(NVIDIA 最好,AMD 也行);核显只能分出几十 MB,装好了也会一载入模型就报错退出。' -ForegroundColor Yellow Write-Host ' 建议直接用网站上的云端功能(提示词、在线生成、剪辑台都不需要显卡)。以后换了有独立显卡的电脑,再运行安装程序就行。' -ForegroundColor Yellow if ((Read-Host ' 直接回车 = 不装了,退出;输入 y 回车 = 仍然要装(多半跑不起来)') -ne 'y') { Write-Host ' 已退出,什么都没下载。' -ForegroundColor DarkGray; Read-Host '按回车关闭'; exit } } if ($nv.Count -gt 0) { Write-Host '用 NVIDIA CUDA 加速版(约 0.9GB)' -ForegroundColor Cyan $bin = Join-Path $dir 'engine\cuda' if (-not (Test-Path (Join-Path $bin 'sd-server.exe'))) { $cz = Join-Path $dir 'sd-cuda12.zip'; $rz = Join-Path $dir 'cudart-cu12.zip' Get-File (GH "$rel/sd-master-cc515a0-bin-win-cuda12-x64.zip") $cz 329408217 '7d64c44c1f3907dc9b2fe0932331d77499005d7b16cf654ea76674072b6d64b5' Get-File (GH "$rel/cudart-sd-bin-win-cu12-x64.zip") $rz 563452046 'fe20366827d357c00797eebb58244dddab7fd9a348d70090c3871004c320f38d' Write-Host ' 解压中……' -ForegroundColor Yellow Expand-Zip $cz $bin { Get-File (GH "$rel/sd-master-cc515a0-bin-win-cuda12-x64.zip") $cz 329408217 '7d64c44c1f3907dc9b2fe0932331d77499005d7b16cf654ea76674072b6d64b5' } Expand-Zip $rz $bin { Get-File (GH "$rel/cudart-sd-bin-win-cu12-x64.zip") $rz 563452046 'fe20366827d357c00797eebb58244dddab7fd9a348d70090c3871004c320f38d' } Remove-Item -LiteralPath $cz, $rz -Force -ErrorAction SilentlyContinue } else { Write-Host ' 已有,跳过:CUDA 引擎' -ForegroundColor DarkGray } } else { Write-Host '用 Vulkan 通用版(AMD / Intel 显卡)' -ForegroundColor Cyan $bin = Join-Path $dir 'engine\vulkan' if (-not (Test-Path (Join-Path $bin 'sd-server.exe'))) { $vz = Join-Path $dir 'sd-vulkan.zip' Get-File (GH "$rel/sd-master-cc515a0-bin-win-vulkan-x64.zip") $vz 31858417 'b8c6538f8948dfaa1adc25c463fb1617098d1ff307032e38f29648d5891ead8d' Expand-Zip $vz $bin { Get-File (GH "$rel/sd-master-cc515a0-bin-win-vulkan-x64.zip") $vz 31858417 'b8c6538f8948dfaa1adc25c463fb1617098d1ff307032e38f29648d5891ead8d' } Remove-Item -LiteralPath $vz -Force -ErrorAction SilentlyContinue } else { Write-Host ' 已有,跳过:Vulkan 引擎' -ForegroundColor DarkGray } } # ---- 4. 模型 ---- Write-Host '' Write-Host '下载模型(约 7GB)' -ForegroundColor Cyan Get-File (HF 'leejet/FLUX.2-klein-4B-GGUF/resolve/main/flux-2-klein-4b-Q4_0.gguf') (Join-Path $dir "$M\flux-2-klein-4b-Q4_0.gguf") 2460378560 Get-File (HF 'unsloth/Qwen3-4B-GGUF/resolve/main/Qwen3-4B-Q8_0.gguf') (Join-Path $dir "$M\Qwen3-4B-Q8_0.gguf") 4280405792 Get-File (HF 'Comfy-Org/vae-text-encorder-for-flux-klein-4b/resolve/main/split_files/vae/flux2-vae.safetensors') (Join-Path $dir "$M\flux2-vae.safetensors") 336211292 Get-File (GH 'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth') (Join-Path $dir 'models\upscalers\RealESRGAN_x4plus.pth') 67040989 '4fa0d38905f75ac06eb49a7951b426670021be3018265fd191d2125df9d682f1' # ---- 4b. 可选模型:Z-Image(图里写中文字);视频见 4c ---- $ramGB = 0; try { $ramGB = [math]::Round((Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory / 1GB) } catch {} $zDir = Join-Path $dir 'models\Z-Image-Turbo'; $vDir = Join-Path $dir 'models\LTX-2.5' # __ZIMG_MANUAL_OK_V1__ 用户批处理下不动、手动下载放进文件夹时没有 .ok 标记,会被当成没装(YouTube @zjack9892);大小和 Hugging Face 上一字节不差就补标记 foreach ($zf in @{ 'z_image_turbo-Q4_K.gguf' = 3864250304; 'ae.safetensors' = 335304388 }.GetEnumerator()) { $zp = Join-Path $zDir $zf.Key if ((Test-Path -LiteralPath $zp) -and -not (Test-Path -LiteralPath "$zp.ok") -and ((Get-Item -LiteralPath $zp).Length -eq $zf.Value)) { New-Item -ItemType File -Force -Path "$zp.ok" | Out-Null; Write-Host " 检测到手动放入的 $($zf.Key),大小正确,算已装好" -ForegroundColor DarkGray } } $zHas = Test-Path -LiteralPath (Join-Path $zDir 'z_image_turbo-Q4_K.gguf.ok') $vHas = Test-Path -LiteralPath (Join-Path $vDir 'ltx-2.5-audio-vae-bf16.safetensors') Write-Host '' Write-Host '可选模型(以后想装,再双击一次安装程序就行)' -ForegroundColor Cyan Write-Host ' · Z-Image Turbo:擅长在图里写中文字、真实照片感(约 4.5GB)' -ForegroundColor DarkGray $a = Read-Host " 要装 Z-Image 吗?直接回车 = $(if ($zHas) { '检查更新' } else { '装' });输入 n 回车 = 不装" if ($a -ne 'n') { Get-File (HF 'leejet/Z-Image-Turbo-GGUF/resolve/main/z_image_turbo-Q4_K.gguf') (Join-Path $zDir 'z_image_turbo-Q4_K.gguf') 0 Get-File (HF 'Comfy-Org/z_image_turbo/resolve/main/split_files/vae/ae.safetensors') (Join-Path $zDir 'ae.safetensors') 0 } # ---- 4c. 视频引擎 WanGP(github.com/deepbeepmeep/Wan2GP,WanGP Community License 2.0:免费使用,不得收费)---- # 以前的视频用 sd.cpp 跑 LTX(8G 只能 480P 5 秒),现在换成 WanGP:720P 10 秒 / 1080P 5 秒、首尾帧,速度约 3–4 分钟一条。 $oldV = Join-Path $dir 'models\LTX-2.5' function Find-WanGP { $c = @(); $pf = Join-Path $dir 'wangp-path.txt' if (Test-Path -LiteralPath $pf) { $c += (Get-Content -LiteralPath $pf -Raw -Encoding UTF8).Trim() } $c += @((Split-Path $dir -Qualifier) + '\Wan2GP', 'D:\Wan2GP') foreach ($r in $c) { try { if ($r -and [IO.File]::Exists([IO.Path]::Combine($r, 'wgp.py'))) { return $r } } catch {} } return $null } # 检查视频引擎的 Python 环境能不能用。注意:Windows PowerShell 5.1 在「出错就停」模式下,python 往 stderr 打一句警告就会被当成失败, # 所以这里临时改成 Continue,只看退出码;失败时把 python 的原话留在 $script:pyErr 里,方便显示原因 function Test-PyImport($exe) { $script:pyErr = '' if (-not $exe -or -not [IO.File]::Exists($exe)) { $script:pyErr = "找不到 $exe"; return $false } $o = $ErrorActionPreference; $ErrorActionPreference = 'Continue' try { $out = & $exe -c "import torch, numpy, gradio" 2>&1 | ForEach-Object { "$_" } if ($LASTEXITCODE -eq 0) { return $true } $script:pyErr = (@($out) | Where-Object { $_ -and $_ -notmatch 'Warning|warn\(' } | Select-Object -Last 6) -join "`n"; return $false } catch { $script:pyErr = "$_"; return $false } finally { $ErrorActionPreference = $o } } $wHas = Find-WanGP $wReady = $false if ($wHas) { $wReady = Test-PyImport ([IO.Path]::Combine($wHas, 'env_venv', 'Scripts', 'python.exe')) } if ($wHas -and -not $wReady) { Write-Host " 发现 $wHas 里的视频引擎没装完整(上次可能中断了),这次会接着装。" -ForegroundColor Yellow } $nv = $false; try { $nv = [bool](& nvidia-smi -L 2>$null) } catch {} if (-not $nv) { $nv = [bool](@($gpus | Where-Object { $_ -match 'NVIDIA' }).Count) } Write-Host ' · 视频 LTX-2.5(视频引擎 WanGP,免费开源 by DeepBeepMeep):文生视频、首尾帧、带声音;8G 显卡 720P 10 秒 / 1080P 5 秒,一条约 3–4 分钟' -ForegroundColor DarkGray Write-Host " 需要 NVIDIA 显卡、32GB 以上内存(你的电脑 $ramGB GB);程序约 10GB,第一次生成视频时还会自动下载模型约 25GB" -ForegroundColor DarkGray # 视频引擎用的 PyTorch 是 CUDA 13 版:NVIDIA 驱动要 580 以上,旧驱动出图正常、视频一载入就退出(cudaErrorNotSupported) $drvV = $null; try { $drvV = (& nvidia-smi --query-gpu=driver_version --format=csv,noheader 2>$null | Select-Object -First 1).Trim() } catch {} if ($nv -and $drvV -and ([double]($drvV -replace '^(\d+\.\d+).*$', '$1')) -lt 580) { Write-Host " 注意:你的 NVIDIA 驱动是 $drvV,视频引擎要 580 以上,不更新的话视频一载入就会退出(出图不受影响)。" -ForegroundColor Yellow Write-Host ' 更新方法:打开 https://www.nvidia.cn/drivers/ (或 NVIDIA App)下载最新的 Game Ready / Studio 驱动,装好重启电脑。' -ForegroundColor Yellow } # 视频引擎(PyTorch CUDA 13)只支持 RTX 20 / GTX 16 系列及更新(算力 7.5 以上);GTX 10 系、Quadro P 系等老卡装了也跑不了 $ccV = 0; try { $ccS = (& nvidia-smi --query-gpu=compute_cap --format=csv,noheader 2>$null | Select-Object -First 1); if ("$ccS".Trim() -match '^\d+\.\d+$') { $ccV = [double]("$ccS".Trim()) } } catch {} $oldNv = ($ccV -gt 0 -and $ccV -lt 7.5) -or ($ccV -eq 0 -and @($gpus | Where-Object { $_ -match 'GTX (9\d\d|10\d\d)|GT 10\d\d|TITAN (X|Xp|V)\b|Quadro (P|M|GV)\d|Tesla (P|V|M)\d' }).Count -gt 0) if ($wReady) { Write-Host " 已经装好了:$wHas" -ForegroundColor Green $wantW = $false # WanGP 更新后可能多了新组件(例如 whisper):按它的 requirements.txt 检查一遍,缺什么补什么(齐全的话很快就过) $vpR = [IO.Path]::Combine($wHas, 'env_venv', 'Scripts', 'python.exe'); $reqR = [IO.Path]::Combine($wHas, 'requirements.txt') if ([IO.File]::Exists($vpR) -and [IO.File]::Exists($reqR)) { Write-Host ' 检查视频引擎组件是否齐全……' -ForegroundColor DarkGray $oldEapR = $ErrorActionPreference; $ErrorActionPreference = 'Continue' try { for ($tryR = 1; $tryR -le 2; $tryR++) { if (-not $onlyOfficial -and $tryR -eq 1) { $env:PIP_INDEX_URL = 'https://pypi.tuna.tsinghua.edu.cn/simple' } else { Remove-Item Env:PIP_INDEX_URL -ErrorAction SilentlyContinue } & $vpR -m pip install -q --disable-pip-version-check -r $reqR 2>&1 | Where-Object { "$_" -notmatch 'WARNING|DEPRECATION' } | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray } if ($LASTEXITCODE -eq 0) { Write-Host ' 组件齐全。' -ForegroundColor Green; break } if ($tryR -eq 2) { Write-Host ' 有组件没装上(网络问题),先不影响出图;视频引擎第一次载入时会自动再补装。' -ForegroundColor Yellow } } } finally { $ErrorActionPreference = $oldEapR; Remove-Item Env:PIP_INDEX_URL -ErrorAction SilentlyContinue } } } elseif (-not $nv) { Write-Host ' 你的显卡不是 NVIDIA,视频引擎跑不了,已自动跳过(出图、改图、写中文字照常能用)。' -ForegroundColor Yellow $wantW = $false } elseif ($oldNv) { Write-Host " 你的显卡($(@($gpus | Where-Object { $_ -match 'NVIDIA' }) -join ' / '))是 2018 年以前的老架构,视频引擎新版已经不支持,装了也跑不起来(出图、改图、写中文字照常能用)。" -ForegroundColor Yellow Write-Host ' 视频需要 RTX 20 / GTX 16 系列或更新的 NVIDIA 显卡。' -ForegroundColor Yellow $wantW = ((Read-Host ' 要装视频引擎吗?直接回车 = 不装;输入 y 回车 = 仍然要装(多半跑不了)') -eq 'y') } elseif ($ramGB -gt 0 -and $ramGB -lt 30) { Write-Host " 内存只有 $ramGB GB,跑视频会很慢或跑不动,建议不装。" -ForegroundColor Yellow $wantW = ((Read-Host ' 要装视频引擎吗?直接回车 = 不装;输入 y 回车 = 仍然要装') -eq 'y') } else { $wantW = ((Read-Host ' 要装视频引擎吗?直接回车 = 装;输入 n 回车 = 不装') -ne 'n') } if ($wantW) { $wroot = if ($wHas) { $wHas } else { (Split-Path $dir -Qualifier) + '\Wan2GP' } $pyDir = (Split-Path $dir -Qualifier) + '\Python311' Write-Host " 视频引擎装到:$wroot" -ForegroundColor Green # 1) Python 3.11:找一个能用的 python.exe(不依赖 py 启动器;系统里装坏了、卸了一半也不怕) New-Item -ItemType Directory -Force -Path $wroot | Out-Null function Test-Py($exe) { try { if ($exe -and [IO.File]::Exists($exe)) { $v = & $exe -c "import sys,venv;print('%d.%d' % sys.version_info[:2])" 2>$null; return ($LASTEXITCODE -eq 0 -and "$v".Trim() -eq '3.11') } } catch {} return $false } function Find-Py { $env:PATH = "$env:LOCALAPPDATA\Programs\Python\Launcher;$env:SystemRoot;$env:PATH" try { $p = & py -3.11 -c "import sys;print(sys.executable)" 2>$null; if ($LASTEXITCODE -eq 0 -and (Test-Py "$p".Trim())) { return "$p".Trim() } } catch {} foreach ($c in @((Join-Path $wroot 'python311\python.exe'), (Join-Path $pyDir 'python.exe'), 'D:\Python311\python.exe', "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe", 'C:\Python311\python.exe')) { if (Test-Py $c) { return $c } } return $null } $py = Find-Py if (-not $py) { # 方法一:官方安装包静默安装 $pexe = Join-Path $env:TEMP 'python-3.11.9-amd64.exe' try { Get-File @('https://mirrors.huaweicloud.com/python/3.11.9/python-3.11.9-amd64.exe', 'https://www.python.org/ftp/python/3.11.9/python-3.11.9-amd64.exe') $pexe 0 Write-Host ' 安装 Python 3.11(静默,约 1 分钟)……' -ForegroundColor Cyan Start-Process -FilePath $pexe -ArgumentList '/quiet', 'InstallAllUsers=0', "TargetDir=$pyDir", 'PrependPath=0', 'Include_test=0', 'Include_launcher=1', 'InstallLauncherAllUsers=0', 'Shortcuts=0' -Wait } catch { Write-Host " 官方安装包没装上:$($_.Exception.Message)" -ForegroundColor DarkGray } Remove-Item -LiteralPath $pexe, "$pexe.ok" -Force -ErrorAction SilentlyContinue $py = Find-Py } if (-not $py) { # 方法二:系统里的 Python 装不上(常见于以前卸载到一半)→ 用 Python 官方的免安装版,直接放在 WanGP 文件夹里,不碰系统 Write-Host ' 改用免安装版 Python 3.11(放在视频引擎文件夹里,不影响系统)……' -ForegroundColor Cyan $nup = Join-Path $env:TEMP 'python-3.11.9-nuget.zip' Get-File @('https://www.nuget.org/api/v2/package/python/3.11.9') $nup 0 # 直接解压到 WanGP 文件夹里(不在临时目录来回拷,杀毒软件扫描时锁住文件也不影响) $x = Join-Path $wroot ('_py_' + [guid]::NewGuid().ToString('N').Substring(0, 8)) Expand-Zip $nup $x { Get-File @('https://www.nuget.org/api/v2/package/python/3.11.9') $nup 0 } $pd = Join-Path $wroot 'python311' if (Test-Path -LiteralPath $pd) { Remove-Item -LiteralPath $pd -Recurse -Force -ErrorAction SilentlyContinue } try { Move-Item -LiteralPath (Join-Path $x 'tools') -Destination $pd -ErrorAction Stop } catch { New-Item -ItemType Directory -Force -Path $pd | Out-Null; Copy-Item -Path (Join-Path $x 'tools\*') -Destination $pd -Recurse -Force -ErrorAction SilentlyContinue } Remove-Item -LiteralPath $x -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $nup, "$nup.ok" -Force -ErrorAction SilentlyContinue Start-Sleep 2 $py = Find-Py } if (-not $py) { throw 'Python 3.11 两种方法都没装上。重启一次电脑再双击安装程序;还不行就把这个窗口截图发到 AI追光。' } Write-Host " Python 3.11:$py" -ForegroundColor DarkGray # WanGP 的安装脚本写死了用「py -3.11」:放一个小替身 py.cmd,让它用上面找到的这个 Python $shim = Join-Path $wroot 'aizg-bin'; New-Item -ItemType Directory -Force -Path $shim | Out-Null [IO.File]::WriteAllText((Join-Path $shim 'py.cmd'), "@echo off`r`nset `"_a=%~1`"`r`nif `"%_a:~0,2%`"==`"-3`" shift`r`n`"$py`" %1 %2 %3 %4 %5 %6 %7 %8 %9`r`n", [Text.Encoding]::Default) # 2) WanGP 程序(GitHub 官方 zip,不经过我们转发) if (-not [IO.File]::Exists([IO.Path]::Combine($wroot, 'wgp.py'))) { $zip = Join-Path $env:TEMP 'wan2gp-main.zip' Write-Host ' 下载 WanGP 程序(GitHub,几十 MB)……' -ForegroundColor Cyan $getW = { Remove-Item -LiteralPath $zip -Force -ErrorAction SilentlyContinue & curl.exe --ssl-no-revoke -L --fail --show-error --retry 5 --retry-all-errors --connect-timeout 30 -o $zip 'https://github.com/deepbeepmeep/Wan2GP/archive/refs/heads/main.zip' if ($LASTEXITCODE -ne 0) { throw 'WanGP 下载失败(GitHub 连不上)。可以换个网络,再双击安装程序重试。' } } & $getW $x = Join-Path $env:TEMP 'wan2gp-x'; if (Test-Path $x) { Remove-Item $x -Recurse -Force } Expand-Zip $zip $x $getW $src = Get-ChildItem $x -Directory | Select-Object -First 1 New-Item -ItemType Directory -Force -Path $wroot | Out-Null Copy-Item -Path (Join-Path $src.FullName '*') -Destination $wroot -Recurse -Force Remove-Item $x -Recurse -Force; Remove-Item $zip -Force } Set-Content -LiteralPath (Join-Path $wroot 'scripts\args.txt') -Value '--server-port 7870' -Encoding ASCII # 3) WanGP 自带的一键安装(按显卡自动选 PyTorch 和加速库,要下载好几个 GB) # 判断装好没有:不能只看 python.exe 在不在(上次装到一半断了也会有),要真的能 import torch $vpy = [IO.Path]::Combine($wroot, 'env_venv', 'Scripts', 'python.exe') $envOk = { Test-PyImport $vpy } # __TORCH_MIRROR_V1__ 改 WanGP 的 setup_config.json 里 PyTorch 的下载地址(官方 ⇄ 上海交大镜像,文件完全一样);装完改回官方 function Set-TorchSrc([bool]$cn) { $cfgP = [IO.Path]::Combine($wroot, 'setup_config.json') if (-not [IO.File]::Exists($cfgP)) { return } try { $t = [IO.File]::ReadAllText($cfgP) $off = 'https://download.pytorch.org/whl/'; $mir = 'https://mirror.sjtu.edu.cn/pytorch-wheels/' $t2 = if ($cn) { $t.Replace($off, $mir) } else { $t.Replace($mir, $off) } if ($t2 -ne $t) { [IO.File]::WriteAllText($cfgP, $t2, (New-Object Text.UTF8Encoding($false))) } } catch {} } if (-not (& $envOk)) { Write-Host ' 安装 WanGP 运行环境(PyTorch 等,好几个 GB,窗口几分钟没动静是正常的,别关)……' -ForegroundColor Cyan $env:PIP_CACHE_DIR = Join-Path $wroot 'pip-cache'; $env:UV_CACHE_DIR = Join-Path $wroot 'uv-cache'; $env:PYTHONUTF8 = '1'; $env:PYTHONIOENCODING = 'utf-8'; $env:GIT_PYTHON_REFRESH = 'quiet' $log = Join-Path $wroot 'aizg-install-log.txt' $oldPath = $env:PATH; $env:PATH = "$shim;$env:PATH"; $oldEap = $ErrorActionPreference; $ErrorActionPreference = 'Continue' # 网慢容易超时:等久一点、断了自动重试;整个环境最多自动装 3 次(下好的包在 pip-cache 里,重试不用重下) $env:PIP_DEFAULT_TIMEOUT = '180'; $env:PIP_RETRIES = '10'; $env:UV_HTTP_TIMEOUT = '300' Push-Location $wroot try { if (Test-Path -LiteralPath $log) { Remove-Item -LiteralPath $log -Force -ErrorAction SilentlyContinue } for ($try = 1; $try -le 3; $try++) { # 选了「自动」下载源:前两次普通的包走清华镜像(PyTorch 走上海交大镜像),第三次全走官方 if (-not $onlyOfficial -and $try -lt 3) { $env:PIP_INDEX_URL = 'https://pypi.tuna.tsinghua.edu.cn/simple' } else { Remove-Item Env:PIP_INDEX_URL -ErrorAction SilentlyContinue } # 装 PyTorch 时只看 PyTorch 官方源,它那份 typing-extensions 名字不规范,新版 pip 会丢掉再去编译源码、编译又缺 flit_core → 失败。补一个普通源,让这些小依赖从普通源装 $env:PIP_EXTRA_INDEX_URL = $(if (-not $onlyOfficial -and $try -lt 3) { 'https://pypi.tuna.tsinghua.edu.cn/simple' } else { 'https://pypi.org/simple' }) if ($try -gt 1) { Write-Host " 第 $try 次重试安装运行环境(上次没装完整,接着装)……" -ForegroundColor Yellow } # __TORCH_MIRROR_V1__ PyTorch(约 2.5GB)官方地址 download.pytorch.org 国内很慢、常断:前两次改走上海交大镜像(同样的官方文件),第三次换回官方 Set-TorchSrc ((-not $onlyOfficial) -and $try -lt 3) if ((-not $onlyOfficial) -and $try -lt 3) { Write-Host ' PyTorch 从国内镜像(上海交大)下载……' -ForegroundColor DarkGray } elseif ($try -eq 3) { Write-Host ' PyTorch 换官方地址下载……' -ForegroundColor DarkGray } & $py setup.py install --env venv --auto 2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log -Append if (& $envOk) { break } Start-Sleep -Seconds 5 } } finally { Set-TorchSrc $false; Pop-Location; $env:PATH = $oldPath; $ErrorActionPreference = $oldEap; Remove-Item Env:PIP_INDEX_URL, Env:PIP_EXTRA_INDEX_URL -ErrorAction SilentlyContinue } if (-not (& $envOk)) { if ($script:pyErr) { Write-Host ' 检查环境时 Python 报的错:' -ForegroundColor Yellow; Write-Host (" " + ($script:pyErr -replace "`n", "`n ")) -ForegroundColor DarkGray; Add-Content -LiteralPath $log -Value ("`n[环境检查] " + $script:pyErr) -ErrorAction SilentlyContinue } if ($script:pyErr -match 'WinError 126|DLL load failed|找不到指定的模块') { throw "WanGP 已经装好,但 PyTorch 打不开:缺少微软 VC++ 运行库。请搜索下载 vc_redist.x64.exe(微软官网)安装,重启电脑后再双击一次安装程序。" } if ($script:pyErr -match '1455|页面文件太小|paging file') { throw "WanGP 已经装好,但电脑虚拟内存太小,PyTorch 打不开。请把虚拟内存调大(开始菜单搜「高级系统设置」→ 性能「设置」→ 高级 → 虚拟内存「更改」,D 盘自定义 32768~65536),重启后再双击一次安装程序。" } throw "WanGP 运行环境自动试了 3 次都没装成功(多半是网络不稳,下载 PyTorch 约 2.5GB 老是断)。换个网络、或关掉再开 VPN/代理后,再双击一次安装程序;还不行就把 $log 发到 AI追光。" } } [IO.File]::WriteAllText((Join-Path $dir 'wangp-path.txt'), $wroot, (New-Object Text.UTF8Encoding($false))) Write-Host ' 视频引擎装好了。提示:刚装完第一次启动如果报「另一个程序正在使用此文件」,重启一次电脑就好。' -ForegroundColor Green } $wf = Find-WanGP if ($wf -and [IO.File]::Exists([IO.Path]::Combine($wf, 'env_venv', 'Scripts', 'python.exe')) -and ($wantW -or $wReady)) { [IO.File]::WriteAllText((Join-Path $dir 'wangp-path.txt'), $wf, (New-Object Text.UTF8Encoding($false))) } if ((Test-Path -LiteralPath $oldV) -and (Find-WanGP)) { Write-Host " 旧版视频模型文件夹 $oldV(约 33GB)已经用不上了,想省空间可以手动删掉。" -ForegroundColor DarkGray } # ---- 5. 引擎管家(网页上开启 / 关闭 / 切换模型)+ 启动脚本 ---- $mgr = @' # AI追光 · 引擎管家(127.0.0.1:7860) # 网页通过它开启 / 关闭 / 切换本地模型。出图用 sd-server,出视频用 WanGP 视频桥(都在 127.0.0.1:7861,同一时间只开一个,省显存)。 # 用法:manager.ps1 [-Model flux|zimage|ltx] [-Url aizg-engine://start/ltx] param([string]$Model = '', [string]$Url = '', [switch]$NoModel) $ErrorActionPreference = 'Continue' $VER = 'aizg-manager 1.4' $dir = $PSScriptRoot $PORT = 7860; $SDPORT = 7861 $sdOut = Join-Path $dir 'engine-log.txt' $sdErr = Join-Path $dir 'engine-err.txt' $stateFile = Join-Path $dir 'manager-state.json' $isWin = ($env:OS -eq 'Windows_NT') $JOBCS = 'using System; using System.Runtime.InteropServices; public static class Job { [StructLayout(LayoutKind.Sequential)] struct BASIC { public long a; public long b; public uint LimitFlags; public UIntPtr c; public UIntPtr d; public uint e; public UIntPtr f; public uint g; public uint h; } [StructLayout(LayoutKind.Sequential)] struct IO { public ulong a; public ulong b; public ulong c; public ulong d; public ulong e; public ulong f; } [StructLayout(LayoutKind.Sequential)] struct EXT { public BASIC Basic; public IO Io; public UIntPtr p1; public UIntPtr p2; public UIntPtr p3; public UIntPtr p4; } [DllImport("kernel32.dll", CharSet=CharSet.Unicode)] static extern IntPtr CreateJobObject(IntPtr a, string n); [DllImport("kernel32.dll")] static extern bool SetInformationJobObject(IntPtr j, int c, ref EXT i, uint l); [DllImport("kernel32.dll")] static extern bool AssignProcessToJobObject(IntPtr j, IntPtr p); static IntPtr h = IntPtr.Zero; public static bool Add(IntPtr proc) { if (h == IntPtr.Zero) { h = CreateJobObject(IntPtr.Zero, null); EXT e = new EXT(); e.Basic.LimitFlags = 0x2000; SetInformationJobObject(h, 9, ref e, (uint)Marshal.SizeOf(typeof(EXT))); } return AssignProcessToJobObject(h, proc); } }' try { $Host.UI.RawUI.WindowTitle = 'AI追光 · 本地引擎(用的时候别关这个窗口)' } catch {} # 关掉控制台「快速编辑」:不然在黑窗口里点一下就会进入选择状态,整个引擎管家卡住、网页连不上 if ($isWin) { try { Add-Type -Namespace AIZG -Name Con -MemberDefinition ('[DllImport("kernel32.dll")] public static extern IntPtr GetStdHandle(int h);' + '[DllImport("kernel32.dll")] public static extern bool GetConsoleMode(IntPtr h, out uint m);' + '[DllImport("kernel32.dll")] public static extern bool SetConsoleMode(IntPtr h, uint m);') $hIn = [AIZG.Con]::GetStdHandle(-10); $cm = 0 if ([AIZG.Con]::GetConsoleMode($hIn, [ref]$cm)) { [void][AIZG.Con]::SetConsoleMode($hIn, (($cm -band (-bnot 0x40)) -bor 0x80)) } } catch {} } if ($Url -match 'aizg-engine://[^/]*/?([a-z]+)') { $Model = $Matches[1] } if ($Model -eq 'start' -or $Model -eq 'open') { $Model = '' } # ---------- 模型表:文件都在才算装好 ---------- function P($rel) { Join-Path $dir $rel } $K = 'models\FLUX.2-klein-4B' $MODELS = [ordered]@{ flux = @{ name = 'FLUX.2 klein 4B'; kind = 'image'; desc = '文生图 · 参考图改图 · 多图合成' files = @("$K\flux-2-klein-4b-Q4_0.gguf", "$K\Qwen3-4B-Q8_0.gguf", "$K\flux2-vae.safetensors") args = @('--diffusion-model', (P "$K\flux-2-klein-4b-Q4_0.gguf"), '--llm', (P "$K\Qwen3-4B-Q8_0.gguf"), '--vae', (P "$K\flux2-vae.safetensors"), '--hires-upscalers-dir', (P 'models\upscalers'), '--cfg-scale', '1.0', '--steps', '4', '--sampling-method', 'euler') } zimage = @{ name = 'Z-Image Turbo'; kind = 'image'; desc = '图里写中文字 · 真实照片感' files = @('models\Z-Image-Turbo\z_image_turbo-Q4_K.gguf', 'models\Z-Image-Turbo\ae.safetensors', "$K\Qwen3-4B-Q8_0.gguf") args = @('--diffusion-model', (P 'models\Z-Image-Turbo\z_image_turbo-Q4_K.gguf'), '--llm', (P "$K\Qwen3-4B-Q8_0.gguf"), '--vae', (P 'models\Z-Image-Turbo\ae.safetensors'), '--hires-upscalers-dir', (P 'models\upscalers'), '--cfg-scale', '1.0', '--steps', '8', '--sampling-method', 'euler') } ltx = @{ name = 'LTX-2.5 视频(WanGP)'; kind = 'video'; desc = '文生视频 · 首尾帧 · 带声音 · 最高 1080P'; ram = 32; backend = 'wangp'; files = @(); args = @() } } # 视频引擎 WanGP(github.com/deepbeepmeep/Wan2GP,免费使用,不得收费):装在哪由 wangp-path.txt 指定,默认 D:\Wan2GP function WanGPRoot { $c = @() $pf = P 'wangp-path.txt'; if (Test-Path -LiteralPath $pf) { try { $c += (Get-Content -LiteralPath $pf -Raw -Encoding UTF8).Trim() } catch {} } $c += @('D:\Wan2GP', (P 'Wan2GP'), "$env:SystemDrive\Wan2GP") foreach ($r in $c) { try { if ($r -and [IO.File]::Exists([IO.Path]::Combine($r, 'env_venv', 'Scripts', 'python.exe')) -and [IO.File]::Exists([IO.Path]::Combine($r, 'wgp.py'))) { return $r } } catch {} } return $null } function Installed($id) { $m = $MODELS[$id]; if (-not $m) { return $false }; if ($m.backend -eq 'wangp') { return [bool](WanGPRoot) }; foreach ($f in $m.files) { if (-not (Test-Path -LiteralPath (P $f))) { return $false } }; return $true } function SdExe { foreach ($d in 'engine\cuda', 'engine\vulkan') { foreach ($n in 'sd-server.exe', 'sd-server') { $e = P "$d\$n"; if (Test-Path $e) { return $e } } } return $null } # ---------- 状态 ---------- $S = @{ state = 'stopped'; model = ''; since = 0; loadStart = 0; loadSec = 0; error = ''; pid = 0; jobs = 0 } $proc = $null $last = '' if (Test-Path -LiteralPath $stateFile) { try { $last = (Get-Content -LiteralPath $stateFile -Raw -ErrorAction Stop | ConvertFrom-Json).model } catch {} } function Save { try { @{ model = $S.model } | ConvertTo-Json | Set-Content -LiteralPath $stateFile -Encoding UTF8 } catch {} } function Now { [long]([DateTime]::UtcNow - [DateTime]'1970-01-01').TotalMilliseconds } function Say($t, $c = 'Gray') { Write-Host ((Get-Date -Format 'HH:mm:ss') + ' ' + $t) -ForegroundColor $c } function Kill-Sd { if ($proc -and -not $proc.HasExited) { try { $proc.Kill(); $proc.WaitForExit(8000) | Out-Null } catch {} } $script:proc = $null # 旧版启动脚本留下的 sd-server 也一并关掉(占着 7861) if ($isWin) { try { Get-NetTCPConnection -LocalPort $SDPORT -State Listen -ErrorAction Stop | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue } } catch {} } } function Start-Sd($id) { if (-not $MODELS.Contains($id)) { return "没有这个模型:$id" } if (-not (Installed $id)) { return "$($MODELS[$id].name) 还没安装:请重新运行安装程序,选装这个模型" } if ($MODELS[$id].backend -eq 'wangp') { $root = WanGPRoot; $exe = [IO.Path]::Combine($root, 'env_venv', 'Scripts', 'python.exe'); $br = P 'aizg-wangp-bridge.py' if (-not (Test-Path -LiteralPath $br)) { return '找不到视频桥 aizg-wangp-bridge.py:请重新运行安装程序' } Kill-Sd # WanGP 自带的网页界面(7870)如果开着会抢显存,先关掉 if ($isWin) { try { Get-NetTCPConnection -LocalPort 7870 -State Listen -ErrorAction Stop | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue; Say '已关闭 WanGP 自带界面(7870),把显卡让给创作台' Yellow } } catch {} } $env:PYTHONUTF8 = '1'; $env:PYTHONIOENCODING = 'utf-8'; $env:GIT_PYTHON_REFRESH = 'quiet' $a = @('-u', $br, '--root', $root, '--port', "$SDPORT") Say "视频引擎:WanGP($root)" DarkGray } else { $exe = SdExe; if (-not $exe) { return '找不到引擎程序 sd-server:请重新运行安装程序' } Kill-Sd $a = @($MODELS[$id].args) + @('--offload-to-cpu', '--diffusion-fa', '--listen-ip', '127.0.0.1', '--listen-port', "$SDPORT") } $q = ($a | ForEach-Object { if ($_ -match '\s') { '"' + $_ + '"' } else { $_ } }) -join ' ' try { $p = @{ FilePath = $exe; ArgumentList = $q; RedirectStandardOutput = $sdOut; RedirectStandardError = $sdErr; PassThru = $true } if ($isWin) { $p.WindowStyle = 'Hidden' } $script:proc = Start-Process @p # 把引擎放进「作业」:管家窗口一关(包括点 ×),Windows 会自动把出图/视频引擎一起关掉,不会留在后台占显卡 if ($isWin) { try { if (-not ('AIZG.Job' -as [type])) { Add-Type -TypeDefinition ('namespace AIZG { ' + $JOBCS + ' }') -ErrorAction Stop }; [void][AIZG.Job]::Add($proc.Handle) } catch {} } } catch { return "引擎启动失败:$($_.Exception.Message)" } $S.state = 'loading'; $S.model = $id; $S.loadStart = Now; $S.error = ''; $S.pid = $proc.Id; $S.jobs = 0 Save Say "载入 $($MODELS[$id].name) …(第一次要几十秒$(if ($MODELS[$id].backend -eq 'wangp') { ',视频第一条还要再载入模型约 1 分钟' }))" Cyan return '' } function Stop-Sd { Kill-Sd; $S.state = 'stopped'; $S.pid = 0; Say '已关闭模型,显存和内存已释放' Yellow } function Probe { try { $r = [Net.WebRequest]::Create("http://127.0.0.1:$SDPORT/sdcpp/v1/capabilities"); $r.Proxy = $null; $r.Timeout = 1500 $resp = $r.GetResponse(); $resp.Close(); return $true } catch { return $false } } function Crash-Hint($t) { if ($t -match 'cudaErrorNotSupported|older driver|driver version is insufficient|CUDA driver version|CUDA is not available') { return '显卡驱动太旧,视频引擎用不了显卡(出图不受影响)。请更新 NVIDIA 驱动到 580 以上:打开 https://www.nvidia.cn/drivers/ 或 NVIDIA App 下载最新驱动,装好重启电脑。' } if ($t -match '1455|页面文件太小|paging file') { return '电脑的虚拟内存(页面文件)太小,装不下视频模型。设置方法:开始菜单搜「高级系统设置」→ 性能「设置」→ 高级 → 虚拟内存「更改」→ 取消「自动管理」→ 选 D 盘 → 自定义大小,初始 32768、最大 65536 → 设置 → 确定,然后重启电脑。' } if ($t -match 'CUDA out of memory|OutOfMemoryError|out of memory') { return '显存不够。把分辨率改成 480P 或时长改短再试;同时关掉其他占显卡的软件(游戏、剪辑软件、其他 AI 工具)。' } if ($t -match 'no kernel image|sm_120|not compatible with the current PyTorch') { return '显卡比较新,现在装的 PyTorch 不支持。重新运行一次安装器(安装 / 更新引擎)就会换成新版。' } if ($t -match 'MemoryError|bad allocation|std::bad_alloc|Unable to allocate|内存不足') { return '电脑内存不够。关掉浏览器其他标签页和别的软件再试;内存 16GB 的电脑还要把虚拟内存调大(开始菜单搜「高级系统设置」→ 虚拟内存,D 盘设 32768~65536)。' } if ($t -match 'DLL load failed|找不到指定的模块|WinError 126') { return '缺少运行库。请安装微软 VC++ 运行库(搜索 vc_redist.x64.exe 下载安装),装完重启电脑。' } if ($t -match 'Unable to Download|ConnectionResetError|Connection aborted|timed out') { return '下载模型时网络断了。再点一次载入会接着下。' } if (-not ($t -match 'Traceback|Error')) { return '引擎没留下错误就退出了,多半是内存不够被系统关掉了:内存 16GB 的电脑请把虚拟内存调大(开始菜单搜「高级系统设置」→ 性能「设置」→ 高级 → 虚拟内存「更改」,D 盘自定义 32768~65536),重启后再试。' } return '' } function Tick { if ($S.state -eq 'loading' -or $S.state -eq 'ready') { if ($proc -and $proc.HasExited) { $S.state = 'crashed'; $S.pid = 0 $t = ''; try { $t = ((Get-Content -LiteralPath $sdOut -Tail 12 -Encoding UTF8 -ErrorAction Stop) + (Get-Content -LiteralPath $sdErr -Tail 6 -Encoding UTF8 -ErrorAction SilentlyContinue)) -join "`n" } catch {} $h = Crash-Hint $t; if ($h) { $t = "【原因】$h`n`n" + $t } $S.error = $t; Say '引擎意外退出了,日志在 engine-log.txt' Red if ($h) { Say ('可能的原因:' + $h) Yellow } else { Say '下面是日志最后几行,截图发给 AI追光 就能看出原因:' Yellow; ($t -split "`n" | Where-Object { $_.Trim() } | Select-Object -Last 8) | ForEach-Object { Write-Host (' ' + $_) -ForegroundColor DarkGray } } return } } # 视频引擎在后台跑(看不到它的窗口):把它日志里的「下载模型 / 开始 / 完成 / 失败」抄到这个黑色窗口,用户能看到进度 if ($S.model -and $MODELS[$S.model].backend -eq 'wangp' -and ($S.state -eq 'loading' -or $S.state -eq 'ready')) { $script:bt++ if ($script:bt -ge 3) { $script:bt = 0 try { $ls = @(Get-Content -LiteralPath $sdOut -Tail 40 -Encoding UTF8 -ErrorAction Stop | Where-Object { $_ -match '^\d\d:\d\d:\d\d (下载视频模型|下载被网络中断|视频引擎缺少组件|补装|开始|完成|失败|出错|WanGP 已就绪)' }) $new = @(); $seen = $false if ($script:lastBr) { foreach ($l in $ls) { if ($seen) { $new += $l } elseif ($l -eq $script:lastBr) { $seen = $true } } ; if (-not $seen) { $new = $ls | Select-Object -Last 1 } } else { $new = $ls | Select-Object -Last 1 } foreach ($l in $new) { if ($l) { $c = if ($l -match '失败|出错') { 'Red' } elseif ($l -match '完成|就绪') { 'Green' } else { 'Cyan' }; Write-Host (' [视频] ' + $l.Substring(9)) -ForegroundColor $c } } if ($ls.Count) { $script:lastBr = $ls[-1] } } catch {} } } if ($S.state -eq 'loading' -and (Probe)) { $S.state = 'ready'; $S.since = Now; $S.loadSec = [math]::Round(((Now) - $S.loadStart) / 1000, 1) Say "$($MODELS[$S.model].name) 已就绪(载入 $($S.loadSec) 秒)· 回网页就能用" Green } } # ---------- 显卡 / 内存(面板显示用,3 秒缓存) ---------- $HW = @{ t = 0; gpu = $null; ram = $null } function Hw { if (((Now) - $HW.t) -lt 3000) { return } $HW.t = Now try { $o = & nvidia-smi --query-gpu=name,memory.used,memory.total --format=csv,noheader,nounits 2>$null | Select-Object -First 1 if ($o) { $x = $o -split ',\s*'; $HW.gpu = @{ name = ($x[0] -replace '^NVIDIA\s+(GeForce\s+)?', ''); used = [int]$x[1]; total = [int]$x[2] } } } catch {} try { if ($isWin) { $os = Get-CimInstance Win32_OperatingSystem; $HW.ram = @{ total = [math]::Round($os.TotalVisibleMemorySize / 1MB, 1); used = [math]::Round(($os.TotalVisibleMemorySize - $os.FreePhysicalMemory) / 1MB, 1) } } } catch {} } function StatusJson { Hw $ms = @(); foreach ($k in $MODELS.Keys) { $m = $MODELS[$k]; $ms += [ordered]@{ id = $k; name = $m.name; kind = $m.kind; desc = $m.desc; installed = (Installed $k); ram = $m.ram; backend = $(if ($m.backend) { $m.backend } else { 'sdcpp' }) } } $o = [ordered]@{ manager = $VER; state = $S.state; model = $S.model; since = $S.since; loadStart = $S.loadStart; loadSec = $S.loadSec; now = (Now); error = $S.error; pid = $S.pid; gpu = $HW.gpu; ram = $HW.ram; models = $ms } return ($o | ConvertTo-Json -Depth 5 -Compress) } # ---------- 极简 HTTP(和追光桥一样用 TcpListener,不需要管理员权限) ---------- $OK_ORIGIN = '^(https://(www\.)?aizhuiguang\.tech|http://(localhost|127\.0\.0\.1)(:\d+)?)$' function Read-Req($stream) { $buf = New-Object byte[] 65536; $ms = New-Object IO.MemoryStream; $hdrEnd = -1 $stream.ReadTimeout = 3000 while ($hdrEnd -lt 0) { $n = $stream.Read($buf, 0, $buf.Length); if ($n -le 0) { return $null } $ms.Write($buf, 0, $n); $all = $ms.ToArray() for ($i = 3; $i -lt $all.Length; $i++) { if ($all[$i - 3] -eq 13 -and $all[$i - 2] -eq 10 -and $all[$i - 1] -eq 13 -and $all[$i] -eq 10) { $hdrEnd = $i + 1; break } } if ($all.Length -gt 1MB) { return $null } } $head = [Text.Encoding]::ASCII.GetString($all, 0, $hdrEnd) $lines = $head -split "`r`n"; $first = $lines[0] -split ' ' $h = @{}; foreach ($l in $lines[1..($lines.Length - 1)]) { $j = $l.IndexOf(':'); if ($j -gt 0) { $h[$l.Substring(0, $j).Trim().ToLower()] = $l.Substring($j + 1).Trim() } } $path = $first[1]; $qs = @{} if ($path -match '\?(.*)$') { foreach ($kv in ($Matches[1] -split '&')) { $p2 = $kv -split '=', 2; if ($p2.Length -eq 2) { $qs[$p2[0]] = [Uri]::UnescapeDataString($p2[1]) } } ; $path = $path -replace '\?.*$', '' } # 请求体(只有收件箱用,最多 64KB) $body = ''; $cl = 0; [void][int]::TryParse([string]$h['content-length'], [ref]$cl) if ($cl -gt 0 -and $cl -le 8MB) { $have = $ms.Length - $hdrEnd while ($have -lt $cl) { $n = $stream.Read($buf, 0, $buf.Length); if ($n -le 0) { break }; $ms.Write($buf, 0, $n); $have += $n } $all = $ms.ToArray(); $body = [Text.Encoding]::UTF8.GetString($all, $hdrEnd, [Math]::Min($cl, $all.Length - $hdrEnd)) } return @{ method = $first[0]; path = $path; qs = $qs; origin = $h['origin']; pna = $h['access-control-request-private-network']; body = $body } } function Send($stream, $code, $body, $origin, $ctype = 'application/json; charset=utf-8') { $b = [Text.Encoding]::UTF8.GetBytes($body) $h = "HTTP/1.1 $code`r`nContent-Type: $ctype`r`nContent-Length: $($b.Length)`r`nCache-Control: no-store`r`nConnection: close`r`n" if ($origin -and $origin -match $OK_ORIGIN) { $h += "Access-Control-Allow-Origin: $origin`r`nVary: Origin`r`nAccess-Control-Allow-Methods: GET, POST, OPTIONS`r`nAccess-Control-Allow-Headers: Content-Type`r`nAccess-Control-Allow-Private-Network: true`r`n" } $hb = [Text.Encoding]::ASCII.GetBytes($h + "`r`n"); $stream.Write($hb, 0, $hb.Length); if ($b.Length) { $stream.Write($b, 0, $b.Length) }; $stream.Flush() } function J($o) { return ($o | ConvertTo-Json -Compress) } # ---------- 收件箱:AI / 脚本通过 API 生成的作品,登记到这里,本地创作台会自动收进作品墙 ---------- $INBOXF = Join-Path $dir 'inbox.json' $script:inbox = @() try { if (Test-Path -LiteralPath $INBOXF) { $tmp = Get-Content -LiteralPath $INBOXF -Raw -Encoding UTF8 | ConvertFrom-Json; $script:inbox = @($tmp | ForEach-Object { $_ } | Where-Object { $_ -and $_.id }) } } catch { $script:inbox = @() } $MIME = @{ '.png' = 'image/png'; '.jpg' = 'image/jpeg'; '.jpeg' = 'image/jpeg'; '.webp' = 'image/webp'; '.mp4' = 'video/mp4'; '.webm' = 'video/webm' } function Save-Inbox { try { [IO.File]::WriteAllText($INBOXF, (ConvertTo-Json @($script:inbox) -Depth 4 -Compress), (New-Object Text.UTF8Encoding $false)) } catch {} } function Add-Inbox($body) { $d = $null; try { $d = $body | ConvertFrom-Json } catch { return @{ code = '400 Bad Request'; o = @{ error = 'bad json' } } } $p = [string]$d.path if (-not $p -or -not [IO.Path]::IsPathRooted($p)) { return @{ code = '400 Bad Request'; o = @{ error = 'path 要写完整路径,例如 D:\测试\a.png' } } } if (-not [IO.File]::Exists($p)) { return @{ code = '404 Not Found'; o = @{ error = "找不到文件:$p" } } } $ext = [IO.Path]::GetExtension($p).ToLower(); if (-not $MIME[$ext]) { return @{ code = '415 Unsupported Media Type'; o = @{ error = '只收 png / jpg / webp / mp4 / webm' } } } $len = (New-Object IO.FileInfo $p).Length; if ($len -gt 300MB) { return @{ code = '413 Payload Too Large'; o = @{ error = '文件超过 300MB' } } } $it = [ordered]@{ id = [guid]::NewGuid().ToString('N').Substring(0, 12); ts = (Now); path = $p; name = [IO.Path]::GetFileName($p); mime = $MIME[$ext]; size = $len prompt = [string]$d.prompt; seed = $(if ($d.seed -ne $null) { [long]$d.seed } else { 0 }); model = [string]$d.model } $script:inbox = @($script:inbox) + @([pscustomobject]$it) if ($script:inbox.Count -gt 300) { $script:inbox = @($script:inbox | Select-Object -Last 300) } Save-Inbox; Say ("[API] 收到作品:" + $it.name + "(创作台开着就会自动出现)") DarkCyan return @{ code = '200 OK'; o = @{ id = $it.id } } } # ---------- 剪辑台中转:AI(Codex 等)在命令行发指令 → 这里排队 → 开着「AI 控制」的剪辑台网页取走执行 → 结果放回来 ---------- $script:lcQ = New-Object System.Collections.ArrayList; $script:lcRes = @{}; $script:lcSeen = 0; $script:lcHelp = '' function Lc-Online { return ((Now) - $script:lcSeen) -lt 6000 } function Send-File($stream, $path, $mime, $origin) { $fs = [IO.File]::OpenRead($path) try { $h = "HTTP/1.1 200 OK`r`nContent-Type: $mime`r`nContent-Length: $($fs.Length)`r`nCache-Control: no-store`r`nConnection: close`r`n" if ($origin -and $origin -match $OK_ORIGIN) { $h += "Access-Control-Allow-Origin: $origin`r`nVary: Origin`r`nAccess-Control-Allow-Private-Network: true`r`n" } $hb = [Text.Encoding]::ASCII.GetBytes($h + "`r`n"); $stream.Write($hb, 0, $hb.Length) $stream.WriteTimeout = 60000; $fs.CopyTo($stream, 1MB); $stream.Flush() } finally { $fs.Close() } } function Handle($req, $stream) { $o = $req.origin if ($req.method -eq 'OPTIONS') { Send $stream '204 No Content' '' $o; return } # 写操作只接受追光网站和本机页面发来的(防止别的网站遥控你的引擎) if ($req.method -eq 'POST' -and -not ($o -and $o -match $OK_ORIGIN)) { Send $stream '403 Forbidden' (J @{ error = 'origin not allowed' }) $o; return } switch ($req.path) { '/status' { Send $stream '200 OK' (StatusJson) $o; return } '/start' { $id = $req.qs['model']; if (-not $id) { $id = $S.model }; if (-not $id) { $id = 'flux' } if ($S.model -eq $id -and ($S.state -eq 'ready' -or $S.state -eq 'loading')) { Send $stream '200 OK' (StatusJson) $o; return } $e = Start-Sd $id if ($e) { $S.state = 'error'; $S.error = $e; Say $e Red; Send $stream '409 Conflict' (J @{ error = $e }) $o } else { Send $stream '200 OK' (StatusJson) $o } return } '/stop' { Stop-Sd; Send $stream '200 OK' (StatusJson) $o; return } '/inbox' { if ($req.method -eq 'POST') { $r = Add-Inbox $req.body; Send $stream $r.code (J $r.o) $o; return } $items = @($script:inbox | ForEach-Object { [ordered]@{ id = $_.id; ts = $_.ts; name = $_.name; mime = $_.mime; size = $_.size; prompt = $_.prompt; seed = $_.seed; model = $_.model } }) Send $stream '200 OK' (ConvertTo-Json @{ items = $items } -Depth 4 -Compress) $o; return } '/inbox/file' { $it = @($script:inbox | Where-Object { $_.id -eq $req.qs['id'] }) | Select-Object -First 1 if (-not $it -or -not [IO.File]::Exists($it.path)) { Send $stream '404 Not Found' (J @{ error = '文件不在了' }) $o; return } Send-File $stream $it.path $it.mime $o; return } '/lc/run' { if ($req.method -ne 'POST') { Send $stream '405 Method Not Allowed' (J @{ error = '用 POST' }) $o; return } if (-not (Lc-Online)) { Send $stream '409 Conflict' (J @{ error = '剪辑台没连上:请在浏览器打开追光剪辑台,点右上角「AI 控制」开关(变绿)' }) $o; return } $d = $null; try { $d = $req.body | ConvertFrom-Json } catch {} if (-not $d -or (-not $d.steps -and -not $d.name)) { Send $stream '400 Bad Request' (J @{ error = 'body 要是 {"steps":[{"name":..,"args":{..}}]} 或 {"name":..,"args":{..}}' }) $o; return } $id = [guid]::NewGuid().ToString('N').Substring(0, 10) [void]$script:lcQ.Add([ordered]@{ id = $id; cmd = $req.body }) Send $stream '200 OK' (J @{ id = $id }) $o; return } '/lc/result' { $id = [string]$req.qs['id'] if ($script:lcRes.ContainsKey($id)) { $r = $script:lcRes[$id]; $script:lcRes.Remove($id); Send $stream '200 OK' ('{"done":true,"result":' + $r + '}') $o; return } Send $stream '200 OK' (J @{ done = $false; online = (Lc-Online) }) $o; return } '/lc/poll' { $script:lcSeen = Now $items = @($script:lcQ | ForEach-Object { '{"id":"' + $_.id + '","cmd":' + $_.cmd + '}' }); $script:lcQ.Clear() Send $stream '200 OK' ('{"cmds":[' + ($items -join ',') + ']}') $o; return } '/lc/done' { $id = [string]$req.qs['id']; if ($id) { $script:lcRes[$id] = $(if ($req.body) { $req.body } else { 'null' }) } if ($script:lcRes.Count -gt 200) { $script:lcRes.Clear() } Send $stream '200 OK' (J @{ ok = $true }) $o; return } '/lc/hello' { $script:lcSeen = Now; if ($req.body) { $script:lcHelp = $req.body }; Send $stream '200 OK' (J @{ ok = $true }) $o; return } '/lc/help' { Send $stream '200 OK' $(if ($script:lcHelp) { $script:lcHelp } else { '剪辑台还没连上:打开追光剪辑台,点右上角「AI 控制」。' }) $o 'text/plain; charset=utf-8'; return } '/lc/status' { Send $stream '200 OK' (J @{ online = (Lc-Online); pending = $script:lcQ.Count }) $o; return } '/inbox/done' { $ids = @(([string]$req.qs['id']) -split ','); $script:inbox = @($script:inbox | Where-Object { $ids -notcontains $_.id }); Save-Inbox Send $stream '200 OK' (J @{ ok = $true; left = $script:inbox.Count }) $o; return } '/quit' { Stop-Sd # WanGP 自带界面(7870)如果开着也一起关掉 if ($isWin) { try { Get-NetTCPConnection -LocalPort 7870 -State Listen -ErrorAction Stop | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue } } catch {} } Send $stream '200 OK' (J @{ bye = $true }) $o; $script:quit = $true; return } '/log' { $n = 200; if ($req.qs['n']) { $n = [int]$req.qs['n'] } $t = ''; try { $t = (Get-Content -LiteralPath $sdOut -Tail $n -Encoding UTF8 -ErrorAction Stop) -join "`n" } catch {} Send $stream '200 OK' $t $o 'text/plain; charset=utf-8'; return } default { Send $stream '200 OK' (J @{ manager = $VER; routes = @('/status', '/start?model=flux|zimage|ltx', '/stop', '/log', '/quit', '/inbox', '/lc/run', '/lc/result', '/lc/help', '/lc/status'); video = 'WanGP' }) $o } } } # ---------- 只允许一个管家:已经在运行就把请求转给它 ---------- $listener = $null try { $listener = New-Object Net.Sockets.TcpListener([Net.IPAddress]::Loopback, $PORT); $listener.Start() } catch { # 端口被占:先问问占着的那个管家还活不活(窗口被点住、卡死时不会回应) $alive = $false foreach ($try in 1..2) { if (-not $alive) { try { $r = [Net.WebRequest]::Create("http://127.0.0.1:$PORT/status"); $r.Proxy = $null; $r.Timeout = 8000; $r.GetResponse().Close(); $alive = $true } catch {} } } if ($alive) { if ($Model) { try { $r = [Net.WebRequest]::Create("http://127.0.0.1:$PORT/start?model=$Model"); $r.Method = 'POST'; $r.Headers.Add('Origin', 'http://127.0.0.1'); $r.ContentLength = 0; $r.GetResponse().Close() } catch {} } Write-Host '本地引擎已经在运行了,回网页用就行。' -ForegroundColor Green; Start-Sleep 3; exit } Write-Host '之前的引擎管家没有回应(可能卡住了),关掉它重新启动……' -ForegroundColor Yellow if ($isWin) { try { Get-NetTCPConnection -LocalPort $PORT -State Listen -ErrorAction Stop | ForEach-Object { if ($_.OwningProcess -ne $PID) { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue } } } catch {} # 旧管家启动的出图/视频引擎会继承 7860 的监听句柄:旧管家死了,端口还被它们「挂着」没人应答,所以一起关掉 try { Get-NetTCPConnection -LocalPort $SDPORT -State Listen -ErrorAction Stop | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue } } catch {} try { Get-CimInstance Win32_Process -ErrorAction Stop | Where-Object { $_.ProcessId -ne $PID -and ($_.Name -eq 'sd-server.exe' -or $_.CommandLine -like '*aizg-wangp-bridge*' -or ($_.CommandLine -like '*manager.ps1*' -and $_.CommandLine -like "*$dir*")) } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } } catch {} } $listener = $null foreach ($try in 1..15) { Start-Sleep 1 try { $listener = New-Object Net.Sockets.TcpListener([Net.IPAddress]::Loopback, $PORT); $listener.Start(); break } catch { $listener = $null } } if (-not $listener) { $who = '' try { $who = (Get-NetTCPConnection -LocalPort $PORT -State Listen -ErrorAction Stop | ForEach-Object { $pp = Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue; "$($pp.ProcessName)(PID $($_.OwningProcess))$($pp.Path)" }) -join ';' } catch {} Write-Host "端口 $PORT 被别的程序占着:$who" -ForegroundColor Red Write-Host '如果是别的软件(比如别的 AI 工具的网页界面),先关掉它;是「AI追光 · 本地引擎」窗口就全部关掉,再双击启动。还不行就重启一下电脑。' -ForegroundColor Yellow Read-Host '按回车关闭'; exit } } # 别让子进程(sd-server / 视频桥)继承 7860 的监听句柄:否则管家一关,端口会被子进程「挂」住、谁也连不上 if ($isWin) { try { Add-Type -Namespace AIZG -Name H -MemberDefinition '[DllImport("kernel32.dll", SetLastError=true)] public static extern bool SetHandleInformation(IntPtr h, uint mask, uint flags);' [void][AIZG.H]::SetHandleInformation($listener.Server.Handle, 1, 0) } catch {} } Write-Host '' Write-Host ' AI追光 · 本地引擎已启动(引擎管家)' -ForegroundColor Cyan Write-Host ' 用的时候别关这个窗口;网页上可以切换模型、关闭引擎。' -ForegroundColor Cyan Write-Host '' $first = $Model; if (-not $first) { $first = $last }; if (-not $first -or -not (Installed $first)) { $first = 'flux' } # -NoModel:只当剪辑台遥控的信箱,不载入任何模型(秒开、不占显卡);要出图时在网页上切模型就行 if ($NoModel) { $S.state = 'stopped'; Say '剪辑台遥控模式:没有载入模型,不占显卡。剪辑台点亮「AI 控制」就能用;要出图做视频时在创作台切换模型。' Green } else { $e = Start-Sd $first; if ($e) { $S.state = 'error'; $S.error = $e; Say $e Red } } $quit = $false; $tk = 0 while (-not $quit) { if ($listener.Pending()) { $c = $listener.AcceptTcpClient() try { $st = $c.GetStream(); $req = Read-Req $st; if ($req) { Handle $req $st } } catch {} finally { try { $c.Close() } catch {} } } else { Start-Sleep -Milliseconds 80 $tk++; if ($tk -ge 12) { $tk = 0; Tick } } } $listener.Stop() Say '引擎管家已退出。' Yellow '@ $mgrPath = Join-Path $dir 'manager.ps1' Set-Content -LiteralPath $mgrPath -Value $mgr -Encoding UTF8 # 剪辑台遥控小工具:Codex 等命令行 AI 用它操作开着「AI 控制」的追光剪辑台 $lcps = @' # AI追光 · 剪辑台遥控(给 Codex / Claude Code 等命令行 AI 用) # 前提:引擎管家开着(双击「追光本地引擎」),浏览器里的追光剪辑台点了右上角「AI 控制」(变绿)。 # 用法: # lc.ps1 help 看剪辑台全部工具和用法 # lc.ps1 status 看剪辑台连上没有 # lc.ps1 cmd.json 执行 json 文件里的指令(推荐:中文和引号最不容易出错) # lc.ps1 '{"name":"get_project"}' 直接执行一条 # 指令格式:{"name":"工具名","args":{...}} 或 {"steps":[{"name":..,"args":{..}}, ...], "stopOnError":true} param([Parameter(Position = 0)][string]$Cmd = 'help', [int]$Timeout = 300) $ErrorActionPreference = 'Stop' [Console]::OutputEncoding = [Text.Encoding]::UTF8 $base = 'http://127.0.0.1:7860' $hdr = @{ Origin = 'http://localhost' } function Get-Text($url) { $r = Invoke-WebRequest -UseBasicParsing -Uri $url -Headers $hdr; return [Text.Encoding]::UTF8.GetString($r.RawContentStream.ToArray()) } try { if ($Cmd -eq 'help') { Get-Text "$base/lc/help"; exit 0 } if ($Cmd -eq 'status') { Get-Text "$base/lc/status"; exit 0 } $json = if (Test-Path -LiteralPath $Cmd) { Get-Content -LiteralPath $Cmd -Raw -Encoding UTF8 } else { $Cmd } $bytes = [Text.Encoding]::UTF8.GetBytes($json) try { $r = Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$base/lc/run" -Headers $hdr -ContentType 'application/json; charset=utf-8' -Body $bytes } catch { $t = ''; if ($_.ErrorDetails -and $_.ErrorDetails.Message) { $t = $_.ErrorDetails.Message }; if (-not $t) { try { $sr = New-Object IO.StreamReader($_.Exception.Response.GetResponseStream(), [Text.Encoding]::UTF8); $t = $sr.ReadToEnd() } catch {} }; if (-not $t) { $t = "连不上引擎管家($base):先双击桌面「追光本地引擎」" }; try { $eo = $t | ConvertFrom-Json; if ($eo.error) { $t = '{"ok":false,"error":"' + $eo.error + '"}' } } catch {}; Write-Output $t; exit 1 } $id = ([Text.Encoding]::UTF8.GetString($r.RawContentStream.ToArray()) | ConvertFrom-Json).id $t0 = Get-Date while (((Get-Date) - $t0).TotalSeconds -lt $Timeout) { $t = Get-Text "$base/lc/result?id=$id" if ($t -match '^\{"done":true') { Write-Output ($t.Substring(22, $t.Length - 23)); exit 0 } Start-Sleep -Milliseconds 250 } Write-Output "{""ok"":false,""error"":""等了 $Timeout 秒剪辑台没回应:看看剪辑台网页是否还开着、「AI 控制」是否还是绿色""}"; exit 1 } catch { Write-Output "{""ok"":false,""error"":""$($_.Exception.Message -replace '"','')""}"; exit 1 } '@ Set-Content -LiteralPath (Join-Path $dir 'lc.ps1') -Value $lcps -Encoding UTF8 $lcr = @' # AI追光 · 剪辑台遥控(只开「信箱」,不载入任何模型,秒开、不占显卡) # 让 Codex 等 AI 操作追光剪辑台时双击这个就够了;要出图 / 做视频时在创作台切换模型即可。 $dir = $PSScriptRoot try { $r = Invoke-WebRequest -UseBasicParsing -Uri 'http://127.0.0.1:7860/status' -TimeoutSec 2 $v = 0; try { $v = [double](([Text.Encoding]::UTF8.GetString($r.RawContentStream.ToArray()) | ConvertFrom-Json).manager -replace '^.*?([\d.]+)\s*$', '$1') } catch {} if ($v -ge 1.4) { Write-Host '引擎已经在运行,剪辑台遥控可以直接用:在剪辑台点亮右上角「AI 控制」。' -ForegroundColor Green; Start-Sleep 4; exit } Write-Host '正在运行的是旧版引擎,不支持剪辑台遥控:请先关掉那个黑色引擎窗口,再双击本快捷方式。' -ForegroundColor Yellow; Read-Host '按回车关闭'; exit } catch {} & (Join-Path $dir 'manager.ps1') -NoModel '@ Set-Content -LiteralPath (Join-Path $dir 'lc-remote.ps1') -Value $lcr -Encoding UTF8 [IO.File]::WriteAllText((Join-Path $dir '剪辑台遥控.bat'), "@echo off`r`npowershell -NoProfile -ExecutionPolicy Bypass -File `"%~dp0lc-remote.ps1`"`r`n", [Text.Encoding]::ASCII) $start = @' # AI追光 · 启动本地引擎(交给引擎管家;旧的快捷方式和 bat 都走这里) param([string]$Url = '') & (Join-Path $PSScriptRoot 'manager.ps1') -Url $Url '@ $startPath = Join-Path $dir 'start-engine.ps1' Set-Content -LiteralPath $startPath -Value $start -Encoding UTF8 # 视频桥:把 WanGP 接到网页创作台(由引擎管家启动) $bridge = @' # -*- coding: utf-8 -*- """ AI追光 · WanGP 视频桥(127.0.0.1:7861) 把 WanGP(github.com/deepbeepmeep/Wan2GP,WanGP Community License 2.0)的 Python API 包成和 sd-server 一样的接口,让 AI追光 本地创作台直接用 LTX-2.5 出视频。 视频引擎:WanGP by DeepBeepMeep —— 免费使用,不得用于收费服务。 用法(由引擎管家 manager.ps1 自动启动,一般不用手动跑): D:\\Wan2GP\\env_venv\\Scripts\\python.exe aizg-wangp-bridge.py --root D:\\Wan2GP --port 7861 接口: GET /sdcpp/v1/capabilities 就绪后 200,载入中 503 POST /sdcpp/v1/vid_gen {prompt,width,height,video_frames,seed,init_image?,end_image?} → {id} GET /sdcpp/v1/jobs/ {status: queued|running|completed|failed|cancelled, progress{...}, result{video_url,mime_type}} POST /sdcpp/v1/jobs//cancel GET /files/ 生成好的 mp4 """ import argparse, base64, json, os, re, sys, threading, time, traceback, uuid from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path # 电脑没装 git 时,WanGP 引用的 GitPython 会直接报错退出(只有插件更新才用 git),这里让它安静跳过 os.environ.setdefault("GIT_PYTHON_REFRESH", "quiet") # __HF_MIRROR_V1__ 国内连不上 Hugging Face 时自动改用镜像 hf-mirror.com 下载模型 # (症状:日志里 0.00 GB 就失败,"locate the file on the Hub" / "Unable to Download") HF_MIRROR = "https://hf-mirror.com" def hf_reachable(base, timeout=8): import urllib.request, urllib.error try: urllib.request.urlopen(urllib.request.Request(base + "/api/models/DeepBeepMeep/LTX-2", method="HEAD", headers={"User-Agent": "aizg-bridge"}), timeout=timeout) return True except urllib.error.HTTPError as e: return e.code < 500 except Exception: return False def hf_use_mirror(): """切到镜像:环境变量 + 已经载入的 huggingface_hub 常量都改掉(下载时才读取,所以中途切换也生效)。""" os.environ["HF_ENDPOINT"] = HF_MIRROR os.environ["HF_HUB_DISABLE_XET"] = "1" # 镜像不转发 Xet 存储,走普通 HTTP 下载 try: import huggingface_hub.constants as C C.ENDPOINT = HF_MIRROR C.HUGGINGFACE_CO_URL_TEMPLATE = HF_MIRROR + "/{repo_id}/resolve/{revision}/{filename}" if hasattr(C, "HF_HUB_DISABLE_XET"): C.HF_HUB_DISABLE_XET = True except Exception: pass def hf_pick_endpoint(): """启动时检查一次:官方能连就用官方;连不上而镜像能连,就改用镜像。用户自己设过 HF_ENDPOINT 就不动。""" if os.environ.get("HF_ENDPOINT"): return if hf_reachable("https://huggingface.co"): return if hf_reachable(HF_MIRROR): hf_use_mirror() print(time.strftime("%H:%M:%S"), "Hugging Face 官方连不上,模型改从国内镜像 hf-mirror.com 下载", flush=True) VER = "aizg-wangp-bridge 2.3" BRIDGE_API = 5 # 3 = 支持多图参考(MSR)+ 配音对口型;4 = 报告模型下载进度;5 = 视频放大 / 补帧(vid_up) # 网页据此判断视频桥支持哪些功能 OK_ORIGIN = re.compile(r"^(https://(www\.)?aizhuiguang\.tech|http://(localhost|127\.0\.0\.1)(:\d+)?)$") MODEL_TYPE = "ltx2_25_22B_distilled" MSR_TYPE = "ltx2_25_22B_msr" # LTX-2.5 + LiconStudio 多主体参考(Apache-2.0) ap = argparse.ArgumentParser() ap.add_argument("--root", default=r"D:\Wan2GP") ap.add_argument("--port", type=int, default=7861) ap.add_argument("--model", default=MODEL_TYPE) ap.add_argument("--warmup", action="store_true", help="安装程序用:先把视频模型下载好(约 25GB),再试生成一条 1 秒小视频,然后退出") ARGS = ap.parse_args() ROOT = Path(ARGS.root).resolve() HERE = Path(__file__).resolve().parent def default_model(): """默认视频模型:优先 aizg-video.json({"model_type": "..."},升级时改这个文件就行),否则用 --model。""" try: d = json.loads((HERE / "aizg-video.json").read_text(encoding="utf-8")) if d.get("model_type"): return str(d["model_type"]) except Exception: pass return ARGS.model VIDEO_MODELS = [] # WanGP 里所有 LTX 视频模型(升级 WanGP 后新模型会自动出现在这里) TMP = ROOT / "aizg_inputs" def log(*a): print(time.strftime("%H:%M:%S"), *a, flush=True) # ---------------- 状态 ---------------- STATE = {"ready": False, "error": "", "loading_since": time.time()} SESSION = None JOBS = {} # id -> dict QUEUE = [] # ids waiting LOCK = threading.Lock() WAKE = threading.Event() DL = {} # 正在下载的模型文件:{file, done, total, speed, idx, cnt, t} def fix_config(): """WanGP 的 wgp_config.json 有时缺键(例如 clear_file_list),补上默认值。""" p = ROOT / "wgp_config.json" try: d = json.loads(p.read_text(encoding="utf-8")) if p.exists() else None if isinstance(d, dict) and "clear_file_list" not in d: d["clear_file_list"] = 5 p.write_text(json.dumps(d, indent=4, ensure_ascii=False), encoding="utf-8") log("已补上 wgp_config.json 的 clear_file_list") except Exception as e: log("检查 wgp_config.json 出错:", e) _DLLOG = {"t": 0, "key": None} def dl_log(): """下载进度每 10 秒写一行日志(引擎管家的黑色窗口会抄过去显示)。""" k = (DL.get("file"), DL.get("idx")) done = DL.get("total") and DL.get("done", 0) >= DL.get("total") if time.time() - _DLLOG["t"] < 10 and k == _DLLOG["key"] and not done: return if done and _DLLOG.get("done") == k: return _DLLOG.update(t=time.time(), key=k, done=k if done else None) d = DL part = "第 %s/%s 个 " % (d.get("idx"), d.get("cnt")) if d.get("cnt") else "" size = "%.2f" % (d.get("done", 0) / 1e9) + (" / %.2f" % (d["total"] / 1e9) if d.get("total") else "") + " GB" spd = " · %.1f MB/s" % (d["speed"] / 1e6) if d.get("speed") else "" log("下载视频模型(只下一次)" + part + (d.get("file") or "") + ":" + size + spd) def repair_requirements(missing): """WanGP 更新后多了新依赖(例如 whisper)没装上:自动按 WanGP 的 requirements.txt 补装一次(先国内镜像,再官方)。""" req = ROOT / "requirements.txt" if not req.exists(): return False import subprocess log(f"视频引擎缺少组件 {missing},正在自动补装(几分钟,只需一次)…") for idx in ("https://pypi.tuna.tsinghua.edu.cn/simple", None): env = dict(os.environ, PIP_DEFAULT_TIMEOUT="180", PIP_RETRIES="10", PIP_DISABLE_PIP_VERSION_CHECK="1") if idx: env["PIP_INDEX_URL"] = idx else: env.pop("PIP_INDEX_URL", None) try: r = subprocess.run([sys.executable, "-m", "pip", "install", "-r", str(req)], env=env, cwd=str(ROOT)) if r.returncode == 0: log("补装完成,重新载入 WanGP …") return True except Exception as e: log("补装出错:", e) return False def load_session(_repaired=False): global SESSION try: fix_config() sys.path.insert(0, str(ROOT)) os.chdir(ROOT) from shared.api import init # noqa try: # 第一次生成视频会自动下载模型(约 25GB):接住 WanGP 的下载进度,网页上显示「下载中 x/y GB」 import shared.utils.download_progress as _dp _orig_upd = _dp._DownloadProgress.update def _upd(self, completed, total, force=False): _orig_upd(self, completed, total, force) DL.update({"file": self.filename or "", "done": int(completed or 0), "total": int(total or 0), "speed": float((self.gen or {}).get("download_progress", {}).get("speed") or 0), "idx": self.file_index, "cnt": self.file_count, "t": time.time()}) dl_log() _dp._DownloadProgress.update = _upd except Exception as e: log("接下载进度失败(不影响生成):", e) log("载入 WanGP …(第一次要一两分钟)") SESSION = init(root=ROOT, cli_args=[], console_output=True) try: for m in SESSION.list_model_metadata(family="ltx2") or []: mt = m.get("model_type") if isinstance(m, dict) else None if mt: VIDEO_MODELS.append({"type": mt, "name": m.get("name") or mt}) except Exception as e: log("读模型列表失败(不影响使用):", e) ARGS.model = default_model() STATE["ready"] = True log(f"WanGP 已就绪 · 载入 {time.time() - STATE['loading_since']:.0f} 秒 · 默认视频模型 {ARGS.model} · 可用 LTX 模型 {len(VIDEO_MODELS)} 个") except Exception as e: if isinstance(e, ModuleNotFoundError) and not _repaired: traceback.print_exc() if repair_requirements(getattr(e, "name", "") or str(e)): import importlib for k in [k for k in sys.modules if k == "wgp" or k.startswith(("shared", "models", "postprocessing", "preprocessing"))]: sys.modules.pop(k, None) importlib.invalidate_caches() return load_session(_repaired=True) STATE["error"] = (f"视频引擎没装完整(缺 {getattr(e, 'name', '')}):请重新双击一次安装程序,它会把视频引擎补装好。" if isinstance(e, ModuleNotFoundError) else f"WanGP 载入失败:{e}") log(STATE["error"]); traceback.print_exc() # ---------------- 任务 ---------------- def save_data_url(s, name): if not s: return None m = re.match(r"^data:(?:image|audio|video)/([\w.+-]+);base64,(.*)$", s, re.S) ext, b64 = (m.group(1), m.group(2)) if m else ("png", s) ext = {"jpeg": "jpg", "mpeg": "mp3", "x-wav": "wav", "wave": "wav", "x-m4a": "m4a", "mp4": "m4a", "webm": "webm", "ogg": "ogg"}.get(ext, ext) TMP.mkdir(parents=True, exist_ok=True) p = TMP / f"{name}.{ext}" p.write_bytes(base64.b64decode(b64)) return str(p) def clean_prompt(t): # WanGP 把空行隔开的每段当成一条独立视频、# 开头的行当注释:合并成一段 t = (t or "").replace("\r", "") t = re.sub(r"\n\s*\n+", "\n", t) t = "\n".join(("#" + l[1:]) if l.lstrip().startswith("#") else l for l in t.split("\n")) return t.strip() def pick_model(want): """网页指定的模型在 WanGP 里有就用它,没有就用默认(旧版 WanGP 没有新模型时不会出错)。""" known = {m["type"] for m in VIDEO_MODELS} if want and (want in known or not known): return want if want: log(f"网页要的 {want} 这版 WanGP 里没有,改用 {ARGS.model}(要用新模型请更新 WanGP)") return ARGS.model def build_settings(job): b = job["body"] refs = [r for r in (b.get("ref_images") or []) if r][:5] mt = pick_model(b.get("ref_model_type") or MSR_TYPE) if refs else pick_model(b.get("model_type")) st = {} try: st = dict(SESSION.get_default_settings(mt) or {}) except Exception as e: log("读默认设置失败,用最小设置:", e) w, h = int(b.get("width") or 1280), int(b.get("height") or 704) frames = int(b.get("video_frames") or 121) seed = int(b.get("seed") if b.get("seed") is not None else -1) st.update({ "model_type": mt, "prompt": clean_prompt(b.get("prompt")), "negative_prompt": b.get("negative_prompt") or "", "resolution": f"{w}x{h}", "video_length": frames, "seed": seed, "num_inference_steps": int((b.get("sample_params") or {}).get("sample_steps") or 8), "repeat_generation": 1, "image_prompt_type": "", "image_start": None, "image_end": None, }) # 配音 / 声音参考:给一段说话录音,视频按这段声音对口型(输出的声音就是这段录音) a = save_data_url(b.get("audio"), job["id"] + "_voice") if a: st["audio_prompt_type"] = "A"; st["audio_guide"] = a if b.get("audio_strength") is not None: st["audio_scale"] = float(b.get("audio_strength")) s = save_data_url(b.get("init_image"), job["id"] + "_start") e = save_data_url(b.get("end_image"), job["id"] + "_end") if refs: # 多图参考(MSR):不能用首帧;第 1 张是背景时用 KI(背景 + 最多 4 个主体),否则 I(最多 4 个主体) paths = [save_data_url(r, f"{job['id']}_ref{i + 1}") for i, r in enumerate(refs)] bg = bool(b.get("ref_bg", True)) and len(paths) >= 2 st["image_refs"] = paths st["video_prompt_type"] = "KI" if bg else "I" st["image_prompt_type"] = "E" if e else "" st["image_start"] = None if e: st["image_end"] = e if not bg: st["image_refs"] = paths[:4] return st if s: st["image_start"] = s; st["image_prompt_type"] = "S" if e: st["image_end"] = e; st["image_prompt_type"] = (st["image_prompt_type"] or "") + "E" if not s: st["image_prompt_type"] = "E" return st UP_SPATIAL = {"flashvsr*1.5", "flashvsr*2", "lanczos*1.5", "lanczos*2", "ltx25*2", ""} UP_TEMPORAL = {"rife*2", ""} def build_upscale(job): """视频放大 / 补帧:用 WanGP 自带的后期处理(FlashVSR 放大、RIFE 补帧),输入是网页传来的 mp4。""" from shared.api import build_media_postprocessing_settings b = job["body"] m = re.match(r"^data:video/[\w.+-]+;base64,(.*)$", b.get("video") or "", re.S) if not m: raise ValueError("没收到视频") TMP.mkdir(parents=True, exist_ok=True) src = TMP / f"{job['id']}_src.mp4" src.write_bytes(base64.b64decode(m.group(1))) sp, tp = b.get("spatial") or "", b.get("temporal") or "" if sp not in UP_SPATIAL or tp not in UP_TEMPORAL or not (sp or tp): raise ValueError(f"不支持的放大方式:{sp} / {tp}") return build_media_postprocessing_settings(str(src), spatial_upsampling=sp, temporal_upsampling=tp, seed=int(b.get("seed") or 1)) def run_job(job): job["status"] = "running"; job["started"] = time.time() try: if job.get("kind") == "up": settings = build_upscale(job) log(f"开始放大 {job['id']} · 画面 {settings.get('spatial_upsampling') or '不放大'} · 补帧 {settings.get('temporal_upsampling') or '不补'}") else: settings = build_settings(job) if job.get("kind") != "up": log(f"开始 {job['id']} · {settings['model_type']} · {settings['resolution']} · {settings['video_length']} 帧 · 首帧{'有' if settings.get('image_start') else '无'} 尾帧{'有' if settings.get('image_end') else '无'}" + (f" · 参考图 {len(settings['image_refs'])} 张({settings.get('video_prompt_type')})" if settings.get('image_refs') else "") + (" · 带配音" if settings.get('audio_guide') else "")) h = SESSION.submit_task(settings) job["handle"] = h for ev in h.events.iter(timeout=0.5): if job.get("cancel") and not h.cancel_requested: h.cancel() k = getattr(ev, "kind", "") d = getattr(ev, "data", None) if DL.get("t", 0) > time.time() - 3: pr = job.setdefault("progress", {}) pr.update({"phase": "download", "download": {k2: DL[k2] for k2 in ("file", "done", "total", "speed", "idx", "cnt")}}) elif (job.get("progress") or {}).get("phase") == "download": job["progress"]["phase"] = "loading_model"; job["progress"].pop("download", None) if k in ("progress", "preview") and d is not None: pr = job.setdefault("progress", {}) ph = getattr(d, "phase", "") or "" if ph and pr.get("phase") != "download" and not (pr.get("phase") == "refine" and ph == "inference"): pr["phase"] = ph if getattr(d, "current_step", None): pr["step"] = d.current_step; pr["total"] = getattr(d, "total_steps", None) if getattr(d, "progress", None): pr["percent"] = max(pr.get("percent", 0), int(d.progress)) pr["text"] = str(getattr(d, "status", "") or "") elif k == "stream" and d is not None: # WanGP 的进度有时只在控制台进度条里(例如 " 3/8 [00:11<00:19"),从这里读步数 txt = str(getattr(d, "text", "") or "") mb = None for mb in re.finditer(r"([\d.]+)([kMGT]?)B?/([\d.]+)([kMGT])B? \[(?:[^\],]*,\s*([\d.]+)([kMG]?)B/s)?", txt): pass if mb and DL.get("t", 0) < time.time() - 3: u = {"": 1, "k": 1e3, "M": 1e6, "G": 1e9, "T": 1e12} DL.update({"file": "", "done": int(float(mb.group(1)) * u[mb.group(2)]), "total": int(float(mb.group(3)) * u[mb.group(4)]), "speed": float(mb.group(5) or 0) * u[mb.group(6) or ""], "idx": 0, "cnt": 0, "t": time.time()}) dl_log() m = None for m in re.finditer(r"(\d+)/(\d+) \[", txt): pass if m: st, tot = int(m.group(1)), int(m.group(2)) pr = job.setdefault("progress", {}) if "Encoding Text" in txt[max(0, m.start() - 60):m.start()]: pr.update({"phase": "encoding_text", "text": txt[-80:]}) elif tot <= 60: stage2 = pr.get("phase") == "refine" or (pr.get("phase") == "inference" and pr.get("total") and pr.get("step") == pr.get("total") and tot != pr.get("total")) pr.update({"phase": "refine" if stage2 else "inference", "step": st, "total": tot, "percent": (90 + int(st * 8 / max(1, tot))) if stage2 else int(st * 88 / max(1, tot))}) elif k == "error" and d is not None: job.setdefault("errors", []).append(str(getattr(d, "message", d))) if h.done: break r = h.result() files = [f for f in (r.generated_files or []) if str(f).lower().endswith((".mp4", ".mkv", ".webm", ".mov"))] if getattr(r, "cancelled", False) or job.get("cancel"): job["status"] = "cancelled" elif files: job["file"] = files[-1]; job["status"] = "completed" job["result"] = {"video_url": f"/files/{job['id']}", "mime_type": "video/mp4", "path": files[-1]} log(f"完成 {job['id']} · {time.time() - job['started']:.0f} 秒 · {files[-1]}") else: msg = "; ".join([str(getattr(x, "message", x)) for x in (r.errors or [])] + job.get("errors", [])) or "WanGP 没有生成视频" # 下载模型时网络被掐断(Hugging Face 常见 ConnectionReset):自动重试 3 次,已下的部分会接着下 if "Unable to Download" in msg and job.get("dl_retry", 0) < 3 and not job.get("cancel"): job["dl_retry"] = job.get("dl_retry", 0) + 1; job["errors"] = [] if os.environ.get("HF_ENDPOINT", "").rstrip("/") != HF_MIRROR and hf_reachable(HF_MIRROR): # __HF_MIRROR_V1__ hf_use_mirror(); log("官方 Hugging Face 下载失败,改用国内镜像 hf-mirror.com 重试") log(f"下载被网络中断,{job['dl_retry'] * 10} 秒后自动重试(第 {job['dl_retry']}/3 次)……") job.pop("handle", None) for _ in range(job["dl_retry"] * 10): if job.get("cancel"): break time.sleep(1) if not job.get("cancel"): return run_job(job) if "Unable to Download" in msg: msg = ("模型下载失败:官方 Hugging Face 和国内镜像 hf-mirror.com 都试过了,自动重试 3 次都没成功。" "请检查网络(开着代理/VPN 的话换个节点,或者关掉再试),然后再点一次生成就会接着下(已下载的部分不会重下)。原始错误:" + msg[:300]) job["status"] = "failed"; job["error"] = {"message": msg} log("失败:", msg) except Exception as e: job["status"] = "failed"; job["error"] = {"message": f"{type(e).__name__}: {e}"} log("出错:", e); traceback.print_exc() finally: job.pop("handle", None); job["finished"] = time.time() def worker(): while True: WAKE.wait(1.0); WAKE.clear() while STATE["ready"]: with LOCK: jid = QUEUE.pop(0) if QUEUE else None if not jid: break job = JOBS.get(jid) if job and job["status"] == "queued": if job.get("cancel"): job["status"] = "cancelled"; continue run_job(job) # ---------------- HTTP ---------------- class H(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" def log_message(self, *a): pass def _cors(self): o = self.headers.get("Origin") if o and OK_ORIGIN.match(o): self.send_header("Access-Control-Allow-Origin", o) self.send_header("Vary", "Origin") self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") self.send_header("Access-Control-Allow-Headers", "Content-Type") self.send_header("Access-Control-Allow-Private-Network", "true") def _json(self, code, obj): b = json.dumps(obj, ensure_ascii=False).encode("utf-8") self.send_response(code) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(b))) self.send_header("Cache-Control", "no-store") self._cors(); self.end_headers(); self.wfile.write(b) def do_OPTIONS(self): self.send_response(204); self.send_header("Content-Length", "0"); self._cors(); self.end_headers() def _job_view(self, j): v = {"id": j["id"], "status": j["status"], "progress": j.get("progress") or {}, "created": j["created"]} if j["status"] == "running" and DL.get("t", 0) > time.time() - 3: v["progress"] = dict(v["progress"], phase="download", download={k2: DL[k2] for k2 in ("file", "done", "total", "speed", "idx", "cnt")}) if j["status"] == "queued": with LOCK: v["queue_position"] = QUEUE.index(j["id"]) + 1 if j["id"] in QUEUE else 0 run = next((x for x in list(JOBS.values()) if x["status"] == "running"), None) if run: # 前面正在跑的任务(例如刷新网页前提交的那条):把它的进度也带上,网页就不会只显示「排队中」 rv = self._job_view(run) v["ahead"] = {"progress": rv.get("progress") or {}, "elapsed": int(time.time() - (run.get("started") or time.time()))} if j.get("error"): v["error"] = j["error"] if j.get("result"): v["result"] = {k: j["result"][k] for k in ("video_url", "mime_type")} return v def do_GET(self): p = self.path.split("?")[0] if p in ("/sdcpp/v1/capabilities", "/status"): if not STATE["ready"]: return self._json(503, {"loading": True, "error": STATE["error"], "since": STATE["loading_since"]}) wangp_ver = "" try: import wgp # noqa wangp_ver = getattr(wgp, "WanGP_version", "") except Exception: pass return self._json(200, {"engine": VER, "bridge_api": BRIDGE_API, "backend": "WanGP", "wangp_version": wangp_ver, "supported_modes": ["vid_gen", "vid_up"], "model": {"name": "LTX-2.5 Distilled 22B (WanGP)", "type": ARGS.model}, "video_models": VIDEO_MODELS, "default_model": ARGS.model, "upscalers": [], "license": "WanGP Community License 2.0 · free use only"}) m = re.match(r"^/sdcpp/v1/jobs/([\w-]+)$", p) if m: j = JOBS.get(m.group(1)) return self._json(200, self._job_view(j)) if j else self._json(404, {"error": "no such job"}) m = re.match(r"^/files/([\w-]+)$", p) if m: j = JOBS.get(m.group(1)) if not j or not j.get("file") or not os.path.exists(j["file"]): return self._json(404, {"error": "file not found"}) size = os.path.getsize(j["file"]) self.send_response(200) self.send_header("Content-Type", "video/mp4"); self.send_header("Content-Length", str(size)) self.send_header("Cache-Control", "no-store"); self._cors(); self.end_headers() with open(j["file"], "rb") as f: while True: c = f.read(1 << 20) if not c: break self.wfile.write(c) return return self._json(200, {"engine": VER, "routes": ["/sdcpp/v1/capabilities", "/sdcpp/v1/vid_gen", "/sdcpp/v1/jobs/", "/files/"]}) def do_POST(self): o = self.headers.get("Origin") if not (o and OK_ORIGIN.match(o)): return self._json(403, {"error": "origin not allowed"}) n = int(self.headers.get("Content-Length") or 0) if n > 80 * 1024 * 1024: return self._json(413, {"error": "图片太大"}) raw = self.rfile.read(n) if n else b"" p = self.path.split("?")[0] if p == "/sdcpp/v1/vid_up": # 视频放大 / 补帧:{video: data:video/mp4;base64,..., spatial: "flashvsr*1.5", temporal: "rife*2"} if not STATE["ready"]: return self._json(503, {"error": {"message": STATE["error"] or "WanGP 还在载入,稍等"}}) try: body = json.loads(raw.decode("utf-8") or "{}") except Exception: return self._json(400, {"error": {"message": "bad json"}}) if not body.get("video"): return self._json(400, {"error": {"message": "没收到视频"}}) jid = uuid.uuid4().hex[:12] JOBS[jid] = {"id": jid, "status": "queued", "body": body, "kind": "up", "created": time.time()} with LOCK: QUEUE.append(jid) WAKE.set() return self._json(202, {"id": jid, "status": "queued"}) if p == "/sdcpp/v1/vid_gen": if not STATE["ready"]: return self._json(503, {"error": {"message": STATE["error"] or "WanGP 还在载入,稍等"}}) try: body = json.loads(raw.decode("utf-8") or "{}") except Exception: return self._json(400, {"error": {"message": "bad json"}}) if not (body.get("prompt") or "").strip(): return self._json(400, {"error": {"message": "提示词是空的"}}) jid = uuid.uuid4().hex[:12] JOBS[jid] = {"id": jid, "status": "queued", "body": body, "created": time.time()} with LOCK: QUEUE.append(jid) WAKE.set() return self._json(202, {"id": jid, "status": "queued"}) if p == "/shutdown": # 管家窗口被关掉、桥留在后台时,网页可以直接关掉它 self._json(200, {"bye": True}) threading.Timer(0.5, lambda: os._exit(0)).start() return m = re.match(r"^/sdcpp/v1/jobs/([\w-]+)/cancel$", p) if m: j = JOBS.get(m.group(1)) if not j: return self._json(404, {"error": "no such job"}) j["cancel"] = True h = j.get("handle") if h: try: h.cancel() except Exception: pass return self._json(200, {"id": j["id"], "status": "cancelling"}) return self._json(404, {"error": "not found"}) def warmup(): """安装程序调用:提前下载视频模型并试跑一条小视频。退出码 0 = 好了,1 = 失败。""" load_session() if not STATE["ready"]: return 2 try: av = SESSION.get_model_availability(ARGS.model) if av.get("available"): log("视频模型已经下载好了,不用再下。") return 0 except Exception as e: log("查不到模型下载状态(不影响):", e) log("开始下载视频模型(约 25GB,只下一次;中途断了,再运行一次安装程序会接着下)……") job = {"id": "aizg-warmup", "status": "queued", "created": time.time(), "body": {"prompt": "a calm lake at sunrise, gentle ripples on the water", "width": 640, "height": 352, "video_frames": 25, "seed": 1}} t = threading.Thread(target=run_job, args=(job,), daemon=True) t.start() last = None while t.is_alive(): t.join(5) pr = job.get("progress") or {} ph = pr.get("phase") if ph in ("inference", "refine") and pr.get("step"): cur = (ph, pr.get("step")) if cur != last: last = cur log(("试生成:画面" if ph == "inference" else "试生成:精修") + " 第 %s/%s 步" % (pr.get("step"), pr.get("total"))) elif ph == "loading_model" and last != "load": last = "load"; log("模型下载好了,正在载入并试生成一条 1 秒的小视频(确认显卡能跑)……") if job.get("status") == "completed": try: os.remove(job["file"]) except Exception: pass log(f"视频模型准备好了,试生成成功(用时 {time.time() - job['created']:.0f} 秒)。") return 0 log("试生成没成功:", (job.get("error") or {}).get("message", "")) return 1 def main(): if ARGS.warmup: code = warmup() sys.stdout.flush() os._exit(code) srv = ThreadingHTTPServer(("127.0.0.1", ARGS.port), H) srv.daemon_threads = True log(f"{VER} · http://127.0.0.1:{ARGS.port} · WanGP 目录 {ROOT}") log("视频引擎:WanGP by DeepBeepMeep(WanGP Community License 2.0,免费使用)") threading.Thread(target=srv.serve_forever, daemon=True).start() threading.Thread(target=worker, daemon=True).start() hf_pick_endpoint() # __HF_MIRROR_V1__ load_session() if not STATE["ready"]: time.sleep(2); os._exit(2) # 让引擎管家看到「意外退出」和日志 try: while True: time.sleep(3600) except KeyboardInterrupt: pass if __name__ == "__main__": main() '@ [IO.File]::WriteAllText((Join-Path $dir 'aizg-wangp-bridge.py'), $bridge, (New-Object Text.UTF8Encoding($false))) # 重置引擎:卡住时一键把管家和后台引擎全关掉再启动 $rst = @' # AI追光 · 重置本地引擎:把卡住的引擎管家、后台的出图/视频引擎全部关掉,再重新启动 $dir = $PSScriptRoot Write-Host '正在关闭所有追光引擎进程……' -ForegroundColor Cyan try { Get-CimInstance Win32_Process -ErrorAction Stop | Where-Object { $_.ProcessId -ne $PID -and ($_.Name -eq 'sd-server.exe' -or $_.CommandLine -like '*aizg-wangp-bridge*' -or ($_.CommandLine -like '*manager.ps1*' -and $_.CommandLine -like "*$dir*")) } | ForEach-Object { Write-Host " 关闭 $($_.Name)(PID $($_.ProcessId))" -ForegroundColor DarkGray; Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } } catch {} foreach ($p in 7860, 7861) { try { Get-NetTCPConnection -LocalPort $p -State Listen -ErrorAction Stop | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue } } catch {} } Start-Sleep 2 Write-Host '重新启动引擎……' -ForegroundColor Green Start-Process powershell.exe -ArgumentList "-NoProfile -ExecutionPolicy Bypass -File `"$dir\manager.ps1`"" Start-Sleep 2 '@ Set-Content -LiteralPath (Join-Path $dir 'reset-engine.ps1') -Value $rst -Encoding UTF8 [IO.File]::WriteAllText((Join-Path $dir '重置引擎.bat'), "@echo off`r`npowershell -NoProfile -ExecutionPolicy Bypass -File `"%~dp0reset-engine.ps1`"`r`n", [Text.Encoding]::ASCII) # 更新视频引擎:WanGP 出新版 / 新模型时双击它(重新下 GitHub 官方 zip 覆盖程序,模型和设置都保留) $upd = @' # AI追光 · 更新视频引擎 WanGP $ErrorActionPreference = 'Stop' trap { Write-Host "出错了:$_" -ForegroundColor Red; Read-Host '按回车关闭'; exit 1 } $root = (Get-Content -LiteralPath (Join-Path $PSScriptRoot 'wangp-path.txt') -Raw -Encoding UTF8).Trim() if (-not (Test-Path -LiteralPath (Join-Path $root 'wgp.py'))) { throw "没找到 WanGP:$root" } Write-Host "更新 WanGP:$root(模型、作品、设置都会保留)" -ForegroundColor Cyan foreach ($p in 7861, 7870) { try { Get-NetTCPConnection -LocalPort $p -State Listen -ErrorAction Stop | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue } } catch {} } $zip = Join-Path $env:TEMP 'wan2gp-main.zip' $x = Join-Path $env:TEMP 'wan2gp-x' for ($i = 1; $i -le 3; $i++) { Remove-Item -LiteralPath $zip -Force -ErrorAction SilentlyContinue & curl.exe --ssl-no-revoke -L --fail --show-error --retry 5 --retry-all-errors --connect-timeout 30 -o $zip 'https://github.com/deepbeepmeep/Wan2GP/archive/refs/heads/main.zip' if ($LASTEXITCODE -ne 0) { throw '下载失败(GitHub 连不上)' } if (Test-Path $x) { Remove-Item $x -Recurse -Force } try { Expand-Archive -LiteralPath $zip -DestinationPath $x -Force -ErrorAction Stop; break } catch { if ($i -eq 3) { throw '解压失败:下载的文件不完整,换个网络再试' }; Write-Host '下载得不完整,重新下……' -ForegroundColor DarkYellow } } $src = Get-ChildItem $x -Directory | Select-Object -First 1 Copy-Item -Path (Join-Path $src.FullName '*') -Destination $root -Recurse -Force Remove-Item $x -Recurse -Force; Remove-Item $zip -Force Set-Content -LiteralPath (Join-Path $root 'scripts\args.txt') -Value '--server-port 7870' -Encoding ASCII $env:PIP_CACHE_DIR = Join-Path $root 'pip-cache'; $env:PYTHONUTF8 = '1' & (Join-Path $root 'env_venv\Scripts\python.exe') -m pip install -r (Join-Path $root 'requirements.txt') Write-Host '更新完成。回网页重新启动引擎就行(新模型第一次用会自动下载)。' -ForegroundColor Green Read-Host '按回车关闭' '@ Set-Content -LiteralPath (Join-Path $dir 'update-wangp.ps1') -Value $upd -Encoding UTF8 # ---- 6. 网页一键启动(aizg-engine://)+ 桌面快捷方式 + 卸载 ---- $cmd = "powershell.exe -NoProfile -ExecutionPolicy Bypass -File `"$mgrPath`" -Url `"%1`"" $k = 'HKCU:\Software\Classes\aizg-engine' New-Item -Path "$k\shell\open\command" -Force | Out-Null Set-Item -Path $k -Value 'URL:AI追光本地引擎' New-ItemProperty -Path $k -Name 'URL Protocol' -Value '' -PropertyType String -Force | Out-Null Set-Item -Path "$k\shell\open\command" -Value $cmd New-Item -Path 'HKCU:\Software\AIZG\Engine' -Force | Out-Null New-ItemProperty -Path 'HKCU:\Software\AIZG\Engine' -Name InstallDir -Value $dir -PropertyType String -Force | Out-Null # 桌面快捷方式:先用英文名建(WScript 在非中文系统里写不了中文文件名),再改成中文名 try { $desk = [Environment]::GetFolderPath('Desktop') $tmp = Join-Path $desk 'AIZG-Engine-tmp.lnk' $ws = New-Object -ComObject WScript.Shell $lnk = $ws.CreateShortcut($tmp) $lnk.TargetPath = (Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe') $lnk.Arguments = "-NoProfile -ExecutionPolicy Bypass -File `"$mgrPath`"" $lnk.WorkingDirectory = $dir $lnk.Description = 'AI zhuiguang local engine' $lnk.Save() $final = Join-Path $desk '追光本地引擎.lnk' if (Test-Path -LiteralPath $final) { Remove-Item -LiteralPath $final -Force } Move-Item -LiteralPath $tmp -Destination $final -Force # 第二个快捷方式:只开剪辑台遥控,不载入模型 $tmp2 = Join-Path $desk 'AIZG-LC-tmp.lnk'; $l2 = $ws.CreateShortcut($tmp2) $l2.TargetPath = (Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe') $l2.Arguments = "-NoProfile -ExecutionPolicy Bypass -File `"$(Join-Path $dir 'lc-remote.ps1')`"" $l2.WorkingDirectory = $dir; $l2.Description = 'AI zhuiguang editor remote (no model)'; $l2.Save() $final2 = Join-Path $desk '追光剪辑台遥控.lnk' if (Test-Path -LiteralPath $final2) { Remove-Item -LiteralPath $final2 -Force } Move-Item -LiteralPath $tmp2 -Destination $final2 -Force } catch { Write-Host "(桌面快捷方式没建成,不影响使用:$($_.Exception.Message))" -ForegroundColor DarkGray } $unps = @" # AI追光 · 卸载本地引擎 Write-Host '卸载 AI追光本地引擎:会删除 $dir 整个文件夹(所有模型),以及网页一键启动和桌面快捷方式。' -ForegroundColor Yellow if ((Read-Host '确定卸载吗?输入 y 回车 = 卸载;直接回车 = 取消') -ne 'y') { exit } foreach (`$p in 7860, 7861) { try { Get-NetTCPConnection -LocalPort `$p -State Listen -ErrorAction Stop | ForEach-Object { Stop-Process -Id `$_.OwningProcess -Force -ErrorAction SilentlyContinue } } catch {} } Start-Sleep 1 Remove-Item -Path 'HKCU:\Software\Classes\aizg-engine' -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -Path 'HKCU:\Software\AIZG\Engine' -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath (Join-Path ([Environment]::GetFolderPath('Desktop')) '追光本地引擎.lnk') -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath (Join-Path ([Environment]::GetFolderPath('Desktop')) '追光剪辑台遥控.lnk') -Force -ErrorAction SilentlyContinue Set-Location `$env:TEMP Remove-Item -LiteralPath '$dir' -Recurse -Force -ErrorAction SilentlyContinue Write-Host '已卸载。' -ForegroundColor Green Read-Host '按回车关闭' "@ Set-Content -LiteralPath (Join-Path $dir 'uninstall.ps1') -Value $unps -Encoding UTF8 # 文件夹里放一个双击就能启动的 bat(.ps1 双击默认是用记事本打开,用户会懵) [IO.File]::WriteAllText((Join-Path $dir '启动追光本地引擎.bat'), "@echo off`r`nstart `"AIZG engine`" powershell -NoProfile -ExecutionPolicy Bypass -File `"%~dp0manager.ps1`"`r`n", [Text.Encoding]::ASCII) if (Test-Path -LiteralPath (Join-Path $dir 'wangp-path.txt')) { [IO.File]::WriteAllText((Join-Path $dir '更新视频引擎WanGP.bat'), "@echo off`r`npowershell -NoProfile -ExecutionPolicy Bypass -File `"%~dp0update-wangp.ps1`"`r`n", [Text.Encoding]::ASCII) } [IO.File]::WriteAllText((Join-Path $dir '卸载追光本地引擎.bat'), "@echo off`r`npowershell -NoProfile -ExecutionPolicy Bypass -File `"%~dp0uninstall.ps1`"`r`n", [Text.Encoding]::ASCII) # ---- 7. 提前下载视频模型(约 25GB):以前要等第一次生成才在后台下载,用户看不到进度,以为卡死了 ---- $wfp = Join-Path $dir 'wangp-path.txt' if (($wantW -or $wReady) -and (Test-Path -LiteralPath $wfp)) { $wr = (Get-Content -LiteralPath $wfp -Raw -Encoding UTF8).Trim() $wpy = [IO.Path]::Combine($wr, 'env_venv', 'Scripts', 'python.exe') if ([IO.File]::Exists($wpy)) { Write-Host '' Write-Host '视频模型(约 25GB):现在下载好,以后第一次生成视频就不用等了。下完会自动试生成一条 1 秒的小视频,确认显卡能跑。' -ForegroundColor Cyan $dv = Read-Host ' 现在下载视频模型吗?直接回车 = 现在下;输入 n 回车 = 以后第一次生成视频时再下' if ($dv -ne 'n') { # 先把正在运行的引擎关掉,免得两个程序抢显卡 try { Invoke-WebRequest -UseBasicParsing -Method Post -Uri 'http://127.0.0.1:7860/quit' -Headers @{ Origin = 'http://localhost' } -TimeoutSec 3 | Out-Null; Start-Sleep 2 } catch {} $env:PYTHONUTF8 = '1'; $env:PYTHONIOENCODING = 'utf-8'; $env:GIT_PYTHON_REFRESH = 'quiet' $oldEap = $ErrorActionPreference; $ErrorActionPreference = 'Continue'; $ok = $false $eps = if ($onlyOfficial) { @('') } else { @('https://hf-mirror.com', '') } foreach ($ep in $eps) { if ($ep) { $env:HF_ENDPOINT = $ep; Write-Host ' 先试国内镜像下载……' -ForegroundColor DarkGray } else { Remove-Item Env:HF_ENDPOINT -ErrorAction SilentlyContinue; if (-not $onlyOfficial) { Write-Host ' 换官方地址继续下载……' -ForegroundColor DarkGray } } Push-Location $wr try { & $wpy -u (Join-Path $dir 'aizg-wangp-bridge.py') --root $wr --warmup 2>&1 | ForEach-Object { $l = "$_"; if ($l -match '^\d\d:\d\d:\d\d ') { Write-Host (' ' + $l.Substring(9)) -ForegroundColor $(if ($l -match '失败|出错|没成功') { 'Red' } elseif ($l -match '好了|成功|不用再下') { 'Green' } else { 'Gray' }) } } ; $ok = ($LASTEXITCODE -eq 0) } finally { Pop-Location } if ($ok) { break } } Remove-Item Env:HF_ENDPOINT -ErrorAction SilentlyContinue; $ErrorActionPreference = $oldEap if ($ok) { Write-Host ' 视频模型准备好了:回网页切到「LTX-2.5 视频」就能直接出视频。' -ForegroundColor Green } else { Write-Host ' 视频模型没下完(多半是网络断了)。不要紧:再双击一次安装程序会接着下;或者直接去网页生成视频,也会自动接着下。' -ForegroundColor Yellow } } } } Write-Host '' Write-Host ' ==============================================' -ForegroundColor Green Write-Host ' 安装完成!' -ForegroundColor Green Write-Host ' ==============================================' -ForegroundColor Green Write-Host ' 以后每次用:回到网页点「启动引擎」,或者双击桌面「追光本地引擎」。网页右上角可以切换模型、一键关闭引擎。' Write-Host " 想卸载:运行 $dir 里的「卸载追光本地引擎.bat」(视频引擎 WanGP 单独在 \Wan2GP 文件夹,不会被删)。" if (Test-Path -LiteralPath (Join-Path $dir 'wangp-path.txt')) { Write-Host " 视频引擎要更新:运行 $dir 里的「更新视频引擎WanGP.bat」。" } Write-Host '' $go = Read-Host '现在就启动引擎吗?直接回车 = 启动;输入 n 回车 = 不启动' if ($go -ne 'n') { Start-Process powershell.exe -ArgumentList "-NoProfile -ExecutionPolicy Bypass -File `"$mgrPath`"" }