diff --git a/components/studio2/Bench.vue b/components/studio2/Bench.vue
index 56f9d8a..d650e63 100644
--- a/components/studio2/Bench.vue
+++ b/components/studio2/Bench.vue
@@ -143,8 +143,10 @@
@@ -206,6 +208,7 @@
Generation settings
Krea does not attach the hero identity reference.
+
Qwen 2.1 is T2I — no hero, locks, or LoRA stack in this build.
{{ form.startClipId ? 'Extend · '+(clips.find((c:any)=>c.id===form.startClipId)?.name || form.startClipId)+' · last frame' : 'Select the clip in the family strip or Previous clip.' }}
@@ -249,8 +252,10 @@
Random
Turbo
+
Refresh LoRAs
+
Compiled prompt
{{ compiled }}
Negative: {{ stylePrompt(form.imageStyles,true) || 'None' }}
@@ -316,6 +321,7 @@ const tasks=['generate','edit','compose','refine','video','extend','music']
const settingsOpen=ref(false),detailsOpen=ref(false),submitting=ref(false),error=ref(''),batchText=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 [
@@ -325,13 +331,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 compiled=computed(()=>compilePrompt(form.promptSections,video.value,form))
@@ -348,7 +364,7 @@ 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))
function isHidden(asset:any){
if(!xaigen || !asset?.id)return false
diff --git a/server/utils/studio2/runner.ts b/server/utils/studio2/runner.ts
index 365d89a..fda8892 100644
--- a/server/utils/studio2/runner.ts
+++ b/server/utils/studio2/runner.ts
@@ -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;
diff --git a/server/utils/studio2/size.ts b/server/utils/studio2/size.ts
index 0c8a860..6378870 100644
--- a/server/utils/studio2/size.ts
+++ b/server/utils/studio2/size.ts
@@ -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))
}
diff --git a/shared/studio2/contracts.mjs b/shared/studio2/contracts.mjs
index 68793f2..40ffeb1 100644
--- a/shared/studio2/contracts.mjs
+++ b/shared/studio2/contracts.mjs
@@ -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)||nmax) 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}}) }
}
diff --git a/shared/studio2/input.mjs b/shared/studio2/input.mjs
index e421b61..e1321f6 100644
--- a/shared/studio2/input.mjs
+++ b/shared/studio2/input.mjs
@@ -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)
diff --git a/shared/studio2/lora-memory.mjs b/shared/studio2/lora-memory.mjs
index 5bb5351..40d55ec 100644
--- a/shared/studio2/lora-memory.mjs
+++ b/shared/studio2/lora-memory.mjs
@@ -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).
diff --git a/tests/studio2-bench.test.mjs b/tests/studio2-bench.test.mjs
index 07466d4..a99f9eb 100644
--- a/tests/studio2-bench.test.mjs
+++ b/tests/studio2-bench.test.mjs
@@ -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/)
+})
diff --git a/utils/loras.ts b/utils/loras.ts
index 412164f..46a2e3a 100644
--- a/utils/loras.ts
+++ b/utils/loras.ts
@@ -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))
}