Negative prompt
diff --git a/docs/qwen21.md b/docs/qwen21.md
index 551d28f..2d0b9bd 100644
--- a/docs/qwen21.md
+++ b/docs/qwen21.md
@@ -39,6 +39,8 @@ Restart Comfy **only when idle** (`COMFY_CONTROL_URL/status` → `gpu.busy=false
- 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.
-- No hero / locks / Klein LoRA stack on this engine.
+- 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`).
-Graphs: `server/assets/studio2_qwen21_t2i.json`, `server/assets/studio2_qwen21_edit.json`.
+Graphs: `server/assets/studio2_qwen21_t2i.json`, `server/assets/studio2_qwen21_edit.json`, `server/assets/studio2_qwen21_pe_t2i.json`, `server/assets/studio2_qwen21_pe_edit.json`.
diff --git a/server/utils/studio2/runner.ts b/server/utils/studio2/runner.ts
index 57587e4..703bd11 100644
--- a/server/utils/studio2/runner.ts
+++ b/server/utils/studio2/runner.ts
@@ -9,7 +9,9 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from '
import { join } from 'node:path';
import template from '../../assets/studio2_minimax_native.json';
import qwen21Template from '../../assets/studio2_qwen21_t2i.json'
-import qwen21EditTemplate from '../../assets/studio2_qwen21_edit.json';
+import qwen21EditTemplate from '../../assets/studio2_qwen21_edit.json'
+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';
@@ -17,7 +19,7 @@ import { createJob, restoreJob, getJob, emitJob, type Job } from '../jobs';
import { markStudioLive, onLiveVideoSettled, type StudioJob } from '../studioQueue';
import { readRecord, saveRecord, studio2Root, records } from './store';
import { ensureComfyReady } from '../comfyLifecycle';
-import { comfyFetch, queuePrompt, fetchHistory, extractVideo } from '../comfy';
+import { comfyFetch, queuePrompt, fetchHistory, extractVideo, freeComfyVram } from '../comfy';
import { extractEditedImage } from '../imageComfy';
import { buildImageV2Workflow, resolveKreaGenerateAssets } from '../imageWorkflowV2';
import { resolveGraphLoraNames, applyUserLoraToGraph, ensureComfyLoraNames } from '../loras';
@@ -37,6 +39,107 @@ function update(r: any, state: string) { r.state = state; r.updatedAt = Date.now
// extend/video start frame (which is either the exact anchor PNG or an already-canvas-sized
// extracted last frame). Fitting these every hop is what destroys identity across chained extends.
const NEVER_FIT = new Set(['hero', 'start']);
+
+function previewAnyText(outputs: Record | undefined, nodeId: string): string {
+ const node = outputs?.[nodeId]
+ if (!node || typeof node !== 'object') return ''
+ const raw = node.text ?? node.STRING ?? node.source
+ if (Array.isArray(raw)) return String(raw[0] ?? '')
+ if (raw == null) return ''
+ return String(raw)
+}
+
+function harvestQwen21Pe(history: Record | null | undefined, promptId: string, edit: boolean) {
+ const outputs = history?.[promptId]?.outputs as Record | undefined
+ const positive_prompt = previewAnyText(outputs, '30').trim()
+ const wh_ratio = previewAnyText(outputs, '31').trim()
+ const ratio_follow = edit ? previewAnyText(outputs, '32').trim() : ''
+ const thinking = previewAnyText(outputs, edit ? '33' : '32').trim()
+ const parseRaw = previewAnyText(outputs, edit ? '34' : '33').trim()
+ const parse_ok = /^(true|1|yes)$/i.test(parseRaw)
+ return { positive_prompt, wh_ratio, ratio_follow, thinking, parse_ok }
+}
+
+async function waitPromptHistory(r: any, job: Job, promptId: string) {
+ let history: any = null, missingSince = 0
+ for (;;) {
+ try { history = await fetchHistory(promptId) }
+ catch { history = null }
+ const e = history?.[promptId]
+ if (job.status === 'cancelled') throw new Error('Cancelled')
+ if (['error', 'interrupted'].includes(e?.status?.status_str))
+ throw new Error('Comfy generation failed or was interrupted; inspect the host error log.')
+ if (!e) {
+ try {
+ const response = await comfyFetch('/queue', { signal: AbortSignal.timeout(5000) })
+ if (response.ok) {
+ const q = await response.json() as any
+ const present = [...(q.queue_running || []), ...(q.queue_pending || [])].some((entry: any) => entry[1] === promptId)
+ if (present) missingSince = 0
+ else if (!missingSince) missingSince = Date.now()
+ else if (Date.now() - missingSince > 60000) throw new Error('PROMPT_MISSING')
+ }
+ } catch (error: any) {
+ if (error.message === 'PROMPT_MISSING')
+ throw new Error('The saved Comfy prompt is no longer in history or the queue. It was not resubmitted.')
+ }
+ }
+ if (e?.status?.completed || e?.status?.status_str === 'success') break
+ if (job.status === 'cancelled') throw new Error('Cancelled')
+ await new Promise(resolve => setTimeout(resolve, 1500))
+ }
+ return history
+}
+
+async function runQwen21PromptEnhance(r: any, job: Job) {
+ const q = r.request, s = q.settings
+ if (!q.enhancePrompt || q.engine !== 'qwen21' || !['generate', 'edit'].includes(q.mode)) return
+ const edit = q.mode === 'edit'
+ if (edit && !q.imageAId) throw new Error('Choose the source still.')
+ update(r, 'enhancing')
+ emitJob(job, { type: 'status', message: 'Enhancing prompt…' })
+ let prompt = String(q.compiledPrompt || '')
+ if (edit && !//i.test(prompt))
+ prompt = `Keep the subject in . ${prompt}`.trim()
+ const graph = structuredClone(edit ? qwen21PeEditTemplate : qwen21PeT2iTemplate)
+ graph['9'].inputs.prompt = prompt
+ graph['9'].inputs.seed = s.seed
+ if (edit) {
+ const a = await upload(r, 'pe-source', readFileSync(stillPath(r.owner, getStill(r.owner, q.imageAId).id)))
+ graph['10'].inputs.image = a
+ graph['9'].inputs.image_1 = ['10', 0]
+ if (q.imageBId) {
+ const b = await upload(r, 'pe-compose', readFileSync(stillPath(r.owner, getStill(r.owner, q.imageBId).id)))
+ graph['11'] = {
+ inputs: { image: b },
+ class_type: 'LoadImage',
+ _meta: { title: 'image_2 optional' }
+ }
+ graph['9'].inputs.image_2 = ['11', 0]
+ }
+ }
+ r.peGraphId = edit ? 'studio2_qwen21_pe_edit.json' : 'studio2_qwen21_pe_t2i.json'
+ saveRecord(r)
+ const queued = await queuePrompt(graph, job.clientId)
+ r.pePromptId = queued.prompt_id
+ saveRecord(r)
+ const history = await waitPromptHistory(r, job, queued.prompt_id)
+ const result = harvestQwen21Pe(history, queued.prompt_id, edit)
+ if (!result.parse_ok || !result.positive_prompt)
+ throw new Error('Prompt enhance failed to parse a rewrite. Try again or turn Enhance prompt off.')
+ r.request.promptRaw = q.compiledPrompt
+ r.request.compiledPrompt = result.positive_prompt
+ r.request.enhance = {
+ wh_ratio: result.wh_ratio || '',
+ ...(edit ? { ratio_follow: result.ratio_follow || '' } : {}),
+ parse_ok: true,
+ thinking: result.thinking || ''
+ }
+ r.pePromptId = ''
+ saveRecord(r)
+ await freeComfyVram()
+}
+
async function upload(r: any, name: string, data: Buffer): Promise {
const prefix = String(useRuntimeConfig().comfyFilenamePrefix).replace(/\/$/, '') + `/studio2/${r.id}/${r.index}`;
let fitted = false;
@@ -234,56 +337,23 @@ async function run(r: any, job: Job) {
r.request.compiledPrompt = compilePrompt(prompts[r.index], ['video', 'extend'].includes(r.request.mode), r.request);
update(r, 'waking');
await ensureComfyReady(message => emitJob(job, { type: 'status', message }));
+ if (job.status === 'cancelled')
+ throw new Error('Cancelled');
+ await runQwen21PromptEnhance(r, job);
if (job.status === 'cancelled')
throw new Error('Cancelled');
const graph = await prepareGraph(r);
if (job.status === 'cancelled')
throw new Error('Cancelled');
update(r, 'submitting');
+ emitJob(job, { type: 'status', message: 'Generating…' });
const queued = await queuePrompt(graph, job.clientId);
job.promptId = queued.prompt_id;
r.promptId = queued.prompt_id;
r.renderStartedAt = Date.now();
update(r, 'rendering');
}
- let history: any, missingSince = 0;
- for (;;) {
- try {
- history = await fetchHistory(r.promptId);
- }
- catch {
- history = null;
- }
- const e = history?.[r.promptId];
- if (job.status === 'cancelled')
- throw new Error('Cancelled');
- if (['error', 'interrupted'].includes(e?.status?.status_str))
- throw new Error('Comfy generation failed or was interrupted; inspect the host error log.');
- if (!e) {
- try {
- const response = await comfyFetch('/queue', { signal: AbortSignal.timeout(5000) });
- if (response.ok) {
- const q = await response.json() as any;
- const present = [...(q.queue_running || []), ...(q.queue_pending || [])].some((entry: any) => entry[1] === r.promptId);
- if (present)
- missingSince = 0;
- else if (!missingSince)
- missingSince = Date.now();
- else if (Date.now() - missingSince > 60000)
- throw new Error('PROMPT_MISSING');
- }
- }
- catch (error: any) {
- if (error.message === 'PROMPT_MISSING')
- throw new Error('The saved Comfy prompt is no longer in history or the queue. It was not resubmitted.');
- }
- }
- if (e?.status?.completed || e?.status?.status_str === 'success')
- break;
- if (job.status === 'cancelled')
- throw new Error('Cancelled');
- await new Promise(resolve => setTimeout(resolve, 1500));
- }
+ const history = await waitPromptHistory(r, job, r.promptId);
const messages = history[r.promptId]?.status?.messages || [];
const executionStart = messages.find((m: any) => m[0] === 'execution_start')?.[1]?.timestamp;
const executionEnd = messages.find((m: any) => m[0] === 'execution_success')?.[1]?.timestamp;
diff --git a/shared/studio2/contracts.mjs b/shared/studio2/contracts.mjs
index b8e9544..88327f0 100644
--- a/shared/studio2/contracts.mjs
+++ b/shared/studio2/contracts.mjs
@@ -61,7 +61,8 @@ 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: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 || '',
+ const enhancePrompt = qwen && !video && !!raw.enhancePrompt
+ 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:[]) },
diff --git a/shared/studio2/input.mjs b/shared/studio2/input.mjs
index 59c0aa6..da8a8dc 100644
--- a/shared/studio2/input.mjs
+++ b/shared/studio2/input.mjs
@@ -110,6 +110,7 @@ export function hydrateStudio2Job(form, saved, { stillIds = new Set(), clipIds =
...form,
mode,
engine,
+ enhancePrompt: engine === 'qwen21' && !!src.enhancePrompt,
lockFace: src.lockFace !== false,
lockOutfit: src.lockOutfit !== false,
hideInput: !!src.hideInput,
diff --git a/shared/studio2/status.mjs b/shared/studio2/status.mjs
index 22cee85..dada742 100644
--- a/shared/studio2/status.mjs
+++ b/shared/studio2/status.mjs
@@ -8,8 +8,8 @@ export function gpuStatus(jobs, health, upscales=[], musicJobs=[]) {
return {label:music.status==='waiting'?'Queued':'Generating',tone:'busy',detail:String(stage)}
}
if(health.yue2?.busy)return {label:'Generating',tone:'busy',detail:'YuE2 is using the GPU'}
- const job=jobs.find(j=>['waking','submitting','rendering','saving'].includes(j.state))
- if(job)return {label:{waking:'Waking up',submitting:'Submitting',rendering:'Generating',saving:'Saving'}[job.state],tone:'busy',detail:job.request?.promptSections?.action || 'Generation in progress'}
+ const job=jobs.find(j=>['waking','enhancing','submitting','rendering','saving'].includes(j.state))
+ if(job)return {label:{waking:'Waking up',enhancing:'Enhancing prompt…',submitting:'Submitting',rendering:'Generating…',saving:'Saving'}[job.state],tone:'busy',detail:job.request?.promptSections?.action || 'Generation in progress'}
if(health.comfy?.busy || health.imageComfy?.busy)return {label:'Busy',tone:'busy',detail:'GPU is working on another job'}
if(health.comfy?.ok || health.imageComfy?.ok)return {label:'Ready',tone:'ready',detail:'No job running'}
if(health.comfy?.asleep)return {label:'Sleeping',tone:'sleeping',detail:'Wakes when a job starts'}
diff --git a/tests/studio2-bench.test.mjs b/tests/studio2-bench.test.mjs
index 07f3b55..ad4f9d4 100644
--- a/tests/studio2-bench.test.mjs
+++ b/tests/studio2-bench.test.mjs
@@ -81,6 +81,10 @@ test('qwen21 supports Generate and Edit with 25/1 defaults and no locks',()=>{
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.equal(q.enhancePrompt,false)
+ 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)
const edit=validateRequest({mode:'edit',engine:'qwen21',folderId:'f',imageAId:'s',promptSections:{action:'change the sky'},settings:{aspect:'auto'}})
assert.equal(edit.mode,'edit');assert.equal(edit.settings.steps,25);assert.equal(edit.settings.cfg,1)
assert.equal(edit.lockFace,false);assert.deepEqual(edit.settings.loraStack,[])
@@ -105,4 +109,11 @@ test('custom negative chips survive normalizeStyles and bench exposes Load actio
const src=readFileSync(new URL('../components/studio2/Bench.vue',import.meta.url),'utf8')
assert.match(src,/loadSelectedJob/);assert.match(src,/hydrateStudio2Job/);assert.match(src,/Edit source/)
assert.match(src,/aspect:'auto'/);assert.match(src,/strip-actions/);assert.match(src,/addNegative/)
+ assert.match(src,/Enhance prompt/);assert.match(src,/enhancePrompt/)
+ const peT2i=readFileSync(new URL('../server/assets/studio2_qwen21_pe_t2i.json',import.meta.url),'utf8')
+ const peEdit=readFileSync(new URL('../server/assets/studio2_qwen21_pe_edit.json',import.meta.url),'utf8')
+ assert.match(peT2i,/QwenImage21_T2IPromptRewrite/);assert.match(peT2i,/pe_t2i\.int8_convrot/)
+ assert.match(peEdit,/QwenImage21_EditPromptRewrite/);assert.match(peEdit,/pe_i2i\.int8_convrot/)
+ assert.doesNotMatch(peT2i,/UnetLoader|KSampler|VAELoader/)
+ assert.doesNotMatch(peEdit,/UnetLoader|KSampler|VAELoader/)
})