Wire Qwen Turbo toggle through Studio 2 and the runner.

Force 6/1 when Turbo is on, skip negatives, keep PE in front, and fail closed if the r128 LoRA is missing.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-09-26 12:42:00 -05:00
co-authored by Cursor
parent 17cbc99f8f
commit ecc4c69a8b
8 changed files with 110 additions and 31 deletions
+24 -3
View File
@@ -119,6 +119,12 @@
<label v-if="form.engine==='qwen21' && (form.mode==='generate' || form.mode==='edit')" class="inline-flex items-center gap-2 text-[var(--type-sm)] text-[var(--muted)]"> <label v-if="form.engine==='qwen21' && (form.mode==='generate' || form.mode==='edit')" class="inline-flex items-center gap-2 text-[var(--type-sm)] text-[var(--muted)]">
<input v-model="form.enhancePrompt" type="checkbox" class="accent-[var(--amber)]">Enhance prompt <input v-model="form.enhancePrompt" type="checkbox" class="accent-[var(--amber)]">Enhance prompt
</label> </label>
<div v-if="form.engine==='qwen21' && (form.mode==='generate' || form.mode==='edit')" class="flex flex-col gap-1">
<label class="inline-flex items-center gap-2 text-[var(--type-sm)] text-[var(--muted)]">
<input v-model="form.turbo" type="checkbox" class="accent-[var(--amber)]">Turbo
</label>
<p v-if="form.turbo" class="text-[11px] text-[var(--muted)]">6-step Viggle distill. CFG stays 1. Enhance prompt recommended.</p>
</div>
<div> <div>
<span class="field-label">Negative prompt</span> <span class="field-label">Negative prompt</span>
@@ -379,6 +385,7 @@ import {LOAD_GENERATION_PAYLOAD_KEY} from '~/utils/queuedJob'
const NEG_STORAGE_KEY='studio2-negative-chips' const NEG_STORAGE_KEY='studio2-negative-chips'
const ENHANCE_STORAGE_KEY='studio2-qwen21-enhance-prompt' const ENHANCE_STORAGE_KEY='studio2-qwen21-enhance-prompt'
const TURBO_STORAGE_KEY='studio2-qwen21-turbo'
function readStoredNegatives(){ function readStoredNegatives(){
if(!import.meta.client)return [...defaultNegatives] if(!import.meta.client)return [...defaultNegatives]
try{ try{
@@ -400,6 +407,14 @@ function writeStoredEnhancePrompt(on:boolean){
if(!import.meta.client)return if(!import.meta.client)return
try{localStorage.setItem(ENHANCE_STORAGE_KEY,on?'1':'0')}catch{/* ignore */} try{localStorage.setItem(ENHANCE_STORAGE_KEY,on?'1':'0')}catch{/* ignore */}
} }
function readStoredTurbo(){
if(!import.meta.client)return false
try{return localStorage.getItem(TURBO_STORAGE_KEY)==='1'}catch{return false}
}
function writeStoredTurbo(on:boolean){
if(!import.meta.client)return
try{localStorage.setItem(TURBO_STORAGE_KEY,on?'1':'0')}catch{/* ignore */}
}
const xaigen=isXaigenStudio(useRuntimeConfig().public) const xaigen=isXaigenStudio(useRuntimeConfig().public)
const instanceName=String(useRuntimeConfig().public.instanceName || 'AIGen') const instanceName=String(useRuntimeConfig().public.instanceName || 'AIGen')
@@ -419,7 +434,7 @@ const liveMusic=ref<Record<string,any>>({})
const negativeDraft=ref('') const negativeDraft=ref('')
const filmstripEl=ref<HTMLElement|null>(null) const filmstripEl=ref<HTMLElement|null>(null)
const tileRefs=new Map<string,HTMLElement>() const tileRefs=new Map<string,HTMLElement>()
const form=reactive<any>({mode:'generate',engine:'flux',enhancePrompt:readStoredEnhancePrompt(),imageStyles:{positive:[],negative:readStoredNegatives()},lockFace:true,lockOutfit:true,hideInput:xaigen,folderId:'',identityStillId:'',imageAId:'',imageBId:'',maskId:'',startClipId:'',endStillId:'',guides:[],promptSections:sectionsFrom({}),settings:{aspect:'auto',fps:24,steps:24,cfg:4,seed:null,seedMode:'random',duration:5,turbo:true,loraStack:[]}}) const form=reactive<any>({mode:'generate',engine:'flux',enhancePrompt:readStoredEnhancePrompt(),turbo:readStoredTurbo(),imageStyles:{positive:[],negative:readStoredNegatives()},lockFace:true,lockOutfit:true,hideInput:xaigen,folderId:'',identityStillId:'',imageAId:'',imageBId:'',maskId:'',startClipId:'',endStillId:'',guides:[],promptSections:sectionsFrom({}),settings:{aspect:'auto',fps:24,steps:24,cfg:4,seed:null,seedMode:'random',duration:5,turbo:true,loraStack:[]}})
const music=reactive({tags:DEFAULT_MUSIC_TAGS,lyrics:DEFAULT_MUSIC_LYRICS,duration:60,seed:'',name:''}) const music=reactive({tags:DEFAULT_MUSIC_TAGS,lyrics:DEFAULT_MUSIC_LYRICS,duration:60,seed:'',name:''})
const tagsPlaceholder=DEFAULT_MUSIC_TAGS,lyricsPlaceholder=DEFAULT_MUSIC_LYRICS const tagsPlaceholder=DEFAULT_MUSIC_TAGS,lyricsPlaceholder=DEFAULT_MUSIC_LYRICS
let loraMemory=createLoraMemory(xaigen) let loraMemory=createLoraMemory(xaigen)
@@ -456,7 +471,7 @@ watch(engineOptions,opts=>{
}) })
watch(()=>form.engine,(engine,prev)=>{ watch(()=>form.engine,(engine,prev)=>{
if(engine==='qwen21' && prev!=='qwen21'){ if(engine==='qwen21' && prev!=='qwen21'){
form.settings.steps=25 form.settings.steps=form.turbo?6:25
form.settings.cfg=1 form.settings.cfg=1
form.lockFace=false form.lockFace=false
form.lockOutfit=false form.lockOutfit=false
@@ -464,9 +479,15 @@ watch(()=>form.engine,(engine,prev)=>{
} }
}) })
watch(()=>form.enhancePrompt,on=>writeStoredEnhancePrompt(!!on)) watch(()=>form.enhancePrompt,on=>writeStoredEnhancePrompt(!!on))
watch(()=>form.turbo,on=>{
writeStoredTurbo(!!on)
if(form.engine!=='qwen21')return
form.settings.steps=on?6:25
form.settings.cfg=1
})
watch(()=>[form.mode,form.engine],([mode,engine])=>{ watch(()=>[form.mode,form.engine],([mode,engine])=>{
if(engine==='qwen21' && mode==='edit'){ if(engine==='qwen21' && mode==='edit'){
form.settings.steps=25 form.settings.steps=form.turbo?6:25
form.settings.cfg=1 form.settings.cfg=1
form.lockFace=false form.lockFace=false
form.lockOutfit=false form.lockOutfit=false
+3
View File
@@ -22,6 +22,9 @@ const rows=computed(()=>{const m=props.metadata||{},s=m.settings||{};const time=
{label:'Compiled prompt',value:m.compiledPrompt}, {label:'Compiled prompt',value:m.compiledPrompt},
{label:'Prompt (raw)',value:m.promptRaw || null}, {label:'Prompt (raw)',value:m.promptRaw || null},
{label:'Enhance prompt',value:m.enhancePrompt==null?null:m.enhancePrompt?'On':'Off'}, {label:'Enhance prompt',value:m.enhancePrompt==null?null:m.enhancePrompt?'On':'Off'},
{label:'Turbo',value:m.turbo==null?null:m.turbo?'On':'Off'},
{label:'Turbo LoRA',value:m.lora || null},
{label:'Turbo sigmas',value:m.sigmas || null},
{label:'Enhance wh_ratio',value:m.enhance?.wh_ratio || null}, {label:'Enhance wh_ratio',value:m.enhance?.wh_ratio || null},
{label:'Enhance ratio_follow',value:m.enhance?.ratio_follow || null}, {label:'Enhance ratio_follow',value:m.enhance?.ratio_follow || null},
{label:'Enhance parse_ok',value:m.enhance?.parse_ok==null?null:m.enhance.parse_ok?'Yes':'No'}, {label:'Enhance parse_ok',value:m.enhance?.parse_ok==null?null:m.enhance.parse_ok?'Yes':'No'},
+2
View File
@@ -159,6 +159,8 @@
<template v-else> <template v-else>
<label class="m-label">Prompt<textarea v-model="ctx.form.promptSections.action" rows="5" class="m-area" placeholder="Describe the image or the shot." /></label> <label class="m-label">Prompt<textarea v-model="ctx.form.promptSections.action" rows="5" class="m-area" placeholder="Describe the image or the shot." /></label>
<label v-if="ctx.form.engine==='qwen21' && (ctx.form.mode==='generate' || ctx.form.mode==='edit')" class="m-check"><input v-model="ctx.form.enhancePrompt" type="checkbox"> Enhance prompt</label> <label v-if="ctx.form.engine==='qwen21' && (ctx.form.mode==='generate' || ctx.form.mode==='edit')" class="m-check"><input v-model="ctx.form.enhancePrompt" type="checkbox"> Enhance prompt</label>
<label v-if="ctx.form.engine==='qwen21' && (ctx.form.mode==='generate' || ctx.form.mode==='edit')" class="m-check"><input v-model="ctx.form.turbo" type="checkbox"> Turbo</label>
<p v-if="ctx.form.engine==='qwen21' && ctx.form.turbo" class="m-muted">6-step Viggle distill. CFG stays 1. Enhance prompt recommended.</p>
<div> <div>
<span class="m-label">Negative prompt</span> <span class="m-label">Negative prompt</span>
<div class="m-neg"> <div class="m-neg">
+3 -2
View File
@@ -42,6 +42,7 @@ Restart Comfy **only when idle** (`COMFY_CONTROL_URL/status` → `gpu.busy=false
- Edit follows `image_1` via the encode node’s latent (resolution long-edge ~1024). Do not inject a picker EmptyLatentImage onto the edit sampler. - Edit follows `image_1` via the encode node’s latent (resolution long-edge ~1024). Do not inject a picker EmptyLatentImage onto the edit sampler.
- Optional **Enhance prompt** (off by default): runs a separate PE-only Comfy graph (`CLIPLoader` + rewrite node), then frees VRAM and queues the existing T2I/Edit graph with the rewritten prompt. Never loads PE CLIP + DiT together on 16 GB. Fail closed if `parse_ok` is false or the rewrite is empty. - Optional **Enhance prompt** (off by default): runs a separate PE-only Comfy graph (`CLIPLoader` + rewrite node), then frees VRAM and queues the existing T2I/Edit graph with the rewritten prompt. Never loads PE CLIP + DiT together on 16 GB. Fail closed if `parse_ok` is false or the rewrite is empty.
- PE weights (int8 only): `text_encoders\qwen3.5_9b_qwen_image_2.1_pe_{t2i,i2i}.int8_convrot.safetensors` — do not replace the image TE `qwen3vl_8b_int8_convrot.safetensors`. - PE weights (int8 only): `text_encoders\qwen3.5_9b_qwen_image_2.1_pe_{t2i,i2i}.int8_convrot.safetensors` — do not replace the image TE `qwen3vl_8b_int8_convrot.safetensors`.
- Custom node: `ComfyUI-Qwen-Image-2.1-Prompt-Enhancer` (`QwenImage21_T2IPromptRewrite` / `QwenImage21_EditPromptRewrite`). - Optional **Turbo** (off by default): Viggle DMD LoRA `Qwen-Image-2.1-viggle-turbo-v0.2.1-6step-lora-r128.safetensors` on the same Q8 GGUF via `ViggleTurboLora` + `ViggleTurboSigmas` (`1.0, 0.9375, 0.875, 0.75, 0.5, 0.25`), 6 steps, CFG 1, empty negative. Not a new engine. Enhance prompt stays compatible and is recommended with Turbo.
- Custom node: `ComfyUI-Viggle-Turbo` (`viggle_turbo.py`). Do not merge the LoRA with stock `LoraLoaderModelOnly` (lossy on int8/bf16).
Graphs: `server/assets/studio2_qwen21_t2i.json`, `server/assets/studio2_qwen21_edit.json`, `server/assets/studio2_qwen21_pe_t2i.json`, `server/assets/studio2_qwen21_pe_edit.json`. Graphs: `server/assets/studio2_qwen21_t2i.json`, `server/assets/studio2_qwen21_edit.json`, `server/assets/studio2_qwen21_t2i_turbo.json`, `server/assets/studio2_qwen21_edit_turbo.json`, `server/assets/studio2_qwen21_pe_t2i.json`, `server/assets/studio2_qwen21_pe_edit.json`.
+46 -9
View File
@@ -10,11 +10,14 @@ 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 qwen21Template from '../../assets/studio2_qwen21_t2i.json'
import qwen21EditTemplate from '../../assets/studio2_qwen21_edit.json' import qwen21EditTemplate from '../../assets/studio2_qwen21_edit.json'
import qwen21T2iTurboTemplate from '../../assets/studio2_qwen21_t2i_turbo.json'
import qwen21EditTurboTemplate from '../../assets/studio2_qwen21_edit_turbo.json'
import qwen21PeT2iTemplate from '../../assets/studio2_qwen21_pe_t2i.json' import qwen21PeT2iTemplate from '../../assets/studio2_qwen21_pe_t2i.json'
import qwen21PeEditTemplate from '../../assets/studio2_qwen21_pe_edit.json'; import qwen21PeEditTemplate from '../../assets/studio2_qwen21_pe_edit.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/qwen21-size.mjs'; import { resolveQwen21Size } from '~/shared/studio2/qwen21-size.mjs';
import { QWEN21_TURBO_LORA, QWEN21_TURBO_SIGMAS, QWEN21_TURBO_STEPS } from '~/shared/studio2/qwen21-turbo.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';
@@ -235,12 +238,20 @@ async function prepareGraph(r: any) {
else if (q.engine === 'qwen21') { else if (q.engine === 'qwen21') {
if (!['generate', 'edit'].includes(q.mode)) if (!['generate', 'edit'].includes(q.mode))
throw new Error('Qwen 2.1 supports Generate and Edit.') throw new Error('Qwen 2.1 supports Generate and Edit.')
const turbo = !!q.turbo
if (turbo) {
q.lora = QWEN21_TURBO_LORA
q.sigmas = QWEN21_TURBO_SIGMAS
s.steps = QWEN21_TURBO_STEPS
s.cfg = 1
}
const negative = turbo ? '' : stylePrompt(q.imageStyles, true)
if (q.mode === 'edit') { if (q.mode === 'edit') {
if (!a) throw new Error('Choose the source still.') if (!a) throw new Error('Choose the source still.')
let prompt = String(q.compiledPrompt || '') let prompt = String(q.compiledPrompt || '')
if (!/<image1>/i.test(prompt)) if (!/<image1>/i.test(prompt))
prompt = `Keep the subject in <image1>. ${prompt}`.trim() prompt = `Keep the subject in <image1>. ${prompt}`.trim()
graph = structuredClone(qwen21EditTemplate) graph = structuredClone(turbo ? qwen21EditTurboTemplate : qwen21EditTemplate)
graph['10'].inputs.image = a graph['10'].inputs.image = a
if (b) { if (b) {
graph['11'] = { graph['11'] = {
@@ -251,10 +262,18 @@ async function prepareGraph(r: any) {
graph['9'].inputs['images.image_2'] = ['11', 0] graph['9'].inputs['images.image_2'] = ['11', 0]
} }
graph['9'].inputs.prompt = prompt graph['9'].inputs.prompt = prompt
graph['9'].inputs.negative_prompt = stylePrompt(q.imageStyles, true) graph['9'].inputs.negative_prompt = negative
// Official edit: sampler latent comes from TextEncode (sized from image_1). // Official edit: sampler latent comes from TextEncode (sized from image_1).
// Do not inject EmptyLatentImage — a mismatched empty canvas shifts the edit.
graph['9'].inputs.resolution = 1024 graph['9'].inputs.resolution = 1024
if (turbo) {
graph['7'].inputs.lora_name = QWEN21_TURBO_LORA
graph['7'].inputs.strength = 1.0
graph['31'].inputs.noise_seed = s.seed
graph['33'].inputs.nodes = QWEN21_TURBO_SIGMAS
graph['36'].inputs.filename_prefix = prefix + '/image'
r.sampleLatent = 'qwen21 edit latent'
r.graphId = 'studio2_qwen21_edit_turbo.json'
} else {
graph['15'].inputs.seed = s.seed graph['15'].inputs.seed = s.seed
graph['15'].inputs.steps = s.steps || 25 graph['15'].inputs.steps = s.steps || 25
graph['15'].inputs.cfg = s.cfg ?? 1 graph['15'].inputs.cfg = s.cfg ?? 1
@@ -262,21 +281,30 @@ async function prepareGraph(r: any) {
graph['15'].inputs.scheduler = 'simple' graph['15'].inputs.scheduler = 'simple'
graph['21'].inputs.filename_prefix = prefix + '/image' graph['21'].inputs.filename_prefix = prefix + '/image'
r.sampleLatent = 'qwen21 edit latent' r.sampleLatent = 'qwen21 edit latent'
r.graphId = 'studio2_qwen21_edit.json'
}
r.sampleDenoise = null r.sampleDenoise = null
r.heroReferenceAttached = false r.heroReferenceAttached = false
r.graphId = 'studio2_qwen21_edit.json'
} else { } else {
// Bench aspect is law for Generate. PE wh_ratio is advisory only (never sizes the canvas). // Bench aspect is law for Generate. PE wh_ratio is advisory only (never sizes the canvas).
const size = resolveQwen21Size(s.aspect) const size = resolveQwen21Size(s.aspect)
s.width = size.width s.width = size.width
s.height = size.height s.height = size.height
graph = structuredClone(qwen21Template) graph = structuredClone(turbo ? qwen21T2iTurboTemplate : qwen21Template)
graph['9'].inputs.prompt = q.compiledPrompt graph['9'].inputs.prompt = q.compiledPrompt
graph['9'].inputs.negative_prompt = stylePrompt(q.imageStyles, true) graph['9'].inputs.negative_prompt = negative
// TextEncode conditioning only — EmptyLatentImage (node 16) owns canvas size, matching the official T2I template.
graph['9'].inputs.resolution = 1024 graph['9'].inputs.resolution = 1024
graph['16'].inputs.width = size.width graph['16'].inputs.width = size.width
graph['16'].inputs.height = size.height graph['16'].inputs.height = size.height
if (turbo) {
graph['7'].inputs.lora_name = QWEN21_TURBO_LORA
graph['7'].inputs.strength = 1.0
graph['31'].inputs.noise_seed = s.seed
graph['33'].inputs.nodes = QWEN21_TURBO_SIGMAS
graph['36'].inputs.filename_prefix = prefix + '/image'
r.sampleLatent = 'qwen21 empty latent'
r.graphId = 'studio2_qwen21_t2i_turbo.json'
} else {
graph['15'].inputs.seed = s.seed graph['15'].inputs.seed = s.seed
graph['15'].inputs.steps = s.steps || 25 graph['15'].inputs.steps = s.steps || 25
graph['15'].inputs.cfg = s.cfg ?? 1 graph['15'].inputs.cfg = s.cfg ?? 1
@@ -284,9 +312,10 @@ async function prepareGraph(r: any) {
graph['15'].inputs.scheduler = 'simple' graph['15'].inputs.scheduler = 'simple'
graph['21'].inputs.filename_prefix = prefix + '/image' graph['21'].inputs.filename_prefix = prefix + '/image'
r.sampleLatent = 'qwen21 empty latent' r.sampleLatent = 'qwen21 empty latent'
r.graphId = 'studio2_qwen21_t2i.json'
}
r.sampleDenoise = null r.sampleDenoise = null
r.heroReferenceAttached = false r.heroReferenceAttached = false
r.graphId = 'studio2_qwen21_t2i.json'
} }
} }
else { else {
@@ -344,7 +373,15 @@ async function run(r: any, job: Job) {
throw new Error('Cancelled'); throw new Error('Cancelled');
update(r, 'submitting'); update(r, 'submitting');
emitJob(job, { type: 'status', message: 'Generating…' }); emitJob(job, { type: 'status', message: 'Generating…' });
const queued = await queuePrompt(graph, job.clientId); let queued: { prompt_id: string; number?: number }
try {
queued = await queuePrompt(graph, job.clientId);
} catch (error: any) {
const msg = String(error?.statusMessage || error?.message || error)
if (r.request?.turbo && /lora|ViggleTurbo|not in list|value_not_in_list/i.test(msg))
throw new Error(`Qwen Turbo LoRA missing on host (${QWEN21_TURBO_LORA}). Install the r128 file under models/loras and restart Comfy.`)
throw error
}
job.promptId = queued.prompt_id; job.promptId = queued.prompt_id;
r.promptId = queued.prompt_id; r.promptId = queued.prompt_id;
r.renderStartedAt = Date.now(); r.renderStartedAt = Date.now();
+6 -2
View File
@@ -2,6 +2,7 @@ import {normalizeStyles,stylePrompt} from './styles.mjs'
import { queueSeeds } from './seed.mjs' import { queueSeeds } from './seed.mjs'
import { sectionsFrom, compileSections } from './prompt.mjs' import { sectionsFrom, compileSections } from './prompt.mjs'
import { QWEN21_ASPECT_IDS, resolveQwen21Size } from './qwen21-size.mjs' import { QWEN21_ASPECT_IDS, resolveQwen21Size } from './qwen21-size.mjs'
import { QWEN21_TURBO_CFG, QWEN21_TURBO_LORA, QWEN21_TURBO_SIGMAS, QWEN21_TURBO_STEPS } from './qwen21-turbo.mjs'
export { sectionsFrom } from './prompt.mjs' export { sectionsFrom } from './prompt.mjs'
export const cameraFamilies = ['static','dolly in','dolly out','pan left','pan right','tilt up','tilt down','zoom in','zoom out','orbit left','orbit right'] export const cameraFamilies = ['static','dolly in','dolly out','pan left','pan right','tilt up','tilt down','zoom in','zoom out','orbit left','orbit right']
export const identityLock = '<Picture 1> / hero is identity for face, hair, glasses, body, and wardrobe. Keep the person and clothes fully preserved. Start frame is composition, pose, place, and camera only. Do not copy facial drift from the start frame.' export const identityLock = '<Picture 1> / hero is identity for face, hair, glasses, body, and wardrobe. Keep the person and clothes fully preserved. Start frame is composition, pose, place, and camera only. Do not copy facial drift from the start frame.'
@@ -65,12 +66,15 @@ 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))
const enhancePrompt = qwen && !video && !!raw.enhancePrompt const enhancePrompt = qwen && !video && !!raw.enhancePrompt
const turbo = qwen && !video && !!raw.turbo
const width = qwenSize ? qwenSize.width : number('width',defaultWidth,256,4096) const width = qwenSize ? qwenSize.width : number('width',defaultWidth,256,4096)
const height = qwenSize ? qwenSize.height : number('height',defaultHeight,256,4096) const height = qwenSize ? qwenSize.height : number('height',defaultHeight,256,4096)
return { mode, engine, shotSeeds, imageStyles:video?{positive:[],negative:[]}:normalizeStyles(raw.imageStyles), hideOutput:!!raw.hideOutput, enhancePrompt, 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 || '', const steps = turbo ? QWEN21_TURBO_STEPS : number('steps',defaultSteps,1,100)
const cfg = turbo ? QWEN21_TURBO_CFG : number('cfg',defaultCfg,0,20)
return { mode, engine, shotSeeds, imageStyles:video?{positive:[],negative:[]}:normalizeStyles(raw.imageStyles), hideOutput:!!raw.hideOutput, enhancePrompt, turbo, ...(turbo ? { lora: QWEN21_TURBO_LORA, sigmas: QWEN21_TURBO_SIGMAS } : {}), 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,engine,lockFace:qwen?false:raw.lockFace,lockOutfit:qwen?false:raw.lockOutfit}), promptSections, compiledPrompt:compilePrompt(promptSections,video,{...raw,engine,lockFace:qwen?false:raw.lockFace,lockOutfit:qwen?false:raw.lockOutfit}),
settings: { aspect, fps:number('fps',24,1,60), width, height, 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:[]) }, settings: { aspect, fps:number('fps',24,1,60), width, height, steps, cfg, 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
View File
@@ -118,6 +118,7 @@ export function hydrateStudio2Job(form, saved, { stillIds = new Set(), clipIds =
mode, mode,
engine, engine,
enhancePrompt: engine === 'qwen21' && !!src.enhancePrompt, enhancePrompt: engine === 'qwen21' && !!src.enhancePrompt,
turbo: engine === 'qwen21' && !!src.turbo,
lockFace: src.lockFace !== false, lockFace: src.lockFace !== false,
lockOutfit: src.lockOutfit !== false, lockOutfit: src.lockOutfit !== false,
hideInput: !!src.hideInput, hideInput: !!src.hideInput,
+11 -1
View File
@@ -83,13 +83,18 @@ test('qwen21 supports Generate and Edit with 25/1 defaults and no locks',()=>{
assert.equal(q.settings.width,1024);assert.equal(q.settings.height,1024) 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.equal(q.lockFace,false);assert.equal(q.lockOutfit,false);assert.deepEqual(q.settings.loraStack,[])
assert.equal(q.enhancePrompt,false) assert.equal(q.enhancePrompt,false)
assert.equal(q.turbo,false)
const wide=validateRequest({mode:'generate',engine:'qwen21',folderId:'f',promptSections:{action:'wide'},settings:{aspect:'16:9'}}) const wide=validateRequest({mode:'generate',engine:'qwen21',folderId:'f',promptSections:{action:'wide'},settings:{aspect:'16:9'}})
assert.equal(wide.settings.width,1536);assert.equal(wide.settings.height,864) assert.equal(wide.settings.width,1536);assert.equal(wide.settings.height,864)
const tall=validateRequest({mode:'generate',engine:'qwen21',folderId:'f',promptSections:{action:'tall'},settings:{aspect:'9:16'}}) const tall=validateRequest({mode:'generate',engine:'qwen21',folderId:'f',promptSections:{action:'tall'},settings:{aspect:'9:16'}})
assert.equal(tall.settings.width,864);assert.equal(tall.settings.height,1536) assert.equal(tall.settings.width,864);assert.equal(tall.settings.height,1536)
const turbo=validateRequest({mode:'generate',engine:'qwen21',folderId:'f',turbo:true,promptSections:{action:'fast'},settings:{aspect:'1:1',steps:25,cfg:4}})
assert.equal(turbo.turbo,true);assert.equal(turbo.settings.steps,6);assert.equal(turbo.settings.cfg,1)
assert.match(String(turbo.lora),/viggle-turbo-v0\.2\.1-6step-lora-r128/)
assert.match(String(turbo.sigmas),/0\.9375/)
const enhanced=validateRequest({mode:'generate',engine:'qwen21',folderId:'f',enhancePrompt:true,promptSections:{action:'corgi'},settings:{aspect:'1:1'}}) const enhanced=validateRequest({mode:'generate',engine:'qwen21',folderId:'f',enhancePrompt:true,promptSections:{action:'corgi'},settings:{aspect:'1:1'}})
assert.equal(enhanced.enhancePrompt,true) assert.equal(enhanced.enhancePrompt,true)
assert.equal(validateRequest({mode:'generate',engine:'flux',folderId:'f',enhancePrompt:true,promptSections:{action:'x'},settings:{aspect:'1:1'}}).enhancePrompt,false) assert.equal(validateRequest({mode:'generate',engine:'flux',folderId:'f',enhancePrompt:true,turbo:true,promptSections:{action:'x'},settings:{aspect:'1:1'}}).turbo,false)
const edit=validateRequest({mode:'edit',engine:'qwen21',folderId:'f',imageAId:'s',promptSections:{action:'change the sky'},settings:{aspect:'auto'}}) const edit=validateRequest({mode:'edit',engine:'qwen21',folderId:'f',imageAId:'s',promptSections:{action:'change the sky'},settings:{aspect:'auto'}})
assert.equal(edit.mode,'edit');assert.equal(edit.settings.steps,25);assert.equal(edit.settings.cfg,1) assert.equal(edit.mode,'edit');assert.equal(edit.settings.steps,25);assert.equal(edit.settings.cfg,1)
assert.equal(edit.lockFace,false);assert.deepEqual(edit.settings.loraStack,[]) assert.equal(edit.lockFace,false);assert.deepEqual(edit.settings.loraStack,[])
@@ -115,6 +120,7 @@ test('custom negative chips survive normalizeStyles and bench exposes Load actio
assert.match(src,/loadSelectedJob/);assert.match(src,/hydrateStudio2Job/);assert.match(src,/Edit source/) assert.match(src,/loadSelectedJob/);assert.match(src,/hydrateStudio2Job/);assert.match(src,/Edit source/)
assert.match(src,/aspect:'auto'/);assert.match(src,/strip-actions/);assert.match(src,/addNegative/) assert.match(src,/aspect:'auto'/);assert.match(src,/strip-actions/);assert.match(src,/addNegative/)
assert.match(src,/Enhance prompt/);assert.match(src,/enhancePrompt/) assert.match(src,/Enhance prompt/);assert.match(src,/enhancePrompt/)
assert.match(src,/\bTurbo\b/);assert.match(src,/form\.turbo/)
const peT2i=readFileSync(new URL('../server/assets/studio2_qwen21_pe_t2i.json',import.meta.url),'utf8') const peT2i=readFileSync(new URL('../server/assets/studio2_qwen21_pe_t2i.json',import.meta.url),'utf8')
const peEdit=readFileSync(new URL('../server/assets/studio2_qwen21_pe_edit.json',import.meta.url),'utf8') const peEdit=readFileSync(new URL('../server/assets/studio2_qwen21_pe_edit.json',import.meta.url),'utf8')
assert.match(peT2i,/QwenImage21_T2IPromptRewrite/);assert.match(peT2i,/pe_t2i\.int8_convrot/) assert.match(peT2i,/QwenImage21_T2IPromptRewrite/);assert.match(peT2i,/pe_t2i\.int8_convrot/)
@@ -123,6 +129,10 @@ test('custom negative chips survive normalizeStyles and bench exposes Load actio
assert.doesNotMatch(peEdit,/UnetLoader|KSampler|VAELoader/) assert.doesNotMatch(peEdit,/UnetLoader|KSampler|VAELoader/)
const t2i=readFileSync(new URL('../server/assets/studio2_qwen21_t2i.json',import.meta.url),'utf8') const t2i=readFileSync(new URL('../server/assets/studio2_qwen21_t2i.json',import.meta.url),'utf8')
assert.match(t2i,/EmptyLatentImage/);assert.match(t2i,/"latent_image":\s*\["16",\s*0\]/) assert.match(t2i,/EmptyLatentImage/);assert.match(t2i,/"latent_image":\s*\["16",\s*0\]/)
const turboT2i=readFileSync(new URL('../server/assets/studio2_qwen21_t2i_turbo.json',import.meta.url),'utf8')
const turboEdit=readFileSync(new URL('../server/assets/studio2_qwen21_edit_turbo.json',import.meta.url),'utf8')
assert.match(turboT2i,/ViggleTurboLora/);assert.match(turboT2i,/ViggleTurboSigmas/);assert.match(turboT2i,/SamplerCustomAdvanced/)
assert.match(turboT2i,/0\.9375/);assert.match(turboEdit,/ViggleTurboLora/);assert.match(turboEdit,/"latent":\s*\["9",\s*2\]/)
assert.deepEqual(resolveQwen21Size('16:9'),{width:1536,height:864}) assert.deepEqual(resolveQwen21Size('16:9'),{width:1536,height:864})
assert.deepEqual(resolveQwen21Size('1:1'),{width:1024,height:1024}) assert.deepEqual(resolveQwen21Size('1:1'),{width:1024,height:1024})
assert.deepEqual(resolveQwen21Size('9:16'),{width:864,height:1536}) assert.deepEqual(resolveQwen21Size('9:16'),{width:864,height:1536})