Wire Qwen 2.1 as Generate-only engine with 25/1 sampler defaults.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-09-20 16:58:00 -05:00
co-authored by Cursor
parent 08d507c491
commit 68731916ca
8 changed files with 83 additions and 20 deletions
+19 -5
View File
@@ -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="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.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>
</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>
<span class="field-label">Prompt</span>
@@ -105,7 +106,7 @@
</div>
</div>
<div>
<div v-if="form.engine!=='qwen21'">
<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" />
</div>
@@ -142,8 +143,10 @@
<!-- CENTER viewer -->
<section class="bench-panel">
<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.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="classicHideOutput" type="checkbox" class="accent-[var(--amber)]">Hide output</label>
</div>
@@ -350,6 +353,7 @@ const videoDuration=ref(0)
const focusedJobId=ref('')
const video=computed(()=>['video','extend'].includes(form.mode))
const kreaSticky=computed(()=>['edit','compose','iterate'].includes(form.mode))
const qwenOnlyGenerate=computed(()=>form.mode!=='generate')
const engineOptions=computed(()=>{
if(video.value){
return [
@@ -359,13 +363,23 @@ const engineOptions=computed(()=>{
}
return [
{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=>{
const current=opts.find(o=>o.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 clips=computed(()=>library.value.clips.filter((s:any)=>s.folderId===form.folderId))
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 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 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 queueCards=computed(()=>{
const cards:any[]=[]
for(const job of benchJobs.value){
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 modeLabel=mode.charAt(0).toUpperCase()+mode.slice(1)
const aspect=job.request?.settings?.aspect || 'auto'
+24
View File
@@ -8,8 +8,10 @@ import { queueSeeds } from '~/shared/studio2/seed.mjs';
import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from 'node:fs';
import { join } from 'node:path';
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 { compilePrompt, scopedFile } from '~/shared/studio2/contracts.mjs';
import { resolveQwen21Size } from '~/shared/studio2/input.mjs';
import { createJob, restoreJob, getJob, emitJob, type Job } from '../jobs';
import { markStudioLive, onLiveVideoSettled, type StudioJob } from '../studioQueue';
import { readRecord, saveRecord, studio2Root, records } from './store';
@@ -126,6 +128,28 @@ async function prepareGraph(r: any) {
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.text = q.compiledPrompt;
graph['10'].inputs.text = stylePrompt(q.imageStyles, true);
graph['14'].inputs.width = size.width;
graph['14'].inputs.height = 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 = 'empty latent';
r.sampleDenoise = null;
r.heroReferenceAttached = false;
r.graphId = 'studio2_qwen21_t2i.json';
}
else {
const mode = identityPlan?.mode ?? (q.mode === 'iterate' ? (a ? 'edit' : 'generate') : q.mode);
const found = q.engine === 'krea' ? await resolveKreaGenerateAssets() : null;
+1 -1
View File
@@ -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.')
Object.assign(request.settings,resolveSize(request.settings.aspect,dimensions))
Object.assign(request.settings,resolveSize(request.settings.aspect,dimensions,undefined,request.engine))
}
+13 -7
View File
@@ -13,7 +13,7 @@ export function appearanceLock({lockFace = true, lockOutfit = true} = {}) {
export function compilePrompt(value, video = false, locks = {}) {
if (video) return [compileSections(value),appearanceLock(locks)].filter(Boolean).join('\n\n')
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'
return [sectionsFrom(value).action, stylePrompt(locks.imageStyles),
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'
if (!['generate','edit','compose','refine','iterate','video','extend'].includes(mode)) fail('Unknown task.')
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 === '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.guides) && raw.guides.length>8) fail('Use up to eight guide stills.')
if (!raw.folderId) fail('Choose a folder.')
@@ -46,8 +47,13 @@ export function validateRequest(raw, xaigen = false) {
const promptSections = video ? allSections : {action:allSections.action}
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 }
for (const key of ['steps']) if (!Number.isInteger(number(key,key==='steps'?24:1,0,2147483647))) fail(`Invalid ${key}.`)
for (const key of ['width','height']) if (number(key,key==='width'?1344:768,256,4096)%32) fail('Dimensions must be multiples of 32.')
const qwen = engine === 'qwen21'
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)
if (video) {
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 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 || ''},
promptSections, compiledPrompt:compilePrompt(promptSections,video,raw),
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:[] },
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',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} }),
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}}) }
}
+9 -3
View File
@@ -1,7 +1,13 @@
import {restoreLoras,loraUse} from './lora-memory.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 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]}
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}
@@ -35,7 +41,7 @@ export function restoreAsset(form, item, xaigen = false, memory) {
const restored = restoreInput(item)
const next = {...form,...restored,settings:{...form.settings,...restored.settings}}
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.imageAId=item.id
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'}}
if(item.kind==='clip') return {...bindClip(next,item,xaigen,memory),mode:'extend'}
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.startClipId=''
return restoreLoras(next,item.studio2?.settings?.loraStack ?? item.settings?.loraStack,xaigen,memory)
+3 -1
View File
@@ -2,7 +2,9 @@ import {filterLoraStackForUse} from '../../utils/loras.ts'
export function loraUse(form) {
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).
+7
View File
@@ -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.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
View File
@@ -283,7 +283,7 @@ export function kleinLoraOnKreaMessage(name: string) {
return imageLoraEngineMismatchMessage(name, 'krea')
}
export type LoraUse = 'minimax' | 'ltx' | 'flux' | 'krea'
export type LoraUse = 'minimax' | 'ltx' | 'flux' | 'krea' | 'qwen21'
export function isSystemGraphLora(name: string) {
const id = loraIdentityKey(name)
@@ -301,16 +301,19 @@ export function inferLoraUse(name: string): LoraUse[] | null {
if (/ltx/.test(value)) uses.push('ltx')
if (/klein|flux/.test(value)) uses.push('flux')
if (/krea/.test(value)) uses.push('krea')
if (/qwen/.test(value)) uses.push('qwen21')
return uses.length ? uses : null
}
export function loraMatchesUse(name: string, use: LoraUse) {
if (use === 'qwen21') return false
return !isSystemGraphLora(name) && !!inferLoraUse(name)?.includes(use)
}
/** Saved stacks may contain unlabelled adapters; never carry a known wrong engine. */
export function filterLoraStackForUse(stack: unknown, use: LoraUse, xaigen: boolean) {
if (use === 'ltx' && !xaigen) return []
if (use === 'qwen21') return []
return filterLoraStackForStudio(stack, xaigen).filter(item => {
const uses = inferLoraUse(item.name)
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) {
if (use === 'qwen21') return []
return names.filter(name => loraMatchesUse(name, use))
}