Size Qwen 2.1 Generate from the bench aspect table.
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 <cursoragent@cursor.com>
This commit is contained in:
+2
-1
@@ -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 `<image1>` / `<image2>`. If the user omits `<image1>`, the runner prepends `Keep the subject in <image1>.`
|
- Edit requires Start still → `images.image_1`; prompt slots use `<image1>` / `<image2>`. If the user omits `<image1>`, the runner prepends `Keep the subject in <image1>.`
|
||||||
- Face / outfit locks stay Klein semantics — they do not drive Qwen slots.
|
- 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**.
|
- 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.
|
- 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`).
|
- Custom node: `ComfyUI-Qwen-Image-2.1-Prompt-Enhancer` (`QwenImage21_T2IPromptRewrite` / `QwenImage21_EditPromptRewrite`).
|
||||||
|
|||||||
@@ -40,6 +40,15 @@
|
|||||||
"class_type": "TextEncodeQwenImage21",
|
"class_type": "TextEncodeQwenImage21",
|
||||||
"_meta": { "title": "Text Encode Qwen Image 2.1" }
|
"_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": {
|
"15": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"seed": 1,
|
"seed": 1,
|
||||||
@@ -51,7 +60,7 @@
|
|||||||
"model": ["3", 0],
|
"model": ["3", 0],
|
||||||
"positive": ["9", 0],
|
"positive": ["9", 0],
|
||||||
"negative": ["9", 1],
|
"negative": ["9", 1],
|
||||||
"latent_image": ["9", 2]
|
"latent_image": ["16", 0]
|
||||||
},
|
},
|
||||||
"class_type": "KSampler",
|
"class_type": "KSampler",
|
||||||
"_meta": { "title": "KSampler" }
|
"_meta": { "title": "KSampler" }
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ 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/input.mjs';
|
import { resolveQwen21Size } from '~/shared/studio2/qwen21-size.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';
|
||||||
@@ -252,41 +252,38 @@ async function prepareGraph(r: any) {
|
|||||||
}
|
}
|
||||||
graph['9'].inputs.prompt = prompt
|
graph['9'].inputs.prompt = prompt
|
||||||
graph['9'].inputs.negative_prompt = stylePrompt(q.imageStyles, true)
|
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
|
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.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
|
||||||
graph['15'].inputs.sampler_name = 'euler'
|
graph['15'].inputs.sampler_name = 'euler'
|
||||||
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 = s.aspect && s.aspect !== 'auto' ? 'qwen21 empty latent' : 'qwen21 edit latent'
|
r.sampleLatent = 'qwen21 edit latent'
|
||||||
r.sampleDenoise = null
|
r.sampleDenoise = null
|
||||||
r.heroReferenceAttached = false
|
r.heroReferenceAttached = false
|
||||||
r.graphId = 'studio2_qwen21_edit.json'
|
r.graphId = 'studio2_qwen21_edit.json'
|
||||||
} else {
|
} else {
|
||||||
|
// 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(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 = stylePrompt(q.imageStyles, true)
|
||||||
// TextEncodeQwenImage21 builds the 64-ch empty latent from resolution (square T2I).
|
// TextEncode conditioning only — EmptyLatentImage (node 16) owns canvas size, matching the official T2I template.
|
||||||
graph['9'].inputs.resolution = Math.max(size.width, size.height)
|
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.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
|
||||||
graph['15'].inputs.sampler_name = 'euler'
|
graph['15'].inputs.sampler_name = 'euler'
|
||||||
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 textencode latent'
|
r.sampleLatent = 'qwen21 empty latent'
|
||||||
r.sampleDenoise = null
|
r.sampleDenoise = null
|
||||||
r.heroReferenceAttached = false
|
r.heroReferenceAttached = false
|
||||||
r.graphId = 'studio2_qwen21_t2i.json'
|
r.graphId = 'studio2_qwen21_t2i.json'
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {normalizeStyles,stylePrompt} from './styles.mjs'
|
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'
|
||||||
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.'
|
||||||
@@ -50,8 +51,10 @@ export function validateRequest(raw, xaigen = false) {
|
|||||||
const qwen = engine === 'qwen21'
|
const qwen = engine === 'qwen21'
|
||||||
const defaultSteps = video ? 8 : qwen ? 25 : 24
|
const defaultSteps = video ? 8 : qwen ? 25 : 24
|
||||||
const defaultCfg = video ? 1.5 : qwen ? 1 : 4
|
const defaultCfg = video ? 1.5 : qwen ? 1 : 4
|
||||||
const defaultWidth = qwen ? 1024 : 1344
|
const aspect = QWEN21_ASPECT_IDS.has(raw.settings?.aspect) ? raw.settings.aspect : 'auto'
|
||||||
const defaultHeight = qwen ? 1024 : 768
|
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 ['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.')
|
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)
|
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 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 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 || '',
|
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 || ''},
|
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:['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} }),
|
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}}) }
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-14
@@ -1,18 +1,22 @@
|
|||||||
import {restoreLoras,loraUse} from './lora-memory.mjs'
|
import {restoreLoras,loraUse} from './lora-memory.mjs'
|
||||||
import { restoreInput, sectionsFrom } from './contracts.mjs'
|
import { restoreInput, sectionsFrom } from './contracts.mjs'
|
||||||
import { normalizeStyles } from './styles.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'}]
|
import { QWEN21_ASPECT_IDS as ASPECT_IDS, resolveQwen21Size } from './qwen21-size.mjs'
|
||||||
export function resolveQwen21Size(aspect, source) {
|
export { resolveQwen21Size, QWEN21_SIZE_TABLE } from './qwen21-size.mjs'
|
||||||
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]}
|
export const aspectOptions = [
|
||||||
if (source?.width && source?.height) {
|
{id:'auto',label:'Automatic',hint:'Match start or hero still'},
|
||||||
const ratio = source.width / source.height
|
{id:'16:9',label:'16:9',hint:'Widescreen'},
|
||||||
const width = Math.max(32, Math.round(Math.sqrt(1024 * 1024 * ratio) / 32) * 32)
|
{id:'9:16',label:'9:16',hint:'Tall'},
|
||||||
const height = Math.max(32, Math.round(Math.sqrt(1024 * 1024 / ratio) / 32) * 32)
|
{id:'1:1',label:'1:1',hint:'Square'},
|
||||||
return {width, height}
|
{id:'4:3',label:'4:3',hint:'Standard'},
|
||||||
}
|
{id:'3:4',label:'3:4',hint:'Portrait standard'},
|
||||||
return {width:1024,height:1024}
|
{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 = '') {
|
export function resolveSize(aspect, source, fallback = {width:1344,height:768}, engine = '') {
|
||||||
if (engine === 'qwen21') return resolveQwen21Size(aspect, source)
|
if (engine === 'qwen21') return resolveQwen21Size(aspect, source)
|
||||||
const presets = {'16:9':[1344,768],'9:16':[768,1344],'1:1':[960,960]}
|
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))
|
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)}
|
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) {
|
export function bindClip(form, clip, xaigen = false, memory) {
|
||||||
if (!clip?.id) return form
|
if (!clip?.id) return form
|
||||||
const saved = clip.studio2 || clip.job || {}
|
const saved = clip.studio2 || clip.job || {}
|
||||||
const settings = saved.settings || clip.settings || {}
|
const settings = saved.settings || clip.settings || {}
|
||||||
const next = {...form, startClipId:clip.id, mode:'extend', settings:{...form.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']) {
|
for (const key of ['duration','width','height','fps','loraStack','turbo','steps','cfg']) {
|
||||||
if (settings[key] == null) continue
|
if (settings[key] == null) continue
|
||||||
try { next.settings[key] = typeof structuredClone === 'function' ? structuredClone(settings[key]) : JSON.parse(JSON.stringify(settings[key])) }
|
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'
|
if (['video','extend'].includes(mode)) engine = xaigen && engine === 'ltx' ? 'ltx' : 'minimax'
|
||||||
else if (engine === 'minimax' || engine === 'ltx') engine = 'flux'
|
else if (engine === 'minimax' || engine === 'ltx') engine = 'flux'
|
||||||
const settings = structuredClone(src.settings || {})
|
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 => ({
|
const loraStack = Array.isArray(settings.loraStack) ? settings.loraStack.map(item => ({
|
||||||
name: item.name,
|
name: item.name,
|
||||||
strengthModel: Number(item.strengthModel ?? item.strength ?? 1),
|
strengthModel: Number(item.strengthModel ?? item.strength ?? 1),
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import {useAsHero,useAsSource,restoreAll,hydrateStudio2Job} from '../shared/stud
|
|||||||
import {toggleStyle,parseBatch,stylePrompt} from '../shared/studio2/styles.mjs'
|
import {toggleStyle,parseBatch,stylePrompt} from '../shared/studio2/styles.mjs'
|
||||||
import {compilePrompt,validateRequest} from '../shared/studio2/contracts.mjs'
|
import {compilePrompt,validateRequest} from '../shared/studio2/contracts.mjs'
|
||||||
import {cachedLoras} from '../shared/studio2/lora-cache.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',()=>{
|
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]}]
|
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.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)
|
||||||
|
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'}})
|
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,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.match(peEdit,/QwenImage21_EditPromptRewrite/);assert.match(peEdit,/pe_i2i\.int8_convrot/)
|
||||||
assert.doesNotMatch(peT2i,/UnetLoader|KSampler|VAELoader/)
|
assert.doesNotMatch(peT2i,/UnetLoader|KSampler|VAELoader/)
|
||||||
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')
|
||||||
|
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})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user