Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
608f7d8d47 | ||
|
|
68731916ca | ||
|
|
08d507c491 | ||
|
|
d65420f34a |
@@ -84,11 +84,12 @@
|
|||||||
<Studio2AssetSlot v-if="form.mode==='refine'" label="Mask" :asset="stills.find((a:any)=>a.id===form.maskId)" :hidden="slotHidden(form.maskId)" @pick="picker='maskId'" />
|
<Studio2AssetSlot v-if="form.mode==='refine'" label="Mask" :asset="stills.find((a:any)=>a.id===form.maskId)" :hidden="slotHidden(form.maskId)" @pick="picker='maskId'" />
|
||||||
<Studio2AssetSlot v-if="video" label="End still" :asset="stills.find((a:any)=>a.id===form.endStillId)" :hidden="slotHidden(form.endStillId)" @pick="picker='endStillId'" />
|
<Studio2AssetSlot v-if="video" label="End still" :asset="stills.find((a:any)=>a.id===form.endStillId)" :hidden="slotHidden(form.endStillId)" @pick="picker='endStillId'" />
|
||||||
|
|
||||||
<div class="identity-slots flex flex-col gap-2 text-[var(--type-sm)] text-[var(--muted)]" aria-label="Identity locks">
|
<div v-if="form.engine!=='qwen21'" class="identity-slots flex flex-col gap-2 text-[var(--type-sm)] text-[var(--muted)]" aria-label="Identity locks">
|
||||||
<label class="inline-flex items-center gap-2"><input v-model="form.lockFace" type="checkbox" class="accent-[var(--amber)]">Lock face</label>
|
<label class="inline-flex items-center gap-2"><input v-model="form.lockFace" type="checkbox" class="accent-[var(--amber)]">Lock face</label>
|
||||||
<label class="inline-flex items-center gap-2"><input v-model="form.lockOutfit" type="checkbox" class="accent-[var(--amber)]">Lock outfit</label>
|
<label class="inline-flex items-center gap-2"><input v-model="form.lockOutfit" type="checkbox" class="accent-[var(--amber)]">Lock outfit</label>
|
||||||
<p class="text-[11px]">hero encode + lock text — not a MiniMax face/clothes split on stills</p>
|
<p class="text-[11px]">hero encode + lock text — not a MiniMax face/clothes split on stills</p>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-else class="text-[var(--type-sm)] text-[var(--muted)]">Qwen 2.1 is T2I — no hero, locks, or LoRA stack in this build.</p>
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
<span class="field-label">Prompt</span>
|
<span class="field-label">Prompt</span>
|
||||||
@@ -105,7 +106,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div v-if="form.engine!=='qwen21'">
|
||||||
<span class="field-label">LoRA</span>
|
<span class="field-label">LoRA</span>
|
||||||
<LoraSelect v-model="form.settings.loraStack" compact :names="pickerLoras" :use="currentLoraUse" :clip-strength="!video" filename-subtitle :aliases="{...loraNames.aliases,...library.loraAliases}" :triggers="{...loraNames.triggers,...library.loraTriggers}" missing-label="None" @insert-trigger="form.promptSections.action += ' '+$event" />
|
<LoraSelect v-model="form.settings.loraStack" compact :names="pickerLoras" :use="currentLoraUse" :clip-strength="!video" filename-subtitle :aliases="{...loraNames.aliases,...library.loraAliases}" :triggers="{...loraNames.triggers,...library.loraTriggers}" missing-label="None" @insert-trigger="form.promptSections.action += ' '+$event" />
|
||||||
</div>
|
</div>
|
||||||
@@ -142,8 +143,10 @@
|
|||||||
<!-- CENTER viewer -->
|
<!-- CENTER viewer -->
|
||||||
<section class="bench-panel">
|
<section class="bench-panel">
|
||||||
<div v-if="form.mode!=='music'" class="viewer-lock-bar" aria-label="Locks and hide">
|
<div v-if="form.mode!=='music'" class="viewer-lock-bar" aria-label="Locks and hide">
|
||||||
|
<template v-if="form.engine!=='qwen21'">
|
||||||
<label class="inline-flex items-center gap-1.5"><input v-model="form.lockFace" type="checkbox" class="accent-[var(--amber)]">Lock face</label>
|
<label class="inline-flex items-center gap-1.5"><input v-model="form.lockFace" type="checkbox" class="accent-[var(--amber)]">Lock face</label>
|
||||||
<label class="inline-flex items-center gap-1.5"><input v-model="form.lockOutfit" type="checkbox" class="accent-[var(--amber)]">Lock outfit</label>
|
<label class="inline-flex items-center gap-1.5"><input v-model="form.lockOutfit" type="checkbox" class="accent-[var(--amber)]">Lock outfit</label>
|
||||||
|
</template>
|
||||||
<label v-if="xaigen" class="inline-flex items-center gap-1.5"><input v-model="classicHideInput" type="checkbox" class="accent-[var(--amber)]">Hide input</label>
|
<label v-if="xaigen" class="inline-flex items-center gap-1.5"><input v-model="classicHideInput" type="checkbox" class="accent-[var(--amber)]">Hide input</label>
|
||||||
<label v-if="xaigen" class="inline-flex items-center gap-1.5"><input v-model="classicHideOutput" type="checkbox" class="accent-[var(--amber)]">Hide output</label>
|
<label v-if="xaigen" class="inline-flex items-center gap-1.5"><input v-model="classicHideOutput" type="checkbox" class="accent-[var(--amber)]">Hide output</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -350,6 +353,7 @@ const videoDuration=ref(0)
|
|||||||
const focusedJobId=ref('')
|
const focusedJobId=ref('')
|
||||||
const video=computed(()=>['video','extend'].includes(form.mode))
|
const video=computed(()=>['video','extend'].includes(form.mode))
|
||||||
const kreaSticky=computed(()=>['edit','compose','iterate'].includes(form.mode))
|
const kreaSticky=computed(()=>['edit','compose','iterate'].includes(form.mode))
|
||||||
|
const qwenOnlyGenerate=computed(()=>form.mode!=='generate')
|
||||||
const engineOptions=computed(()=>{
|
const engineOptions=computed(()=>{
|
||||||
if(video.value){
|
if(video.value){
|
||||||
return [
|
return [
|
||||||
@@ -359,13 +363,23 @@ const engineOptions=computed(()=>{
|
|||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
{value:'flux',label:'Klein',disabled:false,hint:''},
|
{value:'flux',label:'Klein',disabled:false,hint:''},
|
||||||
{value:'krea',label:'Krea 2',disabled:kreaSticky.value,hint:kreaSticky.value?'sticky hero = Klein':''}
|
{value:'krea',label:'Krea 2',disabled:kreaSticky.value,hint:kreaSticky.value?'sticky hero = Klein':''},
|
||||||
|
{value:'qwen21',label:'Qwen 2.1',disabled:qwenOnlyGenerate.value,hint:qwenOnlyGenerate.value?'Qwen 2.1 is T2I in this build':''}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
watch(engineOptions,opts=>{
|
watch(engineOptions,opts=>{
|
||||||
const current=opts.find(o=>o.value===form.engine)
|
const current=opts.find(o=>o.value===form.engine)
|
||||||
if(!current || current.disabled)form.engine=opts.find(o=>!o.disabled)?.value || form.engine
|
if(!current || current.disabled)form.engine=opts.find(o=>!o.disabled)?.value || form.engine
|
||||||
})
|
})
|
||||||
|
watch(()=>form.engine,(engine,prev)=>{
|
||||||
|
if(engine==='qwen21' && prev!=='qwen21'){
|
||||||
|
form.settings.steps=25
|
||||||
|
form.settings.cfg=1
|
||||||
|
form.lockFace=false
|
||||||
|
form.lockOutfit=false
|
||||||
|
form.settings.loraStack=[]
|
||||||
|
}
|
||||||
|
})
|
||||||
const stills=computed(()=>library.value.stills.filter((s:any)=>s.folderId===form.folderId))
|
const stills=computed(()=>library.value.stills.filter((s:any)=>s.folderId===form.folderId))
|
||||||
const clips=computed(()=>library.value.clips.filter((s:any)=>s.folderId===form.folderId))
|
const clips=computed(()=>library.value.clips.filter((s:any)=>s.folderId===form.folderId))
|
||||||
const stillUrl=(id:string)=>`/api/library/stills/${id}`
|
const stillUrl=(id:string)=>`/api/library/stills/${id}`
|
||||||
@@ -387,13 +401,13 @@ const inputStill=computed(()=>stills.value.find((s:any)=>s.id===form.imageAId))
|
|||||||
const family=computed(()=>folderAssets(library.value,jobs.value,form.folderId))
|
const family=computed(()=>folderAssets(library.value,jobs.value,form.folderId))
|
||||||
const sourceAsset=computed(()=>form.startClipId?clips.value.find((c:any)=>c.id===form.startClipId):stills.value.find((s:any)=>s.id===(form.imageAId||form.identityStillId)))
|
const sourceAsset=computed(()=>form.startClipId?clips.value.find((c:any)=>c.id===form.startClipId):stills.value.find((s:any)=>s.id===(form.imageAId||form.identityStillId)))
|
||||||
const sourceSize=computed(()=>measured[sourceAsset.value?.id] || sourceAsset.value)
|
const sourceSize=computed(()=>measured[sourceAsset.value?.id] || sourceAsset.value)
|
||||||
const resolvedSize=computed(()=>resolveSize(form.settings.aspect,sourceSize.value))
|
const resolvedSize=computed(()=>resolveSize(form.settings.aspect,sourceSize.value,undefined,form.engine))
|
||||||
const negativeChips=computed(()=>form.imageStyles.negative || [])
|
const negativeChips=computed(()=>form.imageStyles.negative || [])
|
||||||
const queueCards=computed(()=>{
|
const queueCards=computed(()=>{
|
||||||
const cards:any[]=[]
|
const cards:any[]=[]
|
||||||
for(const job of benchJobs.value){
|
for(const job of benchJobs.value){
|
||||||
const action=job.request?.promptSections?.action || 'Job'
|
const action=job.request?.promptSections?.action || 'Job'
|
||||||
const eng=job.request?.engine==='ltx'?'LTX':job.request?.engine==='krea'?'Krea 2':job.request?.engine==='minimax'?'MiniMax H3':'Klein'
|
const eng=job.request?.engine==='ltx'?'LTX':job.request?.engine==='krea'?'Krea 2':job.request?.engine==='qwen21'?'Qwen 2.1':job.request?.engine==='minimax'?'MiniMax H3':'Klein'
|
||||||
const mode=job.request?.mode || 'generate'
|
const mode=job.request?.mode || 'generate'
|
||||||
const modeLabel=mode.charAt(0).toUpperCase()+mode.slice(1)
|
const modeLabel=mode.charAt(0).toUpperCase()+mode.slice(1)
|
||||||
const aspect=job.request?.settings?.aspect || 'auto'
|
const aspect=job.request?.settings?.aspect || 'auto'
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Qwen Image 2.1 (engine `qwen21`) — host weights + Comfy graph
|
||||||
|
|
||||||
|
## Host paths (this RTX 5080 box)
|
||||||
|
|
||||||
|
| Role | Resolved path |
|
||||||
|
|---|---|
|
||||||
|
| ComfyUI root (Klein / host agent `ComfyUI (1)`) | `C:\Users\ianjm\AppData\Local\Comfy-Desktop\ComfyUI-Installs\ComfyUI (1)\ComfyUI` |
|
||||||
|
| Models root (Desktop Shared via `shared_model_paths.yaml`) | `C:\Users\ianjm\AppData\Local\Comfy-Desktop\ComfyUI-Shared\models` |
|
||||||
|
|
||||||
|
Weights must land under Shared so every Desktop instance sees them:
|
||||||
|
|
||||||
|
- `diffusion_models\qwen-image-2.1-Q8_0.gguf` (~7.59 GiB) — from `abenzerps/Qwen-Image-2.1-Uncensored-GGUF`
|
||||||
|
- `text_encoders\qwen3vl_8b_int8_convrot.safetensors` — from `Comfy-Org/Qwen-Image-2.1` (INT8; BF16 8B VL OOMs on 16 GB)
|
||||||
|
- `vae\qwen_image_2.1_vae_bf16.safetensors` — from `Comfy-Org/Qwen-Image-2.1` (**not** the old Qwen-Image 1.0 VAE)
|
||||||
|
|
||||||
|
Custom node: `custom_nodes\ComfyUI-GGUF` (`UnetLoaderGGUF`). Do not install a second GGUF pack.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -ExecutionPolicy Bypass -File scripts\setup-qwen21.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
DiT download uses the exact host invocation:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
hf download hf://abenzerps/Qwen-Image-2.1-Uncensored-GGUF/qwen-image-2.1-Q8_0.gguf
|
||||||
|
```
|
||||||
|
|
||||||
|
That lands in the Hugging Face hub cache; the setup script copies it into Shared `diffusion_models`. The abenzerps GGUF ships with `kv_count=0` (no `general.architecture`) **and** Q8_0-quantized 1D RMSNorm weights (logical 128 → packed 136), which breaks Comfy’s fused `rms_rope`. `scripts/tag-qwen21-gguf.py` rewrites the file with `general.architecture=qwen_image` and promotes small/1D tensors to F32 so city96 `UnetLoaderGGUF` can load it.
|
||||||
|
|
||||||
|
Restart Comfy **only when idle** (`COMFY_CONTROL_URL/status` → `gpu.busy=false`). Confirm `object_info` lists `UnetLoaderGGUF` and the three filenames.
|
||||||
|
|
||||||
|
## App
|
||||||
|
|
||||||
|
- Engine key: `qwen21` · UI label: **Qwen 2.1**
|
||||||
|
- Generate (T2I) only in this build. Edit / Compose / Iterate / Video disable with: “Qwen 2.1 is T2I in this build”.
|
||||||
|
- Sampler defaults: euler / simple / cfg **1** / steps **25** · `ModelSamplingAuraFlow` shift **3.1**
|
||||||
|
- Default canvas **1024×1024** (aspect 16:9 / 9:16 / 1:1 → long-edge square via `TextEncodeQwenImage21` resolution; multiples of 32). Drop to 768 if VRAM errors.
|
||||||
|
- No hero / locks / Klein LoRA stack on this engine.
|
||||||
|
|
||||||
|
Graph: `server/assets/studio2_qwen21_t2i.json`.
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
# Download Qwen Image 2.1 GGUF + companions into the live Comfy Desktop Shared models tree.
|
||||||
|
# Also ensures city96/ComfyUI-GGUF is present on the ComfyUI (1) install.
|
||||||
|
#
|
||||||
|
# Example:
|
||||||
|
# powershell -ExecutionPolicy Bypass -File scripts\setup-qwen21.ps1
|
||||||
|
# powershell -File scripts\setup-qwen21.ps1 -SkipDownload
|
||||||
|
# powershell -File scripts\setup-qwen21.ps1 -ComfyRoot "C:\...\ComfyUI (1)\ComfyUI"
|
||||||
|
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$ComfyRoot = '',
|
||||||
|
[string]$ModelsRoot = '',
|
||||||
|
[switch]$SkipDownload,
|
||||||
|
[switch]$SkipNode,
|
||||||
|
[switch]$Force
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$ProgressPreference = 'Continue'
|
||||||
|
|
||||||
|
function Write-Step([string]$Message) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "==> $Message" -ForegroundColor Cyan
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-Ok([string]$Message) {
|
||||||
|
Write-Host " $Message" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-Warn([string]$Message) {
|
||||||
|
Write-Host " warn $Message" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resolve-ComfyRoot([string]$Hint) {
|
||||||
|
$candidates = @(
|
||||||
|
$Hint,
|
||||||
|
$env:COMFY_ROOT,
|
||||||
|
$env:COMFYUI_ROOT,
|
||||||
|
(Join-Path $env:LOCALAPPDATA 'Comfy-Desktop\ComfyUI-Installs\ComfyUI (1)\ComfyUI'),
|
||||||
|
(Join-Path $env:LOCALAPPDATA 'Comfy-Desktop\ComfyUI-Installs\ComfyUI\ComfyUI'),
|
||||||
|
'C:\ComfyUI',
|
||||||
|
(Join-Path $env:USERPROFILE 'ComfyUI')
|
||||||
|
) | Where-Object { $_ } | ForEach-Object { $_.TrimEnd('\', '/') }
|
||||||
|
|
||||||
|
foreach ($path in $candidates) {
|
||||||
|
if (Test-Path (Join-Path $path 'main.py')) { return (Resolve-Path $path).Path }
|
||||||
|
if (Test-Path (Join-Path $path 'ComfyUI\main.py')) { return (Resolve-Path (Join-Path $path 'ComfyUI')).Path }
|
||||||
|
}
|
||||||
|
throw "Could not find ComfyUI root (main.py). Pass -ComfyRoot."
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resolve-ModelsRoot([string]$Hint, [string]$ComfyRootPath) {
|
||||||
|
$shared = Join-Path $env:LOCALAPPDATA 'Comfy-Desktop\ComfyUI-Shared\models'
|
||||||
|
$candidates = @(
|
||||||
|
$Hint,
|
||||||
|
$env:COMFY_MODELS_ROOT,
|
||||||
|
$shared,
|
||||||
|
(Join-Path $ComfyRootPath 'models')
|
||||||
|
) | Where-Object { $_ } | ForEach-Object { $_.TrimEnd('\', '/') }
|
||||||
|
|
||||||
|
foreach ($path in $candidates) {
|
||||||
|
if (Test-Path $path) { return (Resolve-Path $path).Path }
|
||||||
|
}
|
||||||
|
New-Item -ItemType Directory -Force -Path $shared | Out-Null
|
||||||
|
return (Resolve-Path $shared).Path
|
||||||
|
}
|
||||||
|
|
||||||
|
function Find-HfCli {
|
||||||
|
foreach ($name in @('hf', 'huggingface-cli')) {
|
||||||
|
$cmd = Get-Command $name -ErrorAction SilentlyContinue
|
||||||
|
if ($cmd) { return $cmd.Source }
|
||||||
|
}
|
||||||
|
throw "Hugging Face CLI not found (hf / huggingface-cli). Install with: pip install -U huggingface_hub"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Ensure-Dir([string]$Path) {
|
||||||
|
if (-not (Test-Path $Path)) {
|
||||||
|
New-Item -ItemType Directory -Force -Path $Path | Out-Null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-ModelFile([string]$Path, [long]$MinBytes) {
|
||||||
|
if (-not (Test-Path $Path)) { throw "Missing model file: $Path" }
|
||||||
|
$item = Get-Item $Path
|
||||||
|
if ($item.Length -lt $MinBytes) {
|
||||||
|
throw "Suspicious size for $Path ($($item.Length) bytes). Expected >= $MinBytes. Delete and re-run."
|
||||||
|
}
|
||||||
|
Write-Ok ("{0} {1:N0} bytes" -f $item.FullName, $item.Length)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Flatten-IfNested([string]$DestDir, [string]$FileName) {
|
||||||
|
$nested = Get-ChildItem $DestDir -Recurse -File -Filter $FileName -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object { $_.DirectoryName -ne $DestDir } |
|
||||||
|
Select-Object -First 1
|
||||||
|
if ($nested) {
|
||||||
|
$target = Join-Path $DestDir $FileName
|
||||||
|
if ((Test-Path $target) -and -not $Force) {
|
||||||
|
Write-Warn "Nested copy found but destination already exists: $target"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Move-Item -Force $nested.FullName $target
|
||||||
|
Write-Ok "Flattened $($nested.FullName) -> $target"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-HfFile {
|
||||||
|
param(
|
||||||
|
[string]$Cli,
|
||||||
|
[string]$Repo,
|
||||||
|
[string]$File,
|
||||||
|
[string]$LocalDir
|
||||||
|
)
|
||||||
|
Ensure-Dir $LocalDir
|
||||||
|
$dest = Join-Path $LocalDir (Split-Path $File -Leaf)
|
||||||
|
if ((Test-Path $dest) -and -not $Force) {
|
||||||
|
$len = (Get-Item $dest).Length
|
||||||
|
if ($len -gt 1MB) {
|
||||||
|
Write-Ok "Already present: $dest ($len bytes)"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Write-Step "hf download $Repo $File"
|
||||||
|
& $Cli download $Repo $File --local-dir $LocalDir
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "hf download failed for $Repo / $File (exit $LASTEXITCODE)" }
|
||||||
|
Flatten-IfNested -DestDir $LocalDir -FileName (Split-Path $File -Leaf)
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- resolve paths ---
|
||||||
|
Write-Step "Resolve ComfyUI root"
|
||||||
|
$root = Resolve-ComfyRoot $ComfyRoot
|
||||||
|
Write-Ok $root
|
||||||
|
|
||||||
|
Write-Step "Resolve models root (Desktop Shared preferred)"
|
||||||
|
$models = Resolve-ModelsRoot $ModelsRoot $root
|
||||||
|
Write-Ok $models
|
||||||
|
|
||||||
|
$Diff = Join-Path $models 'diffusion_models'
|
||||||
|
$TE = Join-Path $models 'text_encoders'
|
||||||
|
$VAE = Join-Path $models 'vae'
|
||||||
|
Ensure-Dir $Diff
|
||||||
|
Ensure-Dir $TE
|
||||||
|
Ensure-Dir $VAE
|
||||||
|
|
||||||
|
if (-not $SkipDownload) {
|
||||||
|
$cli = Find-HfCli
|
||||||
|
Write-Ok "HF CLI: $cli"
|
||||||
|
|
||||||
|
# DiT GGUF only (no Q6/Q5/Q4, no full bf16 DiT)
|
||||||
|
# Exact invocation required on this host:
|
||||||
|
# hf download hf://abenzerps/Qwen-Image-2.1-Uncensored-GGUF/qwen-image-2.1-Q8_0.gguf
|
||||||
|
# That form writes into the Hugging Face hub cache (not --local-dir).
|
||||||
|
Write-Step "hf download hf://abenzerps/Qwen-Image-2.1-Uncensored-GGUF/qwen-image-2.1-Q8_0.gguf"
|
||||||
|
$ditName = 'qwen-image-2.1-Q8_0.gguf'
|
||||||
|
$ditDest = Join-Path $Diff $ditName
|
||||||
|
if ((Test-Path $ditDest) -and -not $Force -and (Get-Item $ditDest).Length -gt 1MB) {
|
||||||
|
Write-Ok "Already present: $ditDest ($((Get-Item $ditDest).Length) bytes)"
|
||||||
|
} else {
|
||||||
|
& $cli download "hf://abenzerps/Qwen-Image-2.1-Uncensored-GGUF/$ditName"
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "hf download failed for DiT GGUF (exit $LASTEXITCODE)" }
|
||||||
|
$cacheRoot = Join-Path $env:USERPROFILE '.cache\huggingface\hub\models--abenzerps--Qwen-Image-2.1-Uncensored-GGUF'
|
||||||
|
$found = Get-ChildItem $cacheRoot -Recurse -File -Filter $ditName -ErrorAction SilentlyContinue |
|
||||||
|
Sort-Object LastWriteTime -Descending |
|
||||||
|
Select-Object -First 1
|
||||||
|
if (-not $found) { throw "hf download finished but $ditName was not found under $cacheRoot" }
|
||||||
|
Ensure-Dir $Diff
|
||||||
|
Copy-Item -Force $found.FullName $ditDest
|
||||||
|
Write-Ok "Copied $($found.FullName) -> $ditDest ($((Get-Item $ditDest).Length) bytes)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# abenzerps ships kv_count=0 + Q8_0 1D RMSNorms (packed 136 vs logical 128).
|
||||||
|
# Tag architecture=qwen_image and promote small/1D tensors to F32 for ComfyUI-GGUF.
|
||||||
|
Write-Step "Tag DiT GGUF (architecture + F32 norms)"
|
||||||
|
$tagger = Join-Path $PSScriptRoot 'tag-qwen21-gguf.py'
|
||||||
|
$pyCandidates = @(
|
||||||
|
(Join-Path $root '.venv\Scripts\python.exe'),
|
||||||
|
(Join-Path (Split-Path $root) 'standalone-env\python.exe'),
|
||||||
|
(Get-Command python -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source)
|
||||||
|
) | Where-Object { $_ -and (Test-Path $_) }
|
||||||
|
$py = $pyCandidates | Select-Object -First 1
|
||||||
|
if (-not $py) { throw "No Python found to run $tagger" }
|
||||||
|
$fixed = Join-Path $Diff 'qwen-image-2.1-Q8_0.fixed.gguf'
|
||||||
|
& $py $tagger $ditDest $fixed
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "tag-qwen21-gguf.py failed (exit $LASTEXITCODE)" }
|
||||||
|
Copy-Item -Force $fixed $ditDest
|
||||||
|
Write-Ok "Installed tagged GGUF at $ditDest"
|
||||||
|
|
||||||
|
# TE + VAE companions from Comfy-Org (INT8 TE for 16 GB VRAM)
|
||||||
|
Invoke-HfFile -Cli $cli -Repo 'Comfy-Org/Qwen-Image-2.1' -File 'text_encoders/qwen3vl_8b_int8_convrot.safetensors' -LocalDir $TE
|
||||||
|
Invoke-HfFile -Cli $cli -Repo 'Comfy-Org/Qwen-Image-2.1' -File 'vae/qwen_image_2.1_vae_bf16.safetensors' -LocalDir $VAE
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Step "Verify weights"
|
||||||
|
Assert-ModelFile (Join-Path $Diff 'qwen-image-2.1-Q8_0.gguf') 7000000000
|
||||||
|
Assert-ModelFile (Join-Path $TE 'qwen3vl_8b_int8_convrot.safetensors') 1000000000
|
||||||
|
Assert-ModelFile (Join-Path $VAE 'qwen_image_2.1_vae_bf16.safetensors') 100000000
|
||||||
|
|
||||||
|
if (-not $SkipNode) {
|
||||||
|
Write-Step "city96 ComfyUI-GGUF"
|
||||||
|
$nodes = Join-Path $root 'custom_nodes\ComfyUI-GGUF'
|
||||||
|
if (-not (Test-Path $nodes)) {
|
||||||
|
git clone https://github.com/city96/ComfyUI-GGUF $nodes
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "git clone ComfyUI-GGUF failed" }
|
||||||
|
Write-Ok "Cloned $nodes"
|
||||||
|
} else {
|
||||||
|
Write-Ok "Already present: $nodes"
|
||||||
|
}
|
||||||
|
|
||||||
|
$py = Join-Path $root '.venv\Scripts\python.exe'
|
||||||
|
if (-not (Test-Path $py)) {
|
||||||
|
$py = Join-Path (Split-Path $root) 'standalone-env\python.exe'
|
||||||
|
}
|
||||||
|
if (Test-Path $py) {
|
||||||
|
Write-Step "Ensure gguf package in Comfy python ($py)"
|
||||||
|
& $py -m pip install -q gguf
|
||||||
|
if ($LASTEXITCODE -ne 0) { Write-Warn "pip install gguf returned $LASTEXITCODE" }
|
||||||
|
else { Write-Ok "gguf importable" }
|
||||||
|
} else {
|
||||||
|
Write-Warn "Comfy python not found; install gguf into the interpreter that runs main.py"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Done. Restart Comfy only when /status shows gpu.busy=false." -ForegroundColor Cyan
|
||||||
|
Write-Host "ComfyRoot=$root"
|
||||||
|
Write-Host "ModelsRoot=$models"
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Make abenzerps Qwen Image 2.1 DiT GGUF loadable in city96 ComfyUI-GGUF.
|
||||||
|
|
||||||
|
abenzerps/Qwen-Image-2.1-Uncensored-GGUF ships Q8_0 with:
|
||||||
|
- kv_count=0 (no general.architecture)
|
||||||
|
- 1D RMSNorm weights quantized to Q8_0 (logical 128 -> packed 136),
|
||||||
|
which breaks Comfy's fused rms_rope path
|
||||||
|
|
||||||
|
This rewrite:
|
||||||
|
1. Adds general.architecture=qwen_image
|
||||||
|
2. Dequantizes small / 1D tensors to F32 (city96 convert keeps them hiprec)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Match city96 ComfyUI-GGUF/tools/convert.py QUANTIZATION_THRESHOLD
|
||||||
|
QUANTIZATION_THRESHOLD = 1024
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("src", type=Path)
|
||||||
|
ap.add_argument("dst", type=Path, nargs="?", default=None)
|
||||||
|
ap.add_argument("--arch", default="qwen_image")
|
||||||
|
ap.add_argument("--name", default="qwen-image-2.1")
|
||||||
|
ap.add_argument("--inplace", action="store_true", help="Replace src after a successful tag")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
import gguf
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
src = args.src.resolve()
|
||||||
|
if not src.is_file():
|
||||||
|
print(f"missing source: {src}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
dst = (args.dst.resolve() if args.dst else src.with_name(src.stem + ".tagged.gguf"))
|
||||||
|
|
||||||
|
reader = gguf.GGUFReader(str(src))
|
||||||
|
|
||||||
|
def get_field(name: str):
|
||||||
|
field = reader.fields.get(name)
|
||||||
|
if field is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return field.contents()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if dst == src:
|
||||||
|
dst = src.with_name(src.stem + ".retag.gguf")
|
||||||
|
|
||||||
|
f32 = gguf.GGMLQuantizationType.F32
|
||||||
|
compat = {gguf.GGMLQuantizationType.F32, gguf.GGMLQuantizationType.F16, gguf.GGMLQuantizationType.BF16}
|
||||||
|
|
||||||
|
print(f"rewriting {src} -> {dst} (arch={args.arch}, tensors={len(reader.tensors)})")
|
||||||
|
writer = gguf.GGUFWriter(str(dst), arch=args.arch, use_temp_file=True)
|
||||||
|
writer.add_name(args.name)
|
||||||
|
try:
|
||||||
|
writer.add_type("model")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
promoted = 0
|
||||||
|
for tensor in reader.tensors:
|
||||||
|
logical = tuple(int(x) for x in tensor.shape)
|
||||||
|
# GGUF stores dims reversed vs torch; logical numel is what matters
|
||||||
|
numel = 1
|
||||||
|
for d in logical:
|
||||||
|
numel *= d
|
||||||
|
qtype = tensor.tensor_type
|
||||||
|
data = tensor.data
|
||||||
|
if hasattr(data, "copy"):
|
||||||
|
data = data.copy()
|
||||||
|
|
||||||
|
needs_f32 = qtype not in compat and (len(logical) <= 1 or numel <= QUANTIZATION_THRESHOLD)
|
||||||
|
if needs_f32:
|
||||||
|
# Dequantize packed blocks -> float32 with logical shape (GGUF order)
|
||||||
|
dequant = gguf.quants.dequantize(np.asarray(data), qtype).astype(np.float32, copy=False)
|
||||||
|
expected = numel
|
||||||
|
if dequant.size != expected:
|
||||||
|
print(
|
||||||
|
f"dequant size mismatch {tensor.name}: got {dequant.size} want {expected} "
|
||||||
|
f"shape={logical} qtype={qtype}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 3
|
||||||
|
data = dequant.reshape(logical)
|
||||||
|
qtype = f32
|
||||||
|
promoted += 1
|
||||||
|
|
||||||
|
writer.add_tensor(tensor.name, data, raw_dtype=qtype)
|
||||||
|
|
||||||
|
writer.write_header_to_file()
|
||||||
|
writer.write_kv_data_to_file()
|
||||||
|
writer.write_tensors_to_file(progress=True)
|
||||||
|
writer.close()
|
||||||
|
|
||||||
|
check = gguf.GGUFReader(str(dst))
|
||||||
|
field = check.fields.get("general.architecture")
|
||||||
|
got = field.contents() if field is not None else None
|
||||||
|
print(
|
||||||
|
f"verified architecture={got!r} tensors={len(check.tensors)} "
|
||||||
|
f"promoted_f32={promoted} size={dst.stat().st_size}"
|
||||||
|
)
|
||||||
|
if got != args.arch or len(check.tensors) != len(reader.tensors):
|
||||||
|
print("tag failed", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
# Spot-check a known norm weight is F32 @ 128
|
||||||
|
sample = next((t for t in check.tensors if t.name.endswith("attn.norm_q.weight")), None)
|
||||||
|
if sample is not None:
|
||||||
|
print(
|
||||||
|
f"sample {sample.name}: shape={tuple(int(x) for x in sample.shape)} "
|
||||||
|
f"type={sample.tensor_type.name} data_shape={tuple(sample.data.shape)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if args.inplace:
|
||||||
|
bak = src.with_suffix(src.suffix + ".untagged.bak")
|
||||||
|
if bak.exists():
|
||||||
|
bak.unlink()
|
||||||
|
src.replace(bak)
|
||||||
|
dst.replace(src)
|
||||||
|
print(f"inplace: {src} (backup {bak})")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
{
|
||||||
|
"4": {
|
||||||
|
"inputs": {
|
||||||
|
"unet_name": "qwen-image-2.1-Q8_0.gguf"
|
||||||
|
},
|
||||||
|
"class_type": "UnetLoaderGGUF",
|
||||||
|
"_meta": { "title": "Load Qwen 2.1 GGUF" }
|
||||||
|
},
|
||||||
|
"3": {
|
||||||
|
"inputs": {
|
||||||
|
"shift": 3.1,
|
||||||
|
"model": ["4", 0]
|
||||||
|
},
|
||||||
|
"class_type": "ModelSamplingAuraFlow",
|
||||||
|
"_meta": { "title": "Qwen 2.1 shift" }
|
||||||
|
},
|
||||||
|
"5": {
|
||||||
|
"inputs": {
|
||||||
|
"clip_name": "qwen3vl_8b_int8_convrot.safetensors",
|
||||||
|
"type": "qwen_image",
|
||||||
|
"device": "default"
|
||||||
|
},
|
||||||
|
"class_type": "CLIPLoader",
|
||||||
|
"_meta": { "title": "Load Qwen 2.1 CLIP" }
|
||||||
|
},
|
||||||
|
"6": {
|
||||||
|
"inputs": {
|
||||||
|
"vae_name": "qwen_image_2.1_vae_bf16.safetensors"
|
||||||
|
},
|
||||||
|
"class_type": "VAELoader",
|
||||||
|
"_meta": { "title": "Load Qwen 2.1 VAE" }
|
||||||
|
},
|
||||||
|
"9": {
|
||||||
|
"inputs": {
|
||||||
|
"clip": ["5", 0],
|
||||||
|
"prompt": "",
|
||||||
|
"negative_prompt": "",
|
||||||
|
"resolution": 1024
|
||||||
|
},
|
||||||
|
"class_type": "TextEncodeQwenImage21",
|
||||||
|
"_meta": { "title": "Text Encode Qwen Image 2.1" }
|
||||||
|
},
|
||||||
|
"15": {
|
||||||
|
"inputs": {
|
||||||
|
"seed": 1,
|
||||||
|
"steps": 25,
|
||||||
|
"cfg": 1,
|
||||||
|
"sampler_name": "euler",
|
||||||
|
"scheduler": "simple",
|
||||||
|
"denoise": 1,
|
||||||
|
"model": ["3", 0],
|
||||||
|
"positive": ["9", 0],
|
||||||
|
"negative": ["9", 1],
|
||||||
|
"latent_image": ["9", 2]
|
||||||
|
},
|
||||||
|
"class_type": "KSampler",
|
||||||
|
"_meta": { "title": "KSampler" }
|
||||||
|
},
|
||||||
|
"20": {
|
||||||
|
"inputs": {
|
||||||
|
"samples": ["15", 0],
|
||||||
|
"vae": ["6", 0]
|
||||||
|
},
|
||||||
|
"class_type": "VAEDecode",
|
||||||
|
"_meta": { "title": "VAE Decode" }
|
||||||
|
},
|
||||||
|
"21": {
|
||||||
|
"inputs": {
|
||||||
|
"filename_prefix": "studio2-qwen21",
|
||||||
|
"images": ["20", 0]
|
||||||
|
},
|
||||||
|
"class_type": "SaveImage",
|
||||||
|
"_meta": { "title": "Save Image" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,8 +8,10 @@ import { queueSeeds } from '~/shared/studio2/seed.mjs';
|
|||||||
import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from 'node:fs';
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import template from '../../assets/studio2_minimax_native.json';
|
import template from '../../assets/studio2_minimax_native.json';
|
||||||
|
import qwen21Template from '../../assets/studio2_qwen21_t2i.json';
|
||||||
import { nativeVideoGraph, attachHeroReference, applyResolvedImageSize } from '~/shared/studio2/graphs.mjs';
|
import { nativeVideoGraph, attachHeroReference, applyResolvedImageSize } from '~/shared/studio2/graphs.mjs';
|
||||||
import { compilePrompt, scopedFile } from '~/shared/studio2/contracts.mjs';
|
import { compilePrompt, scopedFile } from '~/shared/studio2/contracts.mjs';
|
||||||
|
import { resolveQwen21Size } from '~/shared/studio2/input.mjs';
|
||||||
import { createJob, restoreJob, getJob, emitJob, type Job } from '../jobs';
|
import { createJob, restoreJob, getJob, emitJob, type Job } from '../jobs';
|
||||||
import { markStudioLive, onLiveVideoSettled, type StudioJob } from '../studioQueue';
|
import { markStudioLive, onLiveVideoSettled, type StudioJob } from '../studioQueue';
|
||||||
import { readRecord, saveRecord, studio2Root, records } from './store';
|
import { readRecord, saveRecord, studio2Root, records } from './store';
|
||||||
@@ -126,6 +128,28 @@ async function prepareGraph(r: any) {
|
|||||||
r.graphId = 'studio2_minimax_native.json';
|
r.graphId = 'studio2_minimax_native.json';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else if (q.engine === 'qwen21') {
|
||||||
|
if (q.mode !== 'generate')
|
||||||
|
throw new Error('Qwen 2.1 is T2I in this build.');
|
||||||
|
const size = resolveQwen21Size(s.aspect);
|
||||||
|
s.width = size.width;
|
||||||
|
s.height = size.height;
|
||||||
|
graph = structuredClone(qwen21Template);
|
||||||
|
graph['9'].inputs.prompt = q.compiledPrompt;
|
||||||
|
graph['9'].inputs.negative_prompt = stylePrompt(q.imageStyles, true);
|
||||||
|
// TextEncodeQwenImage21 builds the 64-ch empty latent from resolution (square T2I).
|
||||||
|
graph['9'].inputs.resolution = Math.max(size.width, size.height);
|
||||||
|
graph['15'].inputs.seed = s.seed;
|
||||||
|
graph['15'].inputs.steps = s.steps || 25;
|
||||||
|
graph['15'].inputs.cfg = s.cfg ?? 1;
|
||||||
|
graph['15'].inputs.sampler_name = 'euler';
|
||||||
|
graph['15'].inputs.scheduler = 'simple';
|
||||||
|
graph['21'].inputs.filename_prefix = prefix + '/image';
|
||||||
|
r.sampleLatent = 'qwen21 textencode latent';
|
||||||
|
r.sampleDenoise = null;
|
||||||
|
r.heroReferenceAttached = false;
|
||||||
|
r.graphId = 'studio2_qwen21_t2i.json';
|
||||||
|
}
|
||||||
else {
|
else {
|
||||||
const mode = identityPlan?.mode ?? (q.mode === 'iterate' ? (a ? 'edit' : 'generate') : q.mode);
|
const mode = identityPlan?.mode ?? (q.mode === 'iterate' ? (a ? 'edit' : 'generate') : q.mode);
|
||||||
const found = q.engine === 'krea' ? await resolveKreaGenerateAssets() : null;
|
const found = q.engine === 'krea' ? await resolveKreaGenerateAssets() : null;
|
||||||
|
|||||||
@@ -21,5 +21,5 @@ export async function resolveRequestSize(owner: string, request: any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (video && request.startClipId && !dimensions?.width) throw new Error('Cannot measure the previous clip.')
|
if (video && request.startClipId && !dimensions?.width) throw new Error('Cannot measure the previous clip.')
|
||||||
Object.assign(request.settings,resolveSize(request.settings.aspect,dimensions))
|
Object.assign(request.settings,resolveSize(request.settings.aspect,dimensions,undefined,request.engine))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export function appearanceLock({lockFace = true, lockOutfit = true} = {}) {
|
|||||||
export function compilePrompt(value, video = false, locks = {}) {
|
export function compilePrompt(value, video = false, locks = {}) {
|
||||||
if (video) return [compileSections(value),appearanceLock(locks)].filter(Boolean).join('\n\n')
|
if (video) return [compileSections(value),appearanceLock(locks)].filter(Boolean).join('\n\n')
|
||||||
const hasImage=!!(locks.imageAId || locks.identityStillId)
|
const hasImage=!!(locks.imageAId || locks.identityStillId)
|
||||||
const attached=hasImage && locks.engine !== 'krea'
|
const attached=hasImage && locks.engine !== 'krea' && locks.engine !== 'qwen21'
|
||||||
const reference=locks.mode!=='generate' && locks.identityStillId && locks.imageAId && locks.identityStillId!==locks.imageAId ? 'second (hero) reference' : 'image reference'
|
const reference=locks.mode!=='generate' && locks.identityStillId && locks.imageAId && locks.identityStillId!==locks.imageAId ? 'second (hero) reference' : 'image reference'
|
||||||
return [sectionsFrom(value).action, stylePrompt(locks.imageStyles),
|
return [sectionsFrom(value).action, stylePrompt(locks.imageStyles),
|
||||||
attached && locks.lockFace !== false ? `Preserve the person’s face, glasses, hair, and body identity from the ${reference}.` : '',
|
attached && locks.lockFace !== false ? `Preserve the person’s face, glasses, hair, and body identity from the ${reference}.` : '',
|
||||||
@@ -31,9 +31,10 @@ export function validateRequest(raw, xaigen = false) {
|
|||||||
const mode = raw.mode || 'generate', engine = raw.engine || 'flux'
|
const mode = raw.mode || 'generate', engine = raw.engine || 'flux'
|
||||||
if (!['generate','edit','compose','refine','iterate','video','extend'].includes(mode)) fail('Unknown task.')
|
if (!['generate','edit','compose','refine','iterate','video','extend'].includes(mode)) fail('Unknown task.')
|
||||||
const video = ['video','extend'].includes(mode)
|
const video = ['video','extend'].includes(mode)
|
||||||
if (!(video ? ['minimax','ltx'] : ['flux','krea']).includes(engine)) fail('Engine does not support this task.')
|
if (!(video ? ['minimax','ltx'] : ['flux','krea','qwen21']).includes(engine)) fail('Engine does not support this task.')
|
||||||
if (engine === 'ltx' && !xaigen) fail('LTX is available only in xAIGen.')
|
if (engine === 'ltx' && !xaigen) fail('LTX is available only in xAIGen.')
|
||||||
if (engine === 'krea' && ['edit','compose','iterate'].includes(mode)) fail('Sticky hero edits require Klein. Krea remains available for Generate and masked Refine; no engine fallback is performed.')
|
if (engine === 'krea' && ['edit','compose','iterate'].includes(mode)) fail('Sticky hero edits require Klein. Krea remains available for Generate and masked Refine; no engine fallback is performed.')
|
||||||
|
if (engine === 'qwen21' && mode !== 'generate') fail('Qwen 2.1 is T2I in this build.')
|
||||||
if (Array.isArray(raw.batch) && raw.batch.length>30) fail('A batch supports up to 31 shots including the first prompt.')
|
if (Array.isArray(raw.batch) && raw.batch.length>30) fail('A batch supports up to 31 shots including the first prompt.')
|
||||||
if (Array.isArray(raw.guides) && raw.guides.length>8) fail('Use up to eight guide stills.')
|
if (Array.isArray(raw.guides) && raw.guides.length>8) fail('Use up to eight guide stills.')
|
||||||
if (!raw.folderId) fail('Choose a folder.')
|
if (!raw.folderId) fail('Choose a folder.')
|
||||||
@@ -46,8 +47,13 @@ export function validateRequest(raw, xaigen = false) {
|
|||||||
const promptSections = video ? allSections : {action:allSections.action}
|
const promptSections = video ? allSections : {action:allSections.action}
|
||||||
if (!promptSections.action.trim()) fail('Enter an action prompt.')
|
if (!promptSections.action.trim()) fail('Enter an action prompt.')
|
||||||
const number=(key, fallback, min, max) => { const n=Number(raw.settings?.[key] ?? fallback); if (!Number.isFinite(n)||n<min||n>max) fail(`Invalid ${key}.`); return n }
|
const number=(key, fallback, min, max) => { const n=Number(raw.settings?.[key] ?? fallback); if (!Number.isFinite(n)||n<min||n>max) fail(`Invalid ${key}.`); return n }
|
||||||
for (const key of ['steps']) if (!Number.isInteger(number(key,key==='steps'?24:1,0,2147483647))) fail(`Invalid ${key}.`)
|
const qwen = engine === 'qwen21'
|
||||||
for (const key of ['width','height']) if (number(key,key==='width'?1344:768,256,4096)%32) fail('Dimensions must be multiples of 32.')
|
const defaultSteps = video ? 8 : qwen ? 25 : 24
|
||||||
|
const defaultCfg = video ? 1.5 : qwen ? 1 : 4
|
||||||
|
const defaultWidth = qwen ? 1024 : 1344
|
||||||
|
const defaultHeight = qwen ? 1024 : 768
|
||||||
|
for (const key of ['steps']) if (!Number.isInteger(number(key,key==='steps'?defaultSteps:1,0,2147483647))) fail(`Invalid ${key}.`)
|
||||||
|
for (const key of ['width','height']) if (number(key,key==='width'?defaultWidth:defaultHeight,256,4096)%32) fail('Dimensions must be multiples of 32.')
|
||||||
const duration=number('duration',5,1,15)
|
const duration=number('duration',5,1,15)
|
||||||
if (video) {
|
if (video) {
|
||||||
if (promptSections.cameraBeats.slice(0,-1).some(b=>b.durationSec == null)) fail('Only the last camera beat can run to the end.')
|
if (promptSections.cameraBeats.slice(0,-1).some(b=>b.durationSec == null)) fail('Only the last camera beat can run to the end.')
|
||||||
@@ -55,10 +61,10 @@ export function validateRequest(raw, xaigen = false) {
|
|||||||
}
|
}
|
||||||
const frames=Math.ceil((duration*number('fps',24,1,60)-5)/17)*17+5
|
const frames=Math.ceil((duration*number('fps',24,1,60)-5)/17)*17+5
|
||||||
const shotSeeds=queueSeeds(raw.settings,1+(Array.isArray(raw.batch)?raw.batch.length:0))
|
const shotSeeds=queueSeeds(raw.settings,1+(Array.isArray(raw.batch)?raw.batch.length:0))
|
||||||
return { mode, engine, shotSeeds, imageStyles:video?{positive:[],negative:[]}:normalizeStyles(raw.imageStyles), hideOutput:!!raw.hideOutput, lockFace:raw.lockFace !== false, lockOutfit:raw.lockOutfit !== false, hideInput:!!raw.hideInput, folderId:String(raw.folderId), identityStillId:raw.identityStillId || '', imageAId:raw.imageAId || '', imageBId:raw.imageBId || '', maskId:raw.maskId || '', startClipId:raw.startClipId || '', endStillId:raw.endStillId || '',
|
return { mode, engine, shotSeeds, imageStyles:video?{positive:[],negative:[]}:normalizeStyles(raw.imageStyles), hideOutput:!!raw.hideOutput, lockFace:qwen ? false : raw.lockFace !== false, lockOutfit:qwen ? false : raw.lockOutfit !== false, hideInput:!!raw.hideInput, folderId:String(raw.folderId), identityStillId:qwen ? '' : (raw.identityStillId || ''), imageAId:raw.imageAId || '', imageBId:raw.imageBId || '', maskId:raw.maskId || '', startClipId:raw.startClipId || '', endStillId:raw.endStillId || '',
|
||||||
startFrameSource: raw.startClipId ? {kind:'previous-last-frame',clipId:raw.startClipId} : {kind:'still',stillId:raw.imageAId || ''},
|
startFrameSource: raw.startClipId ? {kind:'previous-last-frame',clipId:raw.startClipId} : {kind:'still',stillId:raw.imageAId || ''},
|
||||||
promptSections, compiledPrompt:compilePrompt(promptSections,video,raw),
|
promptSections, compiledPrompt:compilePrompt(promptSections,video,{...raw,engine,lockFace:qwen?false:raw.lockFace,lockOutfit:qwen?false:raw.lockOutfit}),
|
||||||
settings: { aspect:['auto','16:9','9:16','1:1'].includes(raw.settings?.aspect)?raw.settings.aspect:'auto', fps:number('fps',24,1,60), width:number('width',1344,256,4096),height:number('height',768,256,4096),steps:number('steps',video?8:24,1,100),cfg:number('cfg',video?1.5:4,0,20),seedMode:raw.settings?.seedMode==='locked'?'locked':'random',seed:shotSeeds[0],duration:number('duration',5,1,15),turbo:raw.settings?.turbo !== false, loraStack:Array.isArray(raw.settings?.loraStack)?raw.settings.loraStack:[] },
|
settings: { aspect:['auto','16:9','9:16','1:1'].includes(raw.settings?.aspect)?raw.settings.aspect:'auto', fps:number('fps',24,1,60), width:number('width',defaultWidth,256,4096),height:number('height',defaultHeight,256,4096),steps:number('steps',defaultSteps,1,100),cfg:number('cfg',defaultCfg,0,20),seedMode:raw.settings?.seedMode==='locked'?'locked':'random',seed:shotSeeds[0],duration:number('duration',5,1,15),turbo:raw.settings?.turbo !== false, loraStack:qwen?[]:(Array.isArray(raw.settings?.loraStack)?raw.settings.loraStack:[]) },
|
||||||
guides: (Array.isArray(raw.guides)?raw.guides:[]).slice(0,8).map(g=>{ if (!g.stillId || !Number.isInteger(g.frame) || g.frame<1 || g.frame>=frames) fail('Each guide requires a still and positive frame index.'); return {stillId:String(g.stillId),frame:g.frame} }),
|
guides: (Array.isArray(raw.guides)?raw.guides:[]).slice(0,8).map(g=>{ if (!g.stillId || !Number.isInteger(g.frame) || g.frame<1 || g.frame>=frames) fail('Each guide requires a still and positive frame index.'); return {stillId:String(g.stillId),frame:g.frame} }),
|
||||||
batch: (Array.isArray(raw.batch)?raw.batch:[]).slice(0,30).map(s=>{const next=sectionsFrom({...promptSections,...(typeof s==='string'?{action:s}:s)});if(!next.action.trim())fail('Every shot needs an action prompt.');return video?next:{action:next.action}}) }
|
batch: (Array.isArray(raw.batch)?raw.batch:[]).slice(0,30).map(s=>{const next=sectionsFrom({...promptSections,...(typeof s==='string'?{action:s}:s)});if(!next.action.trim())fail('Every shot needs an action prompt.');return video?next:{action:next.action}}) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import {restoreLoras,loraUse} from './lora-memory.mjs'
|
import {restoreLoras,loraUse} from './lora-memory.mjs'
|
||||||
import { restoreInput } from './contracts.mjs'
|
import { restoreInput } from './contracts.mjs'
|
||||||
export const aspectOptions = [{id:'auto',label:'Automatic',hint:'Match start or hero still'},{id:'16:9',label:'16:9',hint:'1344 × 768 · padded'},{id:'9:16',label:'9:16',hint:'768 × 1344 · padded'},{id:'1:1',label:'1:1',hint:'960 × 960 · padded'}]
|
export const aspectOptions = [{id:'auto',label:'Automatic',hint:'Match start or hero still'},{id:'16:9',label:'16:9',hint:'1344 × 768 · padded'},{id:'9:16',label:'9:16',hint:'768 × 1344 · padded'},{id:'1:1',label:'1:1',hint:'960 × 960 · padded'}]
|
||||||
export function resolveSize(aspect, source, fallback = {width:1344,height:768}) {
|
export function resolveQwen21Size(aspect) {
|
||||||
|
const presets = {'16:9':[1024,576],'9:16':[576,1024],'1:1':[1024,1024]}
|
||||||
|
if (presets[aspect]) return {width:presets[aspect][0],height:presets[aspect][1]}
|
||||||
|
return {width:1024,height:1024}
|
||||||
|
}
|
||||||
|
export function resolveSize(aspect, source, fallback = {width:1344,height:768}, engine = '') {
|
||||||
|
if (engine === 'qwen21') return resolveQwen21Size(aspect)
|
||||||
const presets = {'16:9':[1344,768],'9:16':[768,1344],'1:1':[960,960]}
|
const presets = {'16:9':[1344,768],'9:16':[768,1344],'1:1':[960,960]}
|
||||||
if (presets[aspect]) return {width:presets[aspect][0],height:presets[aspect][1]}
|
if (presets[aspect]) return {width:presets[aspect][0],height:presets[aspect][1]}
|
||||||
if (!source?.width || !source?.height) return {width:fallback.width || 1344,height:fallback.height || 768}
|
if (!source?.width || !source?.height) return {width:fallback.width || 1344,height:fallback.height || 768}
|
||||||
@@ -35,7 +41,7 @@ export function restoreAsset(form, item, xaigen = false, memory) {
|
|||||||
const restored = restoreInput(item)
|
const restored = restoreInput(item)
|
||||||
const next = {...form,...restored,settings:{...form.settings,...restored.settings}}
|
const next = {...form,...restored,settings:{...form.settings,...restored.settings}}
|
||||||
next.settings.seedMode='random'
|
next.settings.seedMode='random'
|
||||||
next.engine=restored.engine==='krea'?'krea':'flux'
|
next.engine=restored.engine==='qwen21'?'qwen21':restored.engine==='krea'?'krea':'flux'
|
||||||
next.mode=['generate','edit','compose','refine','iterate'].includes(restored.mode)?restored.mode:'edit'
|
next.mode=['generate','edit','compose','refine','iterate'].includes(restored.mode)?restored.mode:'edit'
|
||||||
next.imageAId=item.id
|
next.imageAId=item.id
|
||||||
next.startClipId=''
|
next.startClipId=''
|
||||||
@@ -62,7 +68,7 @@ export function restoreAll(form,item,xaigen=false,memory) {
|
|||||||
const restored=restoreInput(item),next={...form,...restored,settings:{...form.settings,...restored.settings,seedMode:'random'}}
|
const restored=restoreInput(item),next={...form,...restored,settings:{...form.settings,...restored.settings,seedMode:'random'}}
|
||||||
if(item.kind==='clip') return {...bindClip(next,item,xaigen,memory),mode:'extend'}
|
if(item.kind==='clip') return {...bindClip(next,item,xaigen,memory),mode:'extend'}
|
||||||
next.mode=restored.mode==='iterate'?'edit':restored.mode || 'edit'
|
next.mode=restored.mode==='iterate'?'edit':restored.mode || 'edit'
|
||||||
next.engine=restored.engine==='krea'?'krea':'flux'
|
next.engine=restored.engine==='qwen21'?'qwen21':restored.engine==='krea'?'krea':'flux'
|
||||||
next.imageAId=item.id
|
next.imageAId=item.id
|
||||||
next.startClipId=''
|
next.startClipId=''
|
||||||
return restoreLoras(next,item.studio2?.settings?.loraStack ?? item.settings?.loraStack,xaigen,memory)
|
return restoreLoras(next,item.studio2?.settings?.loraStack ?? item.settings?.loraStack,xaigen,memory)
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import {filterLoraStackForUse} from '../../utils/loras.ts'
|
|||||||
|
|
||||||
export function loraUse(form) {
|
export function loraUse(form) {
|
||||||
const video=['video','extend'].includes(form.mode)
|
const video=['video','extend'].includes(form.mode)
|
||||||
return video ? (form.engine==='ltx'?'ltx':'minimax') : (form.engine==='krea'?'krea':'flux')
|
if (video) return form.engine==='ltx'?'ltx':'minimax'
|
||||||
|
if (form.engine==='qwen21') return 'qwen21'
|
||||||
|
return form.engine==='krea'?'krea':'flux'
|
||||||
}
|
}
|
||||||
|
|
||||||
// One controller per bench. Storage is optional (SSR/private browsing still works).
|
// One controller per bench. Storage is optional (SSR/private browsing still works).
|
||||||
|
|||||||
@@ -74,3 +74,10 @@ test('image chips compile positive and negative independently; video chips do no
|
|||||||
assert.doesNotMatch(compilePrompt(q.promptSections,false,q),/Picture 1/)
|
assert.doesNotMatch(compilePrompt(q.promptSections,false,q),/Picture 1/)
|
||||||
assert.deepEqual(parseBatch('A wrapped\nsentence\n\nSecond shot\r\n\r\nThird'),['A wrapped\nsentence','Second shot','Third'])
|
assert.deepEqual(parseBatch('A wrapped\nsentence\n\nSecond shot\r\n\r\nThird'),['A wrapped\nsentence','Second shot','Third'])
|
||||||
})
|
})
|
||||||
|
test('qwen21 is generate-only T2I with 25/1 defaults and no locks',()=>{
|
||||||
|
const q=validateRequest({mode:'generate',engine:'qwen21',folderId:'f',promptSections:{action:'a red bicycle'},settings:{aspect:'1:1'}})
|
||||||
|
assert.equal(q.engine,'qwen21');assert.equal(q.settings.steps,25);assert.equal(q.settings.cfg,1)
|
||||||
|
assert.equal(q.settings.width,1024);assert.equal(q.settings.height,1024)
|
||||||
|
assert.equal(q.lockFace,false);assert.equal(q.lockOutfit,false);assert.deepEqual(q.settings.loraStack,[])
|
||||||
|
assert.throws(()=>validateRequest({mode:'edit',engine:'qwen21',folderId:'f',imageAId:'s',promptSections:{action:'x'}}),/T2I in this build/)
|
||||||
|
})
|
||||||
|
|||||||
+5
-1
@@ -283,7 +283,7 @@ export function kleinLoraOnKreaMessage(name: string) {
|
|||||||
return imageLoraEngineMismatchMessage(name, 'krea')
|
return imageLoraEngineMismatchMessage(name, 'krea')
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LoraUse = 'minimax' | 'ltx' | 'flux' | 'krea'
|
export type LoraUse = 'minimax' | 'ltx' | 'flux' | 'krea' | 'qwen21'
|
||||||
|
|
||||||
export function isSystemGraphLora(name: string) {
|
export function isSystemGraphLora(name: string) {
|
||||||
const id = loraIdentityKey(name)
|
const id = loraIdentityKey(name)
|
||||||
@@ -301,16 +301,19 @@ export function inferLoraUse(name: string): LoraUse[] | null {
|
|||||||
if (/ltx/.test(value)) uses.push('ltx')
|
if (/ltx/.test(value)) uses.push('ltx')
|
||||||
if (/klein|flux/.test(value)) uses.push('flux')
|
if (/klein|flux/.test(value)) uses.push('flux')
|
||||||
if (/krea/.test(value)) uses.push('krea')
|
if (/krea/.test(value)) uses.push('krea')
|
||||||
|
if (/qwen/.test(value)) uses.push('qwen21')
|
||||||
return uses.length ? uses : null
|
return uses.length ? uses : null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loraMatchesUse(name: string, use: LoraUse) {
|
export function loraMatchesUse(name: string, use: LoraUse) {
|
||||||
|
if (use === 'qwen21') return false
|
||||||
return !isSystemGraphLora(name) && !!inferLoraUse(name)?.includes(use)
|
return !isSystemGraphLora(name) && !!inferLoraUse(name)?.includes(use)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Saved stacks may contain unlabelled adapters; never carry a known wrong engine. */
|
/** Saved stacks may contain unlabelled adapters; never carry a known wrong engine. */
|
||||||
export function filterLoraStackForUse(stack: unknown, use: LoraUse, xaigen: boolean) {
|
export function filterLoraStackForUse(stack: unknown, use: LoraUse, xaigen: boolean) {
|
||||||
if (use === 'ltx' && !xaigen) return []
|
if (use === 'ltx' && !xaigen) return []
|
||||||
|
if (use === 'qwen21') return []
|
||||||
return filterLoraStackForStudio(stack, xaigen).filter(item => {
|
return filterLoraStackForStudio(stack, xaigen).filter(item => {
|
||||||
const uses = inferLoraUse(item.name)
|
const uses = inferLoraUse(item.name)
|
||||||
return !isSystemGraphLora(item.name) && (!uses || uses.includes(use))
|
return !isSystemGraphLora(item.name) && (!uses || uses.includes(use))
|
||||||
@@ -318,6 +321,7 @@ export function filterLoraStackForUse(stack: unknown, use: LoraUse, xaigen: bool
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function filterLorasForUse(names: string[], use: LoraUse) {
|
export function filterLorasForUse(names: string[], use: LoraUse) {
|
||||||
|
if (use === 'qwen21') return []
|
||||||
return names.filter(name => loraMatchesUse(name, use))
|
return names.filter(name => loraMatchesUse(name, use))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user