From 95873744cd621fc231ec03241302ded62c277af5 Mon Sep 17 00:00:00 2001 From: Towsty Date: Sat, 26 Sep 2026 10:54:18 -0500 Subject: [PATCH] Size Qwen 2.1 Generate from the bench aspect table. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire EmptyLatentImage to the official ~1 MP grid so 16:9 lands at 1536×864, keep PE wh_ratio advisory, and leave Edit on the encode latent from image_1. Co-authored-by: Cursor --- docs/qwen21.md | 3 +- server/assets/studio2_qwen21_t2i.json | 11 ++++- server/utils/studio2/runner.ts | 23 +++++------ shared/studio2/contracts.mjs | 11 +++-- shared/studio2/input.mjs | 35 +++++++++------- shared/studio2/qwen21-size.mjs | 58 +++++++++++++++++++++++++++ tests/studio2-bench.test.mjs | 10 +++++ 7 files changed, 119 insertions(+), 32 deletions(-) create mode 100644 shared/studio2/qwen21-size.mjs diff --git a/docs/qwen21.md b/docs/qwen21.md index 2d0b9bd..b8c83c2 100644 --- a/docs/qwen21.md +++ b/docs/qwen21.md @@ -38,7 +38,8 @@ Restart Comfy **only when idle** (`COMFY_CONTROL_URL/status` → `gpu.busy=false - Edit requires Start still → `images.image_1`; prompt slots use `` / ``. If the user omits ``, the runner prepends `Keep the subject in .` - Face / outfit locks stay Klein semantics — they do not drive Qwen slots. - Sampler defaults: euler / simple / cfg **1** / steps **25**. Edit uses `QwenImage21Cache` (device auto, dtype int8). T2I uses `ModelSamplingAuraFlow` shift **3.1**. -- Default canvas **1024×1024** (aspect 16:9 / 9:16 / 1:1 → long-edge ~1024 via `TextEncodeQwenImage21` resolution; multiples of 32). Edit with aspect `auto` follows `image_1` aspect at ~1024. +- Default Generate canvas follows the bench Aspect control on the Qwen-safe ~1 MP grid (`EmptyLatentImage`): 1:1 → 1024×1024, 16:9 → 1536×864, 9:16 → 864×1536 (and the other table rows). PE `wh_ratio` is advisory only and never sizes the canvas. Do not use native 2K bins on 16 GB. +- 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. - 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`). diff --git a/server/assets/studio2_qwen21_t2i.json b/server/assets/studio2_qwen21_t2i.json index 6b731b0..0508a6e 100644 --- a/server/assets/studio2_qwen21_t2i.json +++ b/server/assets/studio2_qwen21_t2i.json @@ -40,6 +40,15 @@ "class_type": "TextEncodeQwenImage21", "_meta": { "title": "Text Encode Qwen Image 2.1" } }, + "16": { + "inputs": { + "width": 1024, + "height": 1024, + "batch_size": 1 + }, + "class_type": "EmptyLatentImage", + "_meta": { "title": "Qwen 2.1 canvas" } + }, "15": { "inputs": { "seed": 1, @@ -51,7 +60,7 @@ "model": ["3", 0], "positive": ["9", 0], "negative": ["9", 1], - "latent_image": ["9", 2] + "latent_image": ["16", 0] }, "class_type": "KSampler", "_meta": { "title": "KSampler" } diff --git a/server/utils/studio2/runner.ts b/server/utils/studio2/runner.ts index 703bd11..2855f9a 100644 --- a/server/utils/studio2/runner.ts +++ b/server/utils/studio2/runner.ts @@ -14,7 +14,7 @@ import qwen21PeT2iTemplate from '../../assets/studio2_qwen21_pe_t2i.json' import qwen21PeEditTemplate from '../../assets/studio2_qwen21_pe_edit.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 { resolveQwen21Size } from '~/shared/studio2/qwen21-size.mjs'; import { createJob, restoreJob, getJob, emitJob, type Job } from '../jobs'; import { markStudioLive, onLiveVideoSettled, type StudioJob } from '../studioQueue'; import { readRecord, saveRecord, studio2Root, records } from './store'; @@ -252,41 +252,38 @@ async function prepareGraph(r: any) { } graph['9'].inputs.prompt = prompt graph['9'].inputs.negative_prompt = stylePrompt(q.imageStyles, true) + // 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 - if (s.aspect && s.aspect !== 'auto') { - graph['16'] = { - inputs: { width: s.width, height: s.height, batch_size: 1 }, - class_type: 'EmptyLatentImage', - _meta: { title: 'Qwen edit canvas' } - } - graph['15'].inputs.latent_image = ['16', 0] - } 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 = s.aspect && s.aspect !== 'auto' ? 'qwen21 empty latent' : 'qwen21 edit latent' + r.sampleLatent = 'qwen21 edit latent' r.sampleDenoise = null r.heroReferenceAttached = false r.graphId = 'studio2_qwen21_edit.json' } else { + // Bench aspect is law for Generate. PE wh_ratio is advisory only (never sizes the canvas). 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) + // TextEncode conditioning only — EmptyLatentImage (node 16) owns canvas size, matching the official T2I template. + graph['9'].inputs.resolution = 1024 + graph['16'].inputs.width = size.width + graph['16'].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 = 'qwen21 textencode latent' + r.sampleLatent = 'qwen21 empty latent' r.sampleDenoise = null r.heroReferenceAttached = false r.graphId = 'studio2_qwen21_t2i.json' diff --git a/shared/studio2/contracts.mjs b/shared/studio2/contracts.mjs index 88327f0..54a29b0 100644 --- a/shared/studio2/contracts.mjs +++ b/shared/studio2/contracts.mjs @@ -1,6 +1,7 @@ import {normalizeStyles,stylePrompt} from './styles.mjs' import { queueSeeds } from './seed.mjs' import { sectionsFrom, compileSections } from './prompt.mjs' +import { QWEN21_ASPECT_IDS, resolveQwen21Size } from './qwen21-size.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 identityLock = ' / 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.' @@ -50,8 +51,10 @@ export function validateRequest(raw, xaigen = false) { 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 + const aspect = QWEN21_ASPECT_IDS.has(raw.settings?.aspect) ? raw.settings.aspect : 'auto' + const qwenSize = qwen && mode === 'generate' ? resolveQwen21Size(aspect) : null + const defaultWidth = qwenSize ? qwenSize.width : qwen ? 1024 : 1344 + const defaultHeight = qwenSize ? qwenSize.height : 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) @@ -62,10 +65,12 @@ 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)) const enhancePrompt = qwen && !video && !!raw.enhancePrompt + const width = qwenSize ? qwenSize.width : number('width',defaultWidth,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 || '', 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}), - 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:[]) }, + 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:[]) }, 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 da8a8dc..39c11fa 100644 --- a/shared/studio2/input.mjs +++ b/shared/studio2/input.mjs @@ -1,18 +1,22 @@ import {restoreLoras,loraUse} from './lora-memory.mjs' import { restoreInput, sectionsFrom } from './contracts.mjs' import { normalizeStyles } from './styles.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 resolveQwen21Size(aspect, source) { - 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]} - if (source?.width && source?.height) { - const ratio = source.width / source.height - const width = Math.max(32, Math.round(Math.sqrt(1024 * 1024 * ratio) / 32) * 32) - const height = Math.max(32, Math.round(Math.sqrt(1024 * 1024 / ratio) / 32) * 32) - return {width, height} - } - return {width:1024,height:1024} -} +import { QWEN21_ASPECT_IDS as ASPECT_IDS, resolveQwen21Size } from './qwen21-size.mjs' +export { resolveQwen21Size, QWEN21_SIZE_TABLE } from './qwen21-size.mjs' + +export const aspectOptions = [ + {id:'auto',label:'Automatic',hint:'Match start or hero still'}, + {id:'16:9',label:'16:9',hint:'Widescreen'}, + {id:'9:16',label:'9:16',hint:'Tall'}, + {id:'1:1',label:'1:1',hint:'Square'}, + {id:'4:3',label:'4:3',hint:'Standard'}, + {id:'3:4',label:'3:4',hint:'Portrait standard'}, + {id:'3:2',label:'3:2',hint:'Photo'}, + {id:'2:3',label:'2:3',hint:'Portrait photo'}, + {id:'21:9',label:'21:9',hint:'Ultrawide'}, + {id:'9:21',label:'9:21',hint:'Ultra-tall'} +] + export function resolveSize(aspect, source, fallback = {width:1344,height:768}, engine = '') { if (engine === 'qwen21') return resolveQwen21Size(aspect, source) const presets = {'16:9':[1344,768],'9:16':[768,1344],'1:1':[960,960]} @@ -21,12 +25,15 @@ export function resolveSize(aspect, source, fallback = {width:1344,height:768}, const factor = Math.min(1344/Math.max(source.width,source.height),768/Math.min(source.width,source.height)) return {width:Math.max(256,Math.round(source.width*factor/32)*32),height:Math.max(256,Math.round(source.height*factor/32)*32)} } + +export { ASPECT_IDS } + export function bindClip(form, clip, xaigen = false, memory) { if (!clip?.id) return form const saved = clip.studio2 || clip.job || {} const settings = saved.settings || clip.settings || {} const next = {...form, startClipId:clip.id, mode:'extend', settings:{...form.settings}} - next.settings.aspect=['auto','16:9','9:16','1:1'].includes(settings.aspect)?settings.aspect:(form.settings?.aspect || 'auto') + next.settings.aspect=ASPECT_IDS.has(settings.aspect)?settings.aspect:(form.settings?.aspect || 'auto') for (const key of ['duration','width','height','fps','loraStack','turbo','steps','cfg']) { if (settings[key] == null) continue try { next.settings[key] = typeof structuredClone === 'function' ? structuredClone(settings[key]) : JSON.parse(JSON.stringify(settings[key])) } @@ -96,7 +103,7 @@ export function hydrateStudio2Job(form, saved, { stillIds = new Set(), clipIds = if (['video','extend'].includes(mode)) engine = xaigen && engine === 'ltx' ? 'ltx' : 'minimax' else if (engine === 'minimax' || engine === 'ltx') engine = 'flux' const settings = structuredClone(src.settings || {}) - const aspect = ['auto','16:9','9:16','1:1'].includes(settings.aspect) ? settings.aspect : 'auto' + const aspect = ASPECT_IDS.has(settings.aspect) ? settings.aspect : 'auto' const loraStack = Array.isArray(settings.loraStack) ? settings.loraStack.map(item => ({ name: item.name, strengthModel: Number(item.strengthModel ?? item.strength ?? 1), diff --git a/shared/studio2/qwen21-size.mjs b/shared/studio2/qwen21-size.mjs new file mode 100644 index 0000000..6446f7d --- /dev/null +++ b/shared/studio2/qwen21-size.mjs @@ -0,0 +1,58 @@ +/** Qwen-safe ~1 MP grid (÷32). Do not use native 2K bins on 16 GB. */ +export const QWEN21_SIZE_TABLE = { + '1:1': [1024, 1024], + '16:9': [1536, 864], + '9:16': [864, 1536], + '4:3': [1152, 864], + '3:4': [864, 1152], + '3:2': [1248, 832], + '2:3': [832, 1248], + '21:9': [1536, 672], + '9:21': [672, 1536] +} + +export const QWEN21_ASPECT_IDS = new Set(['auto', ...Object.keys(QWEN21_SIZE_TABLE)]) + +function parseAspectRatio(aspect) { + const m = String(aspect || '').trim().match(/^(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)$/) + if (!m) return null + const w = Number(m[1]), h = Number(m[2]) + if (!(w > 0 && h > 0)) return null + return w / h +} + +function nearestQwen21Aspect(aspect) { + const target = parseAspectRatio(aspect) + if (target == null) return null + let best = '1:1', bestDiff = Infinity + for (const [key, [w, h]] of Object.entries(QWEN21_SIZE_TABLE)) { + const diff = Math.abs(w / h - target) + if (diff < bestDiff) { bestDiff = diff; best = key } + } + return best +} + +export function resolveQwen21Size(aspect, source) { + const key = String(aspect || 'auto').trim() + if (QWEN21_SIZE_TABLE[key]) { + const [width, height] = QWEN21_SIZE_TABLE[key] + return { width, height } + } + if (key === 'auto') { + if (source?.width && source?.height) { + const nearest = nearestQwen21Aspect(`${source.width}:${source.height}`) + if (nearest) { + const [width, height] = QWEN21_SIZE_TABLE[nearest] + return { width, height } + } + } + return { width: 1024, height: 1024 } + } + const nearest = nearestQwen21Aspect(key) + if (nearest) { + const [width, height] = QWEN21_SIZE_TABLE[nearest] + return { width, height } + } + console.warn(`[qwen21] unknown aspect "${key}"; using 1024×1024`) + return { width: 1024, height: 1024 } +} diff --git a/tests/studio2-bench.test.mjs b/tests/studio2-bench.test.mjs index ad4f9d4..4bb6691 100644 --- a/tests/studio2-bench.test.mjs +++ b/tests/studio2-bench.test.mjs @@ -7,6 +7,7 @@ import {useAsHero,useAsSource,restoreAll,hydrateStudio2Job} from '../shared/stud import {toggleStyle,parseBatch,stylePrompt} from '../shared/studio2/styles.mjs' import {compilePrompt,validateRequest} from '../shared/studio2/contracts.mjs' import {cachedLoras} from '../shared/studio2/lora-cache.mjs' +import {resolveQwen21Size} from '../shared/studio2/qwen21-size.mjs' test('deleted library files never return from job outputs; selection falls back or clears',()=>{ const deleted={id:'gone',folderId:'f'},keep={id:'keep',folderId:'f',createdAt:2};const jobs=[{request:{folderId:'f'},outputs:[deleted]}] @@ -82,6 +83,10 @@ 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.lockFace,false);assert.equal(q.lockOutfit,false);assert.deepEqual(q.settings.loraStack,[]) assert.equal(q.enhancePrompt,false) + 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) + 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) 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(validateRequest({mode:'generate',engine:'flux',folderId:'f',enhancePrompt:true,promptSections:{action:'x'},settings:{aspect:'1:1'}}).enhancePrompt,false) @@ -116,4 +121,9 @@ test('custom negative chips survive normalizeStyles and bench exposes Load actio assert.match(peEdit,/QwenImage21_EditPromptRewrite/);assert.match(peEdit,/pe_i2i\.int8_convrot/) assert.doesNotMatch(peT2i,/UnetLoader|KSampler|VAELoader/) assert.doesNotMatch(peEdit,/UnetLoader|KSampler|VAELoader/) + 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.deepEqual(resolveQwen21Size('16:9'),{width:1536,height:864}) + assert.deepEqual(resolveQwen21Size('1:1'),{width:1024,height:1024}) + assert.deepEqual(resolveQwen21Size('9:16'),{width:864,height:1536}) })