From 58c5458c87e70d9ada9b476a50e62418875f03a9 Mon Sep 17 00:00:00 2001 From: Towsty Date: Sat, 26 Sep 2026 15:14:20 -0500 Subject: [PATCH] Store the Edit TextEncode prompt on library stills, not a Start still caption. Edit + Enhance always leads with the keep/ stanza and typed change; PE describe dumps are dropped, and Typed stays promptRaw. Co-authored-by: Cursor --- components/studio2/Bench.vue | 3 +- components/studio2/Details.vue | 11 +-- docs/qwen21.md | 6 +- host/qwen21-pe-prompts/system_prompt_edit.txt | 4 ++ server/utils/library.ts | 8 ++- server/utils/studio2/runner.ts | 69 ++++++++++++++++--- shared/studio2/qwen21-edit.mjs | 61 +++++++++++----- tests/studio2-bench.test.mjs | 29 ++++++-- utils/libraryGeneration.ts | 32 +++++++-- 9 files changed, 177 insertions(+), 46 deletions(-) diff --git a/components/studio2/Bench.vue b/components/studio2/Bench.vue index 70bcc4c..e0fd8b6 100644 --- a/components/studio2/Bench.vue +++ b/components/studio2/Bench.vue @@ -656,9 +656,10 @@ function applyUseAsHero(){ uploadMessage.value=`Using ${assetLabel(selected.value)} as hero.` } async function copySelectedPrompt(){ - const text=selected.value?.studio2?.promptSections?.action + const text=selected.value?.studio2?.prompt || selected.value?.studio2?.compiledPrompt || selected.value?.prompt + || selected.value?.studio2?.promptSections?.action || '' try{ if(import.meta.client && navigator.clipboard)await navigator.clipboard.writeText(String(text||'')) diff --git a/components/studio2/Details.vue b/components/studio2/Details.vue index e0f425a..929872b 100644 --- a/components/studio2/Details.vue +++ b/components/studio2/Details.vue @@ -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:'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:'Compiled prompt',value:m.compiledPrompt}, - {label:'Prompt (raw)',value:m.promptRaw || null}, + {label:'Prompt',value:m.prompt || m.compiledPrompt}, + {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',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 skip reason',value:m.enhance?.skipReason || null}, + {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 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 LoRA',value:m.lora || null}, {label:'Turbo sigmas',value:m.sigmas || null}, diff --git a/docs/qwen21.md b/docs/qwen21.md index a096ac7..65fdf5b 100644 --- a/docs/qwen21.md +++ b/docs/qwen21.md @@ -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. - 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. - - Edit + Enhance → PE-I2I only (`pe_i2i`) with the same Start still on `image_1`. After PE, every Edit rewrite is forced to contain `` (PE used to ban tags for N=1). If the rewrite still drops every `` token, stitch `Keep the identity…` onto the rewrite before Edit. 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`). - - 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. + - 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 (`` 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 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). Edit prompt must lead with an operation and `` — 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`. - 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). diff --git a/host/qwen21-pe-prompts/system_prompt_edit.txt b/host/qwen21-pe-prompts/system_prompt_edit.txt index 65b9626..a8678d1 100644 --- a/host/qwen21-pe-prompts/system_prompt_edit.txt +++ b/host/qwen21-pe-prompts/system_prompt_edit.txt @@ -58,6 +58,10 @@ Before emitting JSON, reason through: what the image(s) actually contain (includ For every input, the rewritten instruction MUST use , , … to refer to each input image. Single-image edits still use . Never write "the photo", "the image", "the woman in the picture" as a substitute for the tag. The first Start still is always . +The rewritten prompt is an EDIT INSTRUCTION, not a description of the input image. +Lead with the operation. Mention 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. ## Output Size Determination diff --git a/server/utils/library.ts b/server/utils/library.ts index 817e718..2930663 100644 --- a/server/utils/library.ts +++ b/server/utils/library.ts @@ -108,6 +108,7 @@ export interface LibraryStill { role: StillRole name?: string prompt?: string + promptRaw?: string promptPre?: string promptPost?: string familyId?: string @@ -1333,6 +1334,7 @@ export async function saveStill(params: { role?: StillRole name?: string prompt?: string + promptRaw?: string promptPre?: string promptPost?: string familyId?: string @@ -1354,8 +1356,9 @@ export async function saveStill(params: { 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 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 - 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) => { const item = next.stills.find(still => still.id === existing.id) if (!item) return @@ -1363,6 +1366,7 @@ export async function saveStill(params: { item.role = role if (name) item.name = name 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.promptPost != null) item.promptPost = params.promptPost || undefined if (params.familyId) item.familyId = params.familyId @@ -1374,6 +1378,7 @@ export async function saveStill(params: { existing.role = role if (name) existing.name = name 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.promptPost != null) existing.promptPost = params.promptPost || undefined if (nextSettings) existing.settings = nextSettings @@ -1395,6 +1400,7 @@ export async function saveStill(params: { role, name: params.name?.trim().slice(0, 80) || undefined, prompt: params.prompt || undefined, + promptRaw: params.promptRaw || undefined, promptPre: params.promptPre || undefined, promptPost: params.promptPost || undefined, familyId: params.familyId, diff --git a/server/utils/studio2/runner.ts b/server/utils/studio2/runner.ts index 04a6547..dc61274 100644 --- a/server/utils/studio2/runner.ts +++ b/server/utils/studio2/runner.ts @@ -105,8 +105,11 @@ async function runQwen21PromptEnhance(r: any, job: Job) { throw new Error('Qwen Edit requires Start still (image_1). Hero is ignored.') update(r, 'enhancing') emitJob(job, { type: 'status', message: 'Enhancing prompt…' }) + // promptRaw = exactly what they typed (compiled from sections before PE). 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) graph['9'].inputs.prompt = peInput graph['9'].inputs.seed = s.seed @@ -137,12 +140,14 @@ async function runQwen21PromptEnhance(r: any, job: Job) { saveRecord(r) const history = await waitPromptHistory(r, job, queued.prompt_id) const result = harvestQwen21Pe(history, queued.prompt_id, edit) - r.request.promptRaw = userPrompt const refusal = qwen21PeRefusal(userPrompt, result) if (refusal.refused) { - // Fail open: keep the job, send the raw brief (Edit keep-identity prefix still applies). - r.request.compiledPrompt = edit ? ensureQwen21EditPrompt(userPrompt) : userPrompt + // Fail open: keep the job, send the raw brief (Edit keep-identity stanza still applies). + const compiled = edit ? ensureQwen21EditPrompt(userPrompt) : userPrompt + r.request.compiledPrompt = compiled + r.request.prompt = compiled r.request.enhance = { + used: false, wh_ratio: '', ...(edit ? { ratio_follow: '' } : {}), parse_ok: !!result.parse_ok, @@ -151,20 +156,35 @@ async function runQwen21PromptEnhance(r: any, job: Job) { thinking: result.thinking || '', comfyPromptId: queued.prompt_id, } - } else { - // PE-I2I: if rewrite dropped every , stitch keep-identity onto the rewrite. - r.request.compiledPrompt = edit - ? stitchQwen21PeEditPrompt(result.positive_prompt) - : result.positive_prompt + } else if (edit) { + // Edit only: stanza + raw first; PE chunk only if it is an edit directive (never PE-alone). + // Generate keeps the long observer paragraph as the product — no describe sanitize. + const stitched = stitchQwen21PeEditPrompt(userPrompt, result.positive_prompt) + r.request.compiledPrompt = stitched.prompt + r.request.prompt = stitched.prompt 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 || '', - ...(edit ? { ratio_follow: result.ratio_follow || '' } : {}), parse_ok: !!result.parse_ok, refused: false, thinking: result.thinking || '', 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%). r.pePromptId = '' r.promptId = '' @@ -287,7 +307,12 @@ async function prepareGraph(r: any) { // Start still → image_1 only. Never Hero. Never T2I / EmptyLatent. if (!q.imageAId || !a) 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 || '')) + q.compiledPrompt = prompt + q.prompt = prompt + q.task = 'edit' graph = structuredClone(turbo ? qwen21EditTurboTemplate : qwen21EditTemplate) graph['10'].inputs.image = a graph['9'].inputs['images.image_1'] = ['10', 0] @@ -325,6 +350,9 @@ async function prepareGraph(r: any) { r.heroReferenceAttached = false } else { // 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) s.width = size.width s.height = size.height @@ -462,7 +490,21 @@ async function run(r: any, job: Job) { } else if (!asset) { 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) throw new Error('Library did not save the output'); @@ -470,6 +512,11 @@ async function run(r: any, job: Job) { saveRecord(r); r.savedAt = Date.now(); 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 }; 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}); diff --git a/shared/studio2/qwen21-edit.mjs b/shared/studio2/qwen21-edit.mjs index dc3c30b..e9ef749 100644 --- a/shared/studio2/qwen21-edit.mjs +++ b/shared/studio2/qwen21-edit.mjs @@ -1,33 +1,60 @@ /** Qwen Edit / PE-I2I prompt helpers. Start still = ; Hero is never a slot. */ export const QWEN21_EDIT_KEEP = - 'Keep the identity, face, body, and pose of the person in .' + 'Keep the identity, face, body, pose, and likeness of the person in .\nDo not invent a different person.' export const QWEN21_EDIT_KEEP_CHANGE = - `${QWEN21_EDIT_KEEP} Apply only this change:` + `${QWEN21_EDIT_KEEP}\nApply only this change:` -/** User omitted — 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 . */ +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) { const text = String(prompt || '').trim() if (!text) return text - if (//i.test(text)) return text - return `${QWEN21_EDIT_KEEP_CHANGE} ${text}`.trim() + if (/Keep the identity, face, body, pose, and likeness of the person in /i.test(text)) return text + return buildQwen21EditKeepStanza(text) } -/** - * After PE-I2I: official PE told N=1 not to use tags, so rewrites often say - * "the photo" / "the image" with zero . Qwen Edit only binds Start still - * when the text contains the tag — force it onto every Edit rewrite. - */ -export function stitchQwen21PeEditPrompt(rewritten) { - let text = String(rewritten || '').trim() - if (!text) return text - // Prefer the tag over natural substitutes (do not invent ). - text = text +/** PE rewrite looks like a T2I observer caption, not an edit directive. */ +export function isQwen21PeEditDescribe(rewritten) { + const text = String(rewritten || '').trim() + if (!text) return true + if (DESCRIBE_LEAD.test(text)) return true + if (!EDIT_VERB.test(text)) return true + return false +} + +function retagNaturalImageRefs(text) { + return String(text || '') .replace(/\bthe woman in the (?:input )?(?:photo|picture|image)\b/gi, 'the person in ') .replace(/\bthe (?:person|man|subject) in the (?:input )?(?:photo|picture|image)\b/gi, 'the person in ') .replace(/\bin the (?:input )?(?:photo|picture|image)\b/gi, 'in ') .replace(/\bthe (?:input )?(?:photo|picture|image)\b/gi, '') - if (//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/ 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 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 } } diff --git a/tests/studio2-bench.test.mjs b/tests/studio2-bench.test.mjs index 1ebfe0d..af87419 100644 --- a/tests/studio2-bench.test.mjs +++ b/tests/studio2-bench.test.mjs @@ -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:'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('change outfit'),'change outfit') - assert.match(stitchQwen21PeEditPrompt('a woman in a red jacket'),//) - assert.equal(stitchQwen21PeEditPrompt('keep add jacket'),'keep add jacket') - assert.match(stitchQwen21PeEditPrompt('change the jacket on the woman in the photo'),//) - assert.doesNotMatch(stitchQwen21PeEditPrompt('change the jacket on the woman in the photo'),/\bthe photo\b/i) + assert.match(ensureQwen21EditPrompt('put her in a red leather jacket'),//) + assert.equal(ensureQwen21EditPrompt('Keep the identity, face, body, pose, and likeness of the person in .\nDo not invent a different person.\nApply only this change: x'),'Keep the identity, face, body, pose, and likeness of the person in .\nDo not invent a different person.\nApply only this change: x') + const stitchedKeep=stitchQwen21PeEditPrompt('put her in a red leather jacket','Change the outfit on the person in to a red leather jacket') + assert.match(stitchedKeep.prompt,/Keep the identity/) + assert.match(stitchedKeep.prompt,//) + 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,//) + 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 /) + 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_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 /) 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) @@ -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,/Qwen Edit uses Start still as/) 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 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/) @@ -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') assert.match(runner,/ensureQwen21EditPrompt/) assert.match(runner,/stitchQwen21PeEditPrompt/) + assert.match(runner,/skippedAsDescribe/) + assert.match(runner,/promptRaw/) assert.match(runner,/images\.image_1/) assert.match(runner,/comfyPromptId/) assert.match(runner,/generateComfyPromptId/) diff --git a/utils/libraryGeneration.ts b/utils/libraryGeneration.ts index 50ce0f7..a1574b6 100644 --- a/utils/libraryGeneration.ts +++ b/utils/libraryGeneration.ts @@ -41,14 +41,29 @@ export type StillGenerationSettings = { export type SettingsRow = { label: string; value: string } -function pushPromptRows(rows: SettingsRow[], item: { prompt?: string; promptPre?: string; promptPost?: string }) { - const parts = restorePromptParts(item.prompt || '', { +function pushPromptRows(rows: SettingsRow[], item: { + 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, - prompt: item.prompt, + prompt: sampled || item.prompt, post: item.promptPost }) 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 (typed && typed !== parts.prompt.trim()) rows.push({ label: 'Typed', value: typed }) 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 '' } -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 rows: SettingsRow[] = [] pushPromptRows(rows, still)