Store the Edit TextEncode prompt on library stills, not a Start still caption.

Edit + Enhance always leads with the keep/<image1> stanza and typed change; PE describe dumps are dropped, and Typed stays promptRaw.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-09-26 15:14:20 -05:00
co-authored by Cursor
parent c46e827c68
commit 58c5458c87
9 changed files with 177 additions and 46 deletions
+2 -1
View File
@@ -656,9 +656,10 @@ function applyUseAsHero(){
uploadMessage.value=`Using ${assetLabel(selected.value)} as hero.` uploadMessage.value=`Using ${assetLabel(selected.value)} as hero.`
} }
async function copySelectedPrompt(){ async function copySelectedPrompt(){
const text=selected.value?.studio2?.promptSections?.action const text=selected.value?.studio2?.prompt
|| selected.value?.studio2?.compiledPrompt || selected.value?.studio2?.compiledPrompt
|| selected.value?.prompt || selected.value?.prompt
|| selected.value?.studio2?.promptSections?.action
|| '' || ''
try{ try{
if(import.meta.client && navigator.clipboard)await navigator.clipboard.writeText(String(text||'')) if(import.meta.client && navigator.clipboard)await navigator.clipboard.writeText(String(text||''))
+7 -4
View File
@@ -19,11 +19,14 @@ const rows=computed(()=>{const m=props.metadata||{},s=m.settings||{};const time=
{label:'Start frame',value:m.startFrameSource?JSON.stringify(m.startFrameSource):'None'}, {label:'Start frame',value:m.startFrameSource?JSON.stringify(m.startFrameSource):'None'},
{label:'Handoff source',value:m.handoffSource || 'None'},{label:'Handoff fitted (resampled)',value:m.fitted==null?'Not recorded':m.fitted?'Yes':'No'}, {label:'Handoff source',value:m.handoffSource || 'None'},{label:'Handoff fitted (resampled)',value:m.fitted==null?'Not recorded':m.fitted?'Yes':'No'},
{label:'End still',value:m.endStillId||'None'},{label:'Guide stills',value:m.guides?.length?JSON.stringify(m.guides):'None'}, {label:'End still',value:m.endStillId||'None'},{label:'Guide stills',value:m.guides?.length?JSON.stringify(m.guides):'None'},
{label:'Compiled prompt',value:m.compiledPrompt}, {label:'Prompt',value:m.prompt || m.compiledPrompt},
{label:'Prompt (raw)',value:m.promptRaw || null}, {label:'Typed',value:m.promptRaw || null},
{label:'Task',value:m.task || m.mode || null},
{label:'Enhance prompt',value:m.enhancePrompt==null?null:m.enhancePrompt?'On':'Off'}, {label:'Enhance prompt',value:m.enhancePrompt==null?null:m.enhancePrompt?'On':'Off'},
{label:'Enhance',value:m.enhance?.refused?'Enhance skipped (model refused) — used your prompt.':m.enhance?.parse_ok==null?null:m.enhance.parse_ok?'Applied':'Parse incomplete'}, {label:'Enhance',value:m.enhance?.refused?'Enhance skipped (model refused) — used your prompt.':m.enhance?.skippedAsDescribe?'Enhance skipped (describe rewrite) — keep stanza + typed only.':m.enhance?.used?'Applied':m.enhance?.parse_ok==null?null:m.enhance.parse_ok?'Applied':'Parse incomplete'},
{label:'Enhance skip reason',value:m.enhance?.skipReason || null}, {label:'Enhance used',value:m.enhance?.used==null?null:m.enhance.used?'Yes':'No'},
{label:'Enhance refused',value:m.enhance?.refused==null?null:m.enhance.refused?'Yes':'No'},
{label:'Enhance skip reason',value:m.enhance?.skipReason || (m.enhance?.skippedAsDescribe?'skippedAsDescribe':null)},
{label:'Turbo',value:m.turbo==null?null:m.turbo?'On':'Off'}, {label:'Turbo',value:m.turbo==null?null:m.turbo?'On':'Off'},
{label:'Turbo LoRA',value:m.lora || null}, {label:'Turbo LoRA',value:m.lora || null},
{label:'Turbo sigmas',value:m.sigmas || null}, {label:'Turbo sigmas',value:m.sigmas || null},
+3 -3
View File
@@ -42,9 +42,9 @@ Restart Comfy **only when idle** (`COMFY_CONTROL_URL/status` → `gpu.busy=false
- Edit follows `image_1` via the encode node’s latent (resolution long-edge ~1024). Do not inject a picker EmptyLatentImage onto the edit sampler. - 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.
- Generate + Enhance → PE-T2I only (`pe_t2i`). No images. - Generate + Enhance → PE-T2I only (`pe_t2i`). No images.
- Edit + Enhance → PE-I2I only (`pe_i2i`) with the same Start still on `image_1`. After PE, every Edit rewrite is forced to contain `<image1>` (PE used to ban tags for N=1). If the rewrite still drops every `<imageN>` token, stitch `Keep the identity…` onto the rewrite before Edit. Never fall back to PE-T2I on Edit. - Edit + Enhance → PE-I2I only (`pe_i2i`) with the same Start still on `image_1`. After PE, the sample prompt is always the keep-identity stanza + typed instruction first (`<image1>` in front). A PE rewrite is appended only when it is an edit directive; T2I-style observer captions (`The image is…`) are dropped (`enhance.skippedAsDescribe`) and the stanza + raw remain. Never let the PE chunk be the entire prompt. Library stores `prompt` (TextEncode string) and `promptRaw` (Typed). Never fall back to PE-T2I on Edit.
- If PE refuses or returns an empty/gutted rewrite, the job continues with `promptRaw` (Edit still gets the keep-identity prefix). Details shows `Enhance skipped (model refused) — used your prompt.` (`enhance.refused`). - If PE refuses or returns an empty/gutted rewrite, the job continues with `promptRaw` (Edit still gets the keep-identity stanza). Details shows `Enhance skipped (model refused) — used your prompt.` (`enhance.refused`).
- Host PE system prompts live in `host/qwen21-pe-prompts/` and are copied onto the node pack by `scripts/setup-qwen21.ps1` (official steps + Adult appendix). Do not swap Heretic PE weights on 16 GB. - Host PE system prompts live in `host/qwen21-pe-prompts/` and are copied onto the node pack by `scripts/setup-qwen21.ps1` (official steps + Adult appendix). Edit prompt must lead with an operation and `<image1>` — never “The image is a photograph of…”. Do not swap Heretic PE weights on 16 GB.
- 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`.
- Optional **Turbo** (off by default): Viggle DMD LoRA `Qwen-Image-2.1-viggle-turbo-v0.2.1-6step-lora-r128.safetensors` on the same Q8 GGUF via `ViggleTurboLora` + `ViggleTurboSigmas` (`1.0, 0.9375, 0.875, 0.75, 0.5, 0.25`), 6 steps, CFG 1, empty negative. Not a new engine. Enhance prompt stays compatible and is recommended with Turbo. - Optional **Turbo** (off by default): Viggle DMD LoRA `Qwen-Image-2.1-viggle-turbo-v0.2.1-6step-lora-r128.safetensors` on the same Q8 GGUF via `ViggleTurboLora` + `ViggleTurboSigmas` (`1.0, 0.9375, 0.875, 0.75, 0.5, 0.25`), 6 steps, CFG 1, empty negative. Not a new engine. Enhance prompt stays compatible and is recommended with Turbo.
- Custom node: `ComfyUI-Viggle-Turbo` (`viggle_turbo.py`). Do not merge the LoRA with stock `LoraLoaderModelOnly` (lossy on int8/bf16). - Custom node: `ComfyUI-Viggle-Turbo` (`viggle_turbo.py`). Do not merge the LoRA with stock `LoraLoaderModelOnly` (lossy on int8/bf16).
@@ -58,6 +58,10 @@ Before emitting JSON, reason through: what the image(s) actually contain (includ
For every input, the rewritten instruction MUST use <image1>, <image2>, … to refer to each input image. Single-image edits still use <image1>. Never write "the photo", "the image", "the woman in the picture" as a substitute for the tag. The first Start still is always <image1>. For every input, the rewritten instruction MUST use <image1>, <image2>, … to refer to each input image. Single-image edits still use <image1>. Never write "the photo", "the image", "the woman in the picture" as a substitute for the tag. The first Start still is always <image1>.
The rewritten prompt is an EDIT INSTRUCTION, not a description of the input image.
Lead with the operation. Mention <image1> in the first sentence.
Do not write "The image is a photograph of…". That format is for text-to-image only.
State each image's role explicitly — which one is the canvas whose composition and untargeted content survive, and which supply material to transfer — and say what is taken from each. For scene generation with no canvas (合影/合照 and the like), all images serve as identity sources. Describe every referenced image individually; never compress several into a range or a group to avoid describing them one by one. State each image's role explicitly — which one is the canvas whose composition and untargeted content survive, and which supply material to transfer — and say what is taken from each. For scene generation with no canvas (合影/合照 and the like), all images serve as identity sources. Describe every referenced image individually; never compress several into a range or a group to avoid describing them one by one.
## Output Size Determination ## Output Size Determination
+7 -1
View File
@@ -108,6 +108,7 @@ export interface LibraryStill {
role: StillRole role: StillRole
name?: string name?: string
prompt?: string prompt?: string
promptRaw?: string
promptPre?: string promptPre?: string
promptPost?: string promptPost?: string
familyId?: string familyId?: string
@@ -1333,6 +1334,7 @@ export async function saveStill(params: {
role?: StillRole role?: StillRole
name?: string name?: string
prompt?: string prompt?: string
promptRaw?: string
promptPre?: string promptPre?: string
promptPost?: string promptPost?: string
familyId?: string familyId?: string
@@ -1354,8 +1356,9 @@ export async function saveStill(params: {
const hideInput = typeof params.hideInput === 'boolean' ? params.hideInput === true : existing.hideInput const hideInput = typeof params.hideInput === 'boolean' ? params.hideInput === true : existing.hideInput
const name = typeof params.name === 'string' ? params.name.trim().slice(0, 80) : existing.name const name = typeof params.name === 'string' ? params.name.trim().slice(0, 80) : existing.name
const prompt = typeof params.prompt === 'string' ? params.prompt : existing.prompt const prompt = typeof params.prompt === 'string' ? params.prompt : existing.prompt
const promptRaw = typeof params.promptRaw === 'string' ? params.promptRaw : existing.promptRaw
const nextSettings = params.settings ? normalizeStillSettings(params.settings) : undefined const nextSettings = params.settings ? normalizeStillSettings(params.settings) : undefined
if (existing.hideInput !== hideInput || existing.name !== name || existing.prompt !== prompt || nextSettings) { if (existing.hideInput !== hideInput || existing.name !== name || existing.prompt !== prompt || existing.promptRaw !== promptRaw || nextSettings) {
await mutate(params.ownerKey, (next) => { await mutate(params.ownerKey, (next) => {
const item = next.stills.find(still => still.id === existing.id) const item = next.stills.find(still => still.id === existing.id)
if (!item) return if (!item) return
@@ -1363,6 +1366,7 @@ export async function saveStill(params: {
item.role = role item.role = role
if (name) item.name = name if (name) item.name = name
if (prompt) item.prompt = prompt if (prompt) item.prompt = prompt
if (params.promptRaw != null) item.promptRaw = params.promptRaw || undefined
if (params.promptPre != null) item.promptPre = params.promptPre || undefined if (params.promptPre != null) item.promptPre = params.promptPre || undefined
if (params.promptPost != null) item.promptPost = params.promptPost || undefined if (params.promptPost != null) item.promptPost = params.promptPost || undefined
if (params.familyId) item.familyId = params.familyId if (params.familyId) item.familyId = params.familyId
@@ -1374,6 +1378,7 @@ export async function saveStill(params: {
existing.role = role existing.role = role
if (name) existing.name = name if (name) existing.name = name
if (prompt) existing.prompt = prompt if (prompt) existing.prompt = prompt
if (params.promptRaw != null) existing.promptRaw = params.promptRaw || undefined
if (params.promptPre != null) existing.promptPre = params.promptPre || undefined if (params.promptPre != null) existing.promptPre = params.promptPre || undefined
if (params.promptPost != null) existing.promptPost = params.promptPost || undefined if (params.promptPost != null) existing.promptPost = params.promptPost || undefined
if (nextSettings) existing.settings = nextSettings if (nextSettings) existing.settings = nextSettings
@@ -1395,6 +1400,7 @@ export async function saveStill(params: {
role, role,
name: params.name?.trim().slice(0, 80) || undefined, name: params.name?.trim().slice(0, 80) || undefined,
prompt: params.prompt || undefined, prompt: params.prompt || undefined,
promptRaw: params.promptRaw || undefined,
promptPre: params.promptPre || undefined, promptPre: params.promptPre || undefined,
promptPost: params.promptPost || undefined, promptPost: params.promptPost || undefined,
familyId: params.familyId, familyId: params.familyId,
+58 -11
View File
@@ -105,8 +105,11 @@ async function runQwen21PromptEnhance(r: any, job: Job) {
throw new Error('Qwen Edit requires Start still (image_1). Hero is ignored.') throw new Error('Qwen Edit requires Start still (image_1). Hero is ignored.')
update(r, 'enhancing') update(r, 'enhancing')
emitJob(job, { type: 'status', message: 'Enhancing prompt…' }) emitJob(job, { type: 'status', message: 'Enhancing prompt…' })
// promptRaw = exactly what they typed (compiled from sections before PE).
const userPrompt = String(q.compiledPrompt || '') const userPrompt = String(q.compiledPrompt || '')
const peInput = edit ? ensureQwen21EditPrompt(userPrompt) : userPrompt r.request.promptRaw = userPrompt
// Edit PE gets the raw edit instruction + Start still — not the keep stanza.
const peInput = userPrompt
const graph = structuredClone(edit ? qwen21PeEditTemplate : qwen21PeT2iTemplate) const graph = structuredClone(edit ? qwen21PeEditTemplate : qwen21PeT2iTemplate)
graph['9'].inputs.prompt = peInput graph['9'].inputs.prompt = peInput
graph['9'].inputs.seed = s.seed graph['9'].inputs.seed = s.seed
@@ -137,12 +140,14 @@ async function runQwen21PromptEnhance(r: any, job: Job) {
saveRecord(r) saveRecord(r)
const history = await waitPromptHistory(r, job, queued.prompt_id) const history = await waitPromptHistory(r, job, queued.prompt_id)
const result = harvestQwen21Pe(history, queued.prompt_id, edit) const result = harvestQwen21Pe(history, queued.prompt_id, edit)
r.request.promptRaw = userPrompt
const refusal = qwen21PeRefusal(userPrompt, result) const refusal = qwen21PeRefusal(userPrompt, result)
if (refusal.refused) { if (refusal.refused) {
// Fail open: keep the job, send the raw brief (Edit keep-identity prefix still applies). // Fail open: keep the job, send the raw brief (Edit keep-identity stanza still applies).
r.request.compiledPrompt = edit ? ensureQwen21EditPrompt(userPrompt) : userPrompt const compiled = edit ? ensureQwen21EditPrompt(userPrompt) : userPrompt
r.request.compiledPrompt = compiled
r.request.prompt = compiled
r.request.enhance = { r.request.enhance = {
used: false,
wh_ratio: '', wh_ratio: '',
...(edit ? { ratio_follow: '' } : {}), ...(edit ? { ratio_follow: '' } : {}),
parse_ok: !!result.parse_ok, parse_ok: !!result.parse_ok,
@@ -151,20 +156,35 @@ async function runQwen21PromptEnhance(r: any, job: Job) {
thinking: result.thinking || '', thinking: result.thinking || '',
comfyPromptId: queued.prompt_id, comfyPromptId: queued.prompt_id,
} }
} else { } else if (edit) {
// PE-I2I: if rewrite dropped every <imageN>, stitch keep-identity onto the rewrite. // Edit only: stanza + raw first; PE chunk only if it is an edit directive (never PE-alone).
r.request.compiledPrompt = edit // Generate keeps the long observer paragraph as the product — no describe sanitize.
? stitchQwen21PeEditPrompt(result.positive_prompt) const stitched = stitchQwen21PeEditPrompt(userPrompt, result.positive_prompt)
: result.positive_prompt r.request.compiledPrompt = stitched.prompt
r.request.prompt = stitched.prompt
r.request.enhance = { r.request.enhance = {
used: !stitched.skippedAsDescribe,
wh_ratio: result.wh_ratio || '',
ratio_follow: result.ratio_follow || '',
parse_ok: !!result.parse_ok,
refused: false,
...(stitched.skippedAsDescribe ? { skippedAsDescribe: true } : {}),
thinking: result.thinking || '',
comfyPromptId: queued.prompt_id,
}
} else {
r.request.compiledPrompt = result.positive_prompt
r.request.prompt = result.positive_prompt
r.request.enhance = {
used: true,
wh_ratio: result.wh_ratio || '', wh_ratio: result.wh_ratio || '',
...(edit ? { ratio_follow: result.ratio_follow || '' } : {}),
parse_ok: !!result.parse_ok, parse_ok: !!result.parse_ok,
refused: false, refused: false,
thinking: result.thinking || '', thinking: result.thinking || '',
comfyPromptId: queued.prompt_id, comfyPromptId: queued.prompt_id,
} }
} }
if (edit || q.mode === 'generate') r.request.task = q.mode
// Stage flip PE → generate: clear PE prompt id and reset the bar (no leftover 100%). // Stage flip PE → generate: clear PE prompt id and reset the bar (no leftover 100%).
r.pePromptId = '' r.pePromptId = ''
r.promptId = '' r.promptId = ''
@@ -287,7 +307,12 @@ async function prepareGraph(r: any) {
// Start still → image_1 only. Never Hero. Never T2I / EmptyLatent. // Start still → image_1 only. Never Hero. Never T2I / EmptyLatent.
if (!q.imageAId || !a) if (!q.imageAId || !a)
throw new Error('Qwen Edit requires Start still (image_1). Hero is ignored.') throw new Error('Qwen Edit requires Start still (image_1). Hero is ignored.')
// promptRaw stays typed; prompt/compiledPrompt = what TextEncode samples.
if (q.promptRaw == null) q.promptRaw = String(q.compiledPrompt || '')
const prompt = ensureQwen21EditPrompt(String(q.compiledPrompt || '')) const prompt = ensureQwen21EditPrompt(String(q.compiledPrompt || ''))
q.compiledPrompt = prompt
q.prompt = prompt
q.task = 'edit'
graph = structuredClone(turbo ? qwen21EditTurboTemplate : qwen21EditTemplate) graph = structuredClone(turbo ? qwen21EditTurboTemplate : qwen21EditTemplate)
graph['10'].inputs.image = a graph['10'].inputs.image = a
graph['9'].inputs['images.image_1'] = ['10', 0] graph['9'].inputs['images.image_1'] = ['10', 0]
@@ -325,6 +350,9 @@ async function prepareGraph(r: any) {
r.heroReferenceAttached = false r.heroReferenceAttached = false
} else { } else {
// Bench aspect is law for Generate. PE wh_ratio is advisory only (never sizes the canvas). // Bench aspect is law for Generate. PE wh_ratio is advisory only (never sizes the canvas).
if (q.promptRaw == null) q.promptRaw = String(q.compiledPrompt || '')
q.prompt = String(q.compiledPrompt || '')
q.task = 'generate'
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
@@ -462,7 +490,21 @@ async function run(r: any, job: Job) {
} }
else if (!asset) { else if (!asset) {
const data = await downloadComfyImage(file), size = imageDimensions(data) || { width: s.width, height: s.height }; const data = await downloadComfyImage(file), size = imageDimensions(data) || { width: s.width, height: s.height };
asset = await saveStill({ ownerKey: r.owner, folderId: q.folderId, filename: file.filename, data, ...size, role: 'output', prompt: q.compiledPrompt, familyId: r.familyId, chainIndex: r.index, settings: s }); // prompt = TextEncode string (after PE + stitch); promptRaw = typed brief.
const samplePrompt = String(q.prompt || q.compiledPrompt || '')
asset = await saveStill({
ownerKey: r.owner,
folderId: q.folderId,
filename: file.filename,
data,
...size,
role: 'output',
prompt: samplePrompt,
promptRaw: q.promptRaw != null ? String(q.promptRaw) : undefined,
familyId: r.familyId,
chainIndex: r.index,
settings: s,
});
} }
if (!asset) if (!asset)
throw new Error('Library did not save the output'); throw new Error('Library did not save the output');
@@ -470,6 +512,11 @@ async function run(r: any, job: Job) {
saveRecord(r); saveRecord(r);
r.savedAt = Date.now(); r.savedAt = Date.now();
r.wallTime = (r.savedAt - r.startedAt) / 1000; r.wallTime = (r.savedAt - r.startedAt) / 1000;
if (q.engine === 'qwen21' && ['generate', 'edit'].includes(q.mode)) {
q.task = q.mode
q.prompt = String(q.prompt || q.compiledPrompt || '')
if (q.promptRaw == null) q.promptRaw = String(q.compiledPrompt || '')
}
const metadata = { ...structuredClone(q), id: r.id, sourceStillId:r.sourceStillId,heroReferenceAttached:r.heroReferenceAttached,sampleLatent:r.sampleLatent,sampleDenoise:r.sampleDenoise,kind: video ? 'video' : 'image', graphId: r.graphId, promptId: r.promptId, queuedAt: r.queuedAt, startedAt: r.startedAt, savedAt: r.savedAt, gpuSeconds: r.gpuSeconds, wallTime: r.wallTime, outputWidth: asset.width || s.width, outputHeight: asset.height || s.height, purgeResult: 'Left on host', handoffSource: r.handoffSource || null, fitted: video && q.startClipId ? !!r.handoffFitted : null }; const metadata = { ...structuredClone(q), id: r.id, sourceStillId:r.sourceStillId,heroReferenceAttached:r.heroReferenceAttached,sampleLatent:r.sampleLatent,sampleDenoise:r.sampleDenoise,kind: video ? 'video' : 'image', graphId: r.graphId, promptId: r.promptId, queuedAt: r.queuedAt, startedAt: r.startedAt, savedAt: r.savedAt, gpuSeconds: r.gpuSeconds, wallTime: r.wallTime, outputWidth: asset.width || s.width, outputHeight: asset.height || s.height, purgeResult: 'Left on host', handoffSource: r.handoffSource || null, fitted: video && q.startClipId ? !!r.handoffFitted : null };
await attachStudio2Metadata(r.owner, asset.id, metadata); await attachStudio2Metadata(r.owner, asset.id, metadata);
if (!r.outputs.some((o:any)=>o.id===asset.id)) r.outputs.push({id:asset.id,kind:video?'clip':'still',studio2:metadata}); if (!r.outputs.some((o:any)=>o.id===asset.id)) r.outputs.push({id:asset.id,kind:video?'clip':'still',studio2:metadata});
+44 -17
View File
@@ -1,33 +1,60 @@
/** Qwen Edit / PE-I2I prompt helpers. Start still = <image1>; Hero is never a slot. */ /** Qwen Edit / PE-I2I prompt helpers. Start still = <image1>; Hero is never a slot. */
export const QWEN21_EDIT_KEEP = export const QWEN21_EDIT_KEEP =
'Keep the identity, face, body, and pose of the person in <image1>.' 'Keep the identity, face, body, pose, and likeness of the person in <image1>.\nDo not invent a different person.'
export const QWEN21_EDIT_KEEP_CHANGE = export const QWEN21_EDIT_KEEP_CHANGE =
`${QWEN21_EDIT_KEEP} Apply only this change:` `${QWEN21_EDIT_KEEP}\nApply only this change:`
/** User omitted <image1> — prepend the keep-identity instruction once. */ const EDIT_VERB =
/\b(?:change|replace|put|swap|keep|remove|add|edit|make|turn|convert|transform|modify|update|set|apply|wear|place|swap|switch)\b/i
const DESCRIBE_LEAD =
/^(?:the image is\b|this is a photograph of\b|a vertical photorealistic\b|a photorealistic\b|this (?:image|photo|picture) (?:shows|depicts|features)\b|the (?:photo|picture) (?:shows|depicts|features)\b)/i
/** Fixed identity stanza + the user's raw edit instruction. Always includes <image1>. */
export function buildQwen21EditKeepStanza(rawInstruction) {
const raw = String(rawInstruction || '').trim()
if (!raw) return QWEN21_EDIT_KEEP.trim()
return `${QWEN21_EDIT_KEEP_CHANGE} ${raw}`.trim()
}
/** Enhance off / refusal: ensure the sample prompt is the keep stanza, never PE-only. */
export function ensureQwen21EditPrompt(prompt) { export function ensureQwen21EditPrompt(prompt) {
const text = String(prompt || '').trim() const text = String(prompt || '').trim()
if (!text) return text if (!text) return text
if (/<image1>/i.test(text)) return text if (/Keep the identity, face, body, pose, and likeness of the person in <image1>/i.test(text)) return text
return `${QWEN21_EDIT_KEEP_CHANGE} ${text}`.trim() return buildQwen21EditKeepStanza(text)
} }
/** /** PE rewrite looks like a T2I observer caption, not an edit directive. */
* After PE-I2I: official PE told N=1 not to use tags, so rewrites often say export function isQwen21PeEditDescribe(rewritten) {
* "the photo" / "the image" with zero <image1>. Qwen Edit only binds Start still const text = String(rewritten || '').trim()
* when the text contains the tag — force it onto every Edit rewrite. if (!text) return true
*/ if (DESCRIBE_LEAD.test(text)) return true
export function stitchQwen21PeEditPrompt(rewritten) { if (!EDIT_VERB.test(text)) return true
let text = String(rewritten || '').trim() return false
if (!text) return text }
// Prefer the tag over natural substitutes (do not invent <image2>).
text = text function retagNaturalImageRefs(text) {
return String(text || '')
.replace(/\bthe woman in the (?:input )?(?:photo|picture|image)\b/gi, 'the person in <image1>') .replace(/\bthe woman in the (?:input )?(?:photo|picture|image)\b/gi, 'the person in <image1>')
.replace(/\bthe (?:person|man|subject) in the (?:input )?(?:photo|picture|image)\b/gi, 'the person in <image1>') .replace(/\bthe (?:person|man|subject) in the (?:input )?(?:photo|picture|image)\b/gi, 'the person in <image1>')
.replace(/\bin the (?:input )?(?:photo|picture|image)\b/gi, 'in <image1>') .replace(/\bin the (?:input )?(?:photo|picture|image)\b/gi, 'in <image1>')
.replace(/\bthe (?:input )?(?:photo|picture|image)\b/gi, '<image1>') .replace(/\bthe (?:input )?(?:photo|picture|image)\b/gi, '<image1>')
if (/<image1>/i.test(text)) return text }
return `${QWEN21_EDIT_KEEP} ${text}`.trim()
/**
* After PE-I2I: never let the PE chunk be the entire prompt.
* Always lead with the keep/<image1> stanza + raw instruction; append PE only when it is an edit directive.
*/
export function stitchQwen21PeEditPrompt(rawInstruction, rewritten) {
const stanza = buildQwen21EditKeepStanza(rawInstruction)
let pe = retagNaturalImageRefs(String(rewritten || '').trim()).trim()
if (!pe) return { prompt: stanza, skippedAsDescribe: false }
if (isQwen21PeEditDescribe(pe)) {
return { prompt: stanza, skippedAsDescribe: true }
}
// No <image1> in the PE chunk is fine — stanza already has the tag; keep PE as extra detail.
return { prompt: `${stanza}\n\n${pe}`.trim(), skippedAsDescribe: false }
} }
+24 -5
View File
@@ -105,13 +105,27 @@ test('qwen21 supports Generate and Edit with 25/1 defaults and no locks',()=>{
assert.throws(()=>validateRequest({mode:'compose',engine:'qwen21',folderId:'f',imageAId:'s',imageBId:'b',promptSections:{action:'x'}}),/Generate and Edit/) assert.throws(()=>validateRequest({mode:'compose',engine:'qwen21',folderId:'f',imageAId:'s',imageBId:'b',promptSections:{action:'x'}}),/Generate and Edit/)
assert.throws(()=>validateRequest({mode:'iterate',engine:'qwen21',folderId:'f',imageAId:'s',promptSections:{action:'x'}}),/Generate and Edit/) assert.throws(()=>validateRequest({mode:'iterate',engine:'qwen21',folderId:'f',imageAId:'s',promptSections:{action:'x'}}),/Generate and Edit/)
assert.equal(ensureQwen21EditPrompt('put her in a red leather jacket'),`${QWEN21_EDIT_KEEP_CHANGE} put her in a red leather jacket`) assert.equal(ensureQwen21EditPrompt('put her in a red leather jacket'),`${QWEN21_EDIT_KEEP_CHANGE} put her in a red leather jacket`)
assert.equal(ensureQwen21EditPrompt('change <image1> outfit'),'change <image1> outfit') assert.match(ensureQwen21EditPrompt('put her in a red leather jacket'),/<image1>/)
assert.match(stitchQwen21PeEditPrompt('a woman in a red jacket'),/<image1>/) assert.equal(ensureQwen21EditPrompt('Keep the identity, face, body, pose, and likeness of the person in <image1>.\nDo not invent a different person.\nApply only this change: x'),'Keep the identity, face, body, pose, and likeness of the person in <image1>.\nDo not invent a different person.\nApply only this change: x')
assert.equal(stitchQwen21PeEditPrompt('keep <image1> add jacket'),'keep <image1> add jacket') const stitchedKeep=stitchQwen21PeEditPrompt('put her in a red leather jacket','Change the outfit on the person in <image1> to a red leather jacket')
assert.match(stitchQwen21PeEditPrompt('change the jacket on the woman in the photo'),/<image1>/) assert.match(stitchedKeep.prompt,/Keep the identity/)
assert.doesNotMatch(stitchQwen21PeEditPrompt('change the jacket on the woman in the photo'),/\bthe photo\b/i) assert.match(stitchedKeep.prompt,/<image1>/)
assert.match(stitchedKeep.prompt,/red leather jacket/)
assert.equal(stitchedKeep.skippedAsDescribe,false)
assert.match(stitchedKeep.prompt,/Change the outfit/)
const stitchedDescribe=stitchQwen21PeEditPrompt('put her in a red leather jacket','The image is a photograph of a woman standing outdoors in soft light…')
assert.equal(stitchedDescribe.skippedAsDescribe,true)
assert.match(stitchedDescribe.prompt,/Keep the identity/)
assert.match(stitchedDescribe.prompt,/put her in a red leather jacket/)
assert.doesNotMatch(stitchedDescribe.prompt,/The image is a photograph/)
const stitchedNoTag=stitchQwen21PeEditPrompt('put her in a red leather jacket','Change the jacket on the woman in the photo to red leather')
assert.equal(stitchedNoTag.skippedAsDescribe,false)
assert.match(stitchedNoTag.prompt,/<image1>/)
assert.doesNotMatch(stitchedNoTag.prompt,/\bthe photo\b/i)
assert.match(readFileSync(new URL('../host/qwen21-pe-prompts/system_prompt_edit.txt',import.meta.url),'utf8'),/Single-image edits still use <image1>/) assert.match(readFileSync(new URL('../host/qwen21-pe-prompts/system_prompt_edit.txt',import.meta.url),'utf8'),/Single-image edits still use <image1>/)
assert.match(readFileSync(new URL('../host/qwen21-pe-prompts/system_prompt_edit.txt',import.meta.url),'utf8'),/EDIT INSTRUCTION/)
assert.doesNotMatch(readFileSync(new URL('../host/qwen21-pe-prompts/system_prompt_edit.txt',import.meta.url),'utf8'),/do NOT use tags/) assert.doesNotMatch(readFileSync(new URL('../host/qwen21-pe-prompts/system_prompt_edit.txt',import.meta.url),'utf8'),/do NOT use tags/)
assert.doesNotMatch(readFileSync(new URL('../host/qwen21-pe-prompts/system_prompt_t2i.txt',import.meta.url),'utf8'),/EDIT INSTRUCTION/)
assert.doesNotMatch(readFileSync(new URL('../host/qwen21-pe-prompts/system_prompt_t2i.txt',import.meta.url),'utf8'),/Single-image edits still use <image1>/) assert.doesNotMatch(readFileSync(new URL('../host/qwen21-pe-prompts/system_prompt_t2i.txt',import.meta.url),'utf8'),/Single-image edits still use <image1>/)
assert.equal(qwen21PeRefusal('corgi in the rain',{positive_prompt:'A photorealistic photograph of a corgi standing in rain…',parse_ok:true}).refused,false) assert.equal(qwen21PeRefusal('corgi in the rain',{positive_prompt:'A photorealistic photograph of a corgi standing in rain…',parse_ok:true}).refused,false)
assert.equal(qwen21PeRefusal('nude adult portrait',{positive_prompt:"I cannot assist with that request.",thinking:'',parse_ok:true}).refused,true) assert.equal(qwen21PeRefusal('nude adult portrait',{positive_prompt:"I cannot assist with that request.",thinking:'',parse_ok:true}).refused,true)
@@ -144,6 +158,9 @@ test('custom negative chips survive normalizeStyles and bench exposes Load actio
assert.match(src,/\bTurbo\b/);assert.match(src,/form\.turbo/) assert.match(src,/\bTurbo\b/);assert.match(src,/form\.turbo/)
assert.match(src,/Qwen Edit uses Start still as/) assert.match(src,/Qwen Edit uses Start still as/)
assert.match(src,/Hero is Klein-only and is ignored/) assert.match(src,/Hero is Klein-only and is ignored/)
assert.match(readFileSync(new URL('../components/studio2/Details.vue',import.meta.url),'utf8'),/\bTyped\b/)
assert.match(readFileSync(new URL('../utils/libraryGeneration.ts',import.meta.url),'utf8'),/\bTyped\b/)
assert.match(readFileSync(new URL('../utils/libraryGeneration.ts',import.meta.url),'utf8'),/promptRaw/)
const peT2i=readFileSync(new URL('../server/assets/studio2_qwen21_pe_t2i.json',import.meta.url),'utf8') 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') 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(peT2i,/QwenImage21_T2IPromptRewrite/);assert.match(peT2i,/pe_t2i\.int8_convrot/)
@@ -158,6 +175,8 @@ test('custom negative chips survive normalizeStyles and bench exposes Load actio
const runner=readFileSync(new URL('../server/utils/studio2/runner.ts',import.meta.url),'utf8') const runner=readFileSync(new URL('../server/utils/studio2/runner.ts',import.meta.url),'utf8')
assert.match(runner,/ensureQwen21EditPrompt/) assert.match(runner,/ensureQwen21EditPrompt/)
assert.match(runner,/stitchQwen21PeEditPrompt/) assert.match(runner,/stitchQwen21PeEditPrompt/)
assert.match(runner,/skippedAsDescribe/)
assert.match(runner,/promptRaw/)
assert.match(runner,/images\.image_1/) assert.match(runner,/images\.image_1/)
assert.match(runner,/comfyPromptId/) assert.match(runner,/comfyPromptId/)
assert.match(runner,/generateComfyPromptId/) assert.match(runner,/generateComfyPromptId/)
+28 -4
View File
@@ -41,14 +41,29 @@ export type StillGenerationSettings = {
export type SettingsRow = { label: string; value: string } export type SettingsRow = { label: string; value: string }
function pushPromptRows(rows: SettingsRow[], item: { prompt?: string; promptPre?: string; promptPost?: string }) { function pushPromptRows(rows: SettingsRow[], item: {
const parts = restorePromptParts(item.prompt || '', { prompt?: string
promptRaw?: string
promptPre?: string
promptPost?: string
studio2?: { prompt?: string; promptRaw?: string; compiledPrompt?: string }
}) {
// Sampled TextEncode string (after PE + stitch). Never a captioner dump of the PNG.
const sampled = String(
item.prompt
|| item.studio2?.prompt
|| item.studio2?.compiledPrompt
|| ''
).trim()
const typed = String(item.promptRaw || item.studio2?.promptRaw || '').trim()
const parts = restorePromptParts(sampled || item.prompt || '', {
pre: item.promptPre, pre: item.promptPre,
prompt: item.prompt, prompt: sampled || item.prompt,
post: item.promptPost post: item.promptPost
}) })
if (parts.pre.trim()) rows.push({ label: 'Pre-text', value: parts.pre.trim() }) if (parts.pre.trim()) rows.push({ label: 'Pre-text', value: parts.pre.trim() })
if (parts.prompt.trim()) rows.push({ label: 'Prompt', value: parts.prompt.trim() }) if (parts.prompt.trim()) rows.push({ label: 'Prompt', value: parts.prompt.trim() })
if (typed && typed !== parts.prompt.trim()) rows.push({ label: 'Typed', value: typed })
if (parts.post.trim()) rows.push({ label: 'Post-text', value: parts.post.trim() }) if (parts.post.trim()) rows.push({ label: 'Post-text', value: parts.post.trim() })
} }
@@ -172,7 +187,16 @@ export function stillModeLabel(still: { filename?: string; settings?: StillGener
return '' return ''
} }
export function stillSettingsRows(still: { prompt?: string; promptPre?: string; promptPost?: string; width?: number; height?: number; settings?: StillGenerationSettings | null }) { export function stillSettingsRows(still: {
prompt?: string
promptRaw?: string
promptPre?: string
promptPost?: string
studio2?: { prompt?: string; promptRaw?: string; compiledPrompt?: string }
width?: number
height?: number
settings?: StillGenerationSettings | null
}) {
const settings = still.settings const settings = still.settings
const rows: SettingsRow[] = [] const rows: SettingsRow[] = []
pushPromptRows(rows, still) pushPromptRows(rows, still)