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 @@

Source

+
@@ -206,6 +208,7 @@

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 @@ +
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)) }