Keep Enhance + Generate as one queue row with shared progress.

The PE stage now stays on the same jobId so the header and queue badge agree, and jobs behind it do not promote when enhance finishes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-09-26 14:42:22 -05:00
co-authored by Cursor
parent 30be4bd5b0
commit 99b61e67d7
9 changed files with 129 additions and 8 deletions
+31
View File
@@ -590,6 +590,37 @@
background: var(--muted); background: var(--muted);
} }
.job-status[data-state='enhancing'] .dot,
.job-status[data-state='waking'] .dot,
.job-status[data-state='submitting'] .dot,
.job-status[data-state='rendering'] .dot,
.job-status[data-state='saving'] .dot {
background: var(--amber);
}
.job-progress {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.job-progress-bar {
flex: 1;
height: 4px;
accent-color: var(--amber);
}
.job-progress[data-indeterminate='true'] .job-progress-bar {
opacity: 0.85;
}
.job-elapsed {
flex-shrink: 0;
color: var(--muted);
font-size: 11px;
}
.kebab { .kebab {
border: 0; border: 0;
background: transparent; background: transparent;
+15 -2
View File
@@ -302,6 +302,14 @@
<span class="left"><span class="dot" aria-hidden="true" />{{ card.statusLabel }}</span> <span class="left"><span class="dot" aria-hidden="true" />{{ card.statusLabel }}</span>
<button v-if="card.cancelable" type="button" class="kebab" aria-label="Cancel job" @click.stop="cancel(card.job)">⋮</button> <button v-if="card.cancelable" type="button" class="kebab" aria-label="Cancel job" @click.stop="cancel(card.job)">⋮</button>
</div> </div>
<div v-if="card.progress" class="job-progress" :data-indeterminate="card.progress.indeterminate ? 'true' : undefined">
<progress
class="job-progress-bar"
max="100"
:value="card.progress.indeterminate ? undefined : card.progress.percent"
/>
<span v-if="card.elapsed != null" class="job-elapsed">{{ card.elapsed }}</span>
</div>
</div> </div>
<p v-if="!queueCards.length" class="px-1 text-[var(--type-sm)] text-[var(--muted)]">No active jobs</p> <p v-if="!queueCards.length" class="px-1 text-[var(--type-sm)] text-[var(--muted)]">No active jobs</p>
</div> </div>
@@ -374,7 +382,7 @@ import {filterLorasForStudio,filterLorasForUse} from '~/utils/loras'
import {sectionsFrom} from '~/shared/studio2/contracts.mjs' import {sectionsFrom} from '~/shared/studio2/contracts.mjs'
import {aspectOptions,resolveSize,bindClip,restoreAll,useAsHero,useAsSource,hydrateStudio2Job} from '~/shared/studio2/input.mjs' import {aspectOptions,resolveSize,bindClip,restoreAll,useAsHero,useAsSource,hydrateStudio2Job} from '~/shared/studio2/input.mjs'
import {folderAssets,newestOutput,assetLabel,reconcileSelection,previewHidden} from '~/shared/studio2/viewer.mjs' import {folderAssets,newestOutput,assetLabel,reconcileSelection,previewHidden} from '~/shared/studio2/viewer.mjs'
import {queueJobs} from '~/shared/studio2/queue-view.mjs' import {queueJobs,jobStageLabel,jobProgressView,elapsedSeconds} from '~/shared/studio2/queue-view.mjs'
import {gpuStatus} from '~/shared/studio2/status.mjs' import {gpuStatus} from '~/shared/studio2/status.mjs'
import {applyUploadedStill} from '~/shared/studio2/upload.mjs' import {applyUploadedStill} from '~/shared/studio2/upload.mjs'
import {defaultNegatives,parseBatch} from '~/shared/studio2/styles.mjs' import {defaultNegatives,parseBatch} from '~/shared/studio2/styles.mjs'
@@ -534,6 +542,7 @@ const slotPairHint=computed(()=>{
}) })
const queueCards=computed(()=>{ const queueCards=computed(()=>{
const cards:any[]=[] const cards:any[]=[]
const now=Date.now()
for(const job of benchJobs.value){ for(const job of benchJobs.value){
const action=job.request?.promptSections?.action || 'Job' const action=job.request?.promptSections?.action || 'Job'
const eng=job.request?.engine==='ltx'?'LTX':job.request?.engine==='krea'?'Krea 2':job.request?.engine==='qwen21'?'Qwen 2.1':job.request?.engine==='minimax'?'MiniMax H3':'Klein' const eng=job.request?.engine==='ltx'?'LTX':job.request?.engine==='krea'?'Krea 2':job.request?.engine==='qwen21'?'Qwen 2.1':job.request?.engine==='minimax'?'MiniMax H3':'Klein'
@@ -541,6 +550,8 @@ const queueCards=computed(()=>{
const modeLabel=mode.charAt(0).toUpperCase()+mode.slice(1) const modeLabel=mode.charAt(0).toUpperCase()+mode.slice(1)
const aspect=job.request?.settings?.aspect || 'auto' const aspect=job.request?.settings?.aspect || 'auto'
const state=job.state==='waiting'?'queued':job.state const state=job.state==='waiting'?'queued':job.state
const secs=elapsedSeconds(job,now)
const progress=jobProgressView(job)
cards.push({ cards.push({
id:job.id, id:job.id,
job, job,
@@ -548,7 +559,9 @@ const queueCards=computed(()=>{
title:action.slice(0,72), title:action.slice(0,72),
meta:`${modeLabel} · ${eng} · ${aspect}`, meta:`${modeLabel} · ${eng} · ${aspect}`,
state, state,
statusLabel:state==='queued'?'Queued':state==='rendering'||state==='submitting'||state==='waking'||state==='saving'?'Running':state==='complete'?'Ready':state, statusLabel:jobStageLabel(state),
progress,
elapsed:secs>0?`${Math.floor(secs/60)}m ${secs%60}s`:'',
cancelable:!['complete','failed','cancelled'].includes(job.state) cancelable:!['complete','failed','cancelled'].includes(job.state)
}) })
} }
+7 -1
View File
@@ -2,7 +2,8 @@
<div class="space-y-2 rounded-xl border border-white/10 p-3 text-sm" aria-live="polite"> <div class="space-y-2 rounded-xl border border-white/10 p-3 text-sm" aria-live="polite">
<div class="flex justify-between gap-3"><button class="text-left" @click="$emit('select')"><strong>{{ job.request.promptSections.action.slice(0,70) }}</strong><span class="block capitalize text-amber-200">{{ job.state==='waiting'?'queued':job.state }} · Shot {{ shot }}/{{ total }}</span></button><button v-if="!terminal" @click="$emit('cancel')">Cancel</button></div> <div class="flex justify-between gap-3"><button class="text-left" @click="$emit('select')"><strong>{{ job.request.promptSections.action.slice(0,70) }}</strong><span class="block capitalize text-amber-200">{{ job.state==='waiting'?'queued':job.state }} · Shot {{ shot }}/{{ total }}</span></button><button v-if="!terminal" @click="$emit('cancel')">Cancel</button></div>
<p>{{ elapsed }} {{ job.startedAt?'elapsed':'waiting' }} · {{ job.request.settings.duration }}s target<span v-if="job.state==='rendering' && job.progress"> · Step {{ job.progress.step }}/{{ job.progress.maxStep }} ({{ job.progress.percent }}%)</span></p> <p>{{ elapsed }} {{ job.startedAt?'elapsed':'waiting' }} · {{ job.request.settings.duration }}s target<span v-if="job.state==='rendering' && job.progress"> · Step {{ job.progress.step }}/{{ job.progress.maxStep }} ({{ job.progress.percent }}%)</span></p>
<progress v-if="job.state==='rendering'" class="h-2 w-full accent-amber-300" :value="job.progress?.percent" max="100" /> <progress v-if="job.state==='enhancing'" class="h-2 w-full accent-amber-300" max="100" :value="enhancePercent" />
<progress v-else-if="job.state==='rendering'" class="h-2 w-full accent-amber-300" :value="job.progress?.percent" max="100" />
<p v-if="job.error" role="alert" class="text-red-300">{{ job.error }}</p> <p v-if="job.error" role="alert" class="text-red-300">{{ job.error }}</p>
</div> </div>
</template> </template>
@@ -13,4 +14,9 @@ const terminal=computed(()=>['complete','failed','cancelled'].includes(props.job
const total=computed(()=>props.job.prompts?.length || (props.job.request.batch?.length || 0)+1) const total=computed(()=>props.job.prompts?.length || (props.job.request.batch?.length || 0)+1)
const shot=computed(()=>Math.min(total.value,(props.job.index || 0)+1)) const shot=computed(()=>Math.min(total.value,(props.job.index || 0)+1))
const elapsed=computed(()=>{const seconds=elapsedSeconds(props.job,props.now);return `${Math.floor(seconds/60)}m ${seconds%60}s`}) const elapsed=computed(()=>{const seconds=elapsedSeconds(props.job,props.now);return `${Math.floor(seconds/60)}m ${seconds%60}s`})
const enhancePercent=computed(()=>{
const p=props.job.progress
if(p && Number(p.maxStep)>0) return Number(p.percent)||Math.round(Number(p.step)/Number(p.maxStep)*100)
return undefined // indeterminate while PE runs without Comfy step events
})
</script> </script>
+10
View File
@@ -270,6 +270,14 @@
<span>● {{ card.statusLabel }}</span> <span>● {{ card.statusLabel }}</span>
<button v-if="card.cancelable" type="button" @click.stop="ctx.cancel(card.job)">Cancel</button> <button v-if="card.cancelable" type="button" @click.stop="ctx.cancel(card.job)">Cancel</button>
</div> </div>
<div v-if="card.progress" class="m-job-progress" :data-indeterminate="card.progress.indeterminate ? 'true' : undefined">
<progress
class="m-job-progress-bar"
max="100"
:value="card.progress.indeterminate ? undefined : card.progress.percent"
/>
<span v-if="card.elapsed" class="m-muted">{{ card.elapsed }}</span>
</div>
</div> </div>
<p v-if="!ctx.queueCards.length" class="m-muted">No active jobs</p> <p v-if="!ctx.queueCards.length" class="m-muted">No active jobs</p>
<button v-if="ctx.canClearFinished" type="button" class="m-link" @click="ctx.clearFinished">Clear finished</button> <button v-if="ctx.canClearFinished" type="button" class="m-link" @click="ctx.clearFinished">Clear finished</button>
@@ -581,5 +589,7 @@ watch(() => props.ctx.form.mode, () => { sheet.value = '' })
.m-job-id { color: var(--muted); font-size: 12px; } .m-job-id { color: var(--muted); font-size: 12px; }
.m-job-title { font-weight: 600; } .m-job-title { font-weight: 600; }
.m-job-status { display: flex; justify-content: space-between; margin-top: 6px; font-size: 12px; } .m-job-status { display: flex; justify-content: space-between; margin-top: 6px; font-size: 12px; }
.m-job-progress { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
.m-job-progress-bar { flex: 1; height: 4px; accent-color: var(--amber); }
.m-link { border: 0; background: transparent; color: var(--amber); text-align: left; } .m-link { border: 0; background: transparent; color: var(--amber); text-align: left; }
</style> </style>
+16 -1
View File
@@ -125,9 +125,14 @@ async function runQwen21PromptEnhance(r: any, job: Job) {
} }
} }
r.peGraphId = edit ? 'studio2_qwen21_pe_edit.json' : 'studio2_qwen21_pe_t2i.json' r.peGraphId = edit ? 'studio2_qwen21_pe_edit.json' : 'studio2_qwen21_pe_t2i.json'
// PE is stage 1 of the same studio job — not a complete/requeue.
r.progress = null
saveRecord(r) saveRecord(r)
const queued = await queuePrompt(graph, job.clientId) const queued = await queuePrompt(graph, job.clientId)
r.pePromptId = queued.prompt_id r.pePromptId = queued.prompt_id
// watchProgress listens on promptId; point it at the PE Comfy prompt for this stage.
r.promptId = queued.prompt_id
job.promptId = queued.prompt_id
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)
@@ -142,11 +147,19 @@ async function runQwen21PromptEnhance(r: any, job: Job) {
wh_ratio: result.wh_ratio || '', wh_ratio: result.wh_ratio || '',
...(edit ? { ratio_follow: result.ratio_follow || '' } : {}), ...(edit ? { ratio_follow: result.ratio_follow || '' } : {}),
parse_ok: true, parse_ok: true,
thinking: result.thinking || '' thinking: result.thinking || '',
comfyPromptId: queued.prompt_id,
} }
// Stage flip PE → generate: clear PE prompt id and reset the bar (no leftover 100%).
r.pePromptId = '' r.pePromptId = ''
r.promptId = ''
job.promptId = ''
r.progress = null
saveRecord(r) saveRecord(r)
await freeComfyVram() await freeComfyVram()
// Same job continues into generate — do not complete or requeue.
update(r, 'submitting')
emitJob(job, { type: 'status', message: 'Generating…' })
} }
async function upload(r: any, name: string, data: Buffer): Promise<string> { async function upload(r: any, name: string, data: Buffer): Promise<string> {
@@ -394,6 +407,8 @@ async function run(r: any, job: Job) {
} }
job.promptId = queued.prompt_id; job.promptId = queued.prompt_id;
r.promptId = queued.prompt_id; r.promptId = queued.prompt_id;
r.generateComfyPromptId = queued.prompt_id;
r.progress = null;
r.renderStartedAt = Date.now(); r.renderStartedAt = Date.now();
update(r, 'rendering'); update(r, 'rendering');
} }
+30 -1
View File
@@ -1,5 +1,6 @@
const terminal = new Set(['complete','failed','cancelled']) const terminal = new Set(['complete','failed','cancelled'])
const running = new Set(['waking','submitting','rendering','saving']) // Include enhancing so PE is the same queue row as generate (not a vanished "Queue 0").
const running = new Set(['waking','enhancing','submitting','rendering','saving'])
export function queueJobs(jobs) { export function queueJobs(jobs) {
return jobs.filter(j=>running.has(j.state)||['waiting','queued','held'].includes(j.state)).sort((a,b)=>Number(running.has(b.state))-Number(running.has(a.state)) || (a.queuedAt||0)-(b.queuedAt||0)) return jobs.filter(j=>running.has(j.state)||['waiting','queued','held'].includes(j.state)).sort((a,b)=>Number(running.has(b.state))-Number(running.has(a.state)) || (a.queuedAt||0)-(b.queuedAt||0))
} }
@@ -14,3 +15,31 @@ export function elapsedSeconds(job, now) {
const end=terminal.has(job.state)?job.finishedAt || job.savedAt || job.updatedAt || start:now const end=terminal.has(job.state)?job.finishedAt || job.savedAt || job.updatedAt || start:now
return Number.isFinite(start)&&Number.isFinite(end)?Math.max(0,Math.floor((end-start)/1000)):0 return Number.isFinite(start)&&Number.isFinite(end)?Math.max(0,Math.floor((end-start)/1000)):0
} }
/** Stage label shown on the queue row (header pill uses status.mjs). */
export function jobStageLabel(state) {
const labels={
waiting:'Queued',queued:'Queued',held:'Held',
waking:'Waking up',enhancing:'Enhancing prompt…',
submitting:'Generating…',rendering:'Generating…',saving:'Saving',
complete:'Ready',failed:'Failed',cancelled:'Cancelled',
}
return labels[state] || state
}
/** Compact progress for the queue row. Enhancing prefers indeterminate over a fake 0%. */
export function jobProgressView(job) {
const state=job?.state
if(!['enhancing','submitting','rendering','waking','saving'].includes(state)) return null
const p=job?.progress
if(state==='enhancing'){
if(p && Number(p.maxStep)>0 && Number(p.step)>=0)
return {indeterminate:false,percent:Math.max(0,Math.min(100,Number(p.percent)||Math.round(Number(p.step)/Number(p.maxStep)*100)))}
return {indeterminate:true,percent:null}
}
if(state==='rendering' && p && Number(p.maxStep)>0)
return {indeterminate:false,percent:Math.max(0,Math.min(100,Number(p.percent)||Math.round(Number(p.step)/Number(p.maxStep)*100)))}
// waking / submitting / saving / rendering without steps: no fake bar
if(state==='submitting' || state==='waking') return {indeterminate:true,percent:null}
return null
}
+1 -1
View File
@@ -7,7 +7,7 @@ export function newestOutput(jobs, seen, folderId) {
return jobs.filter(j=>j.request.folderId===folderId).flatMap(j=>j.outputs || []).filter(a=>!seen.has(a.id)).sort((a,b)=>(a.studio2?.savedAt || 0)-(b.studio2?.savedAt || 0)).at(-1) || null return jobs.filter(j=>j.request.folderId===folderId).flatMap(j=>j.outputs || []).filter(a=>!seen.has(a.id)).sort((a,b)=>(a.studio2?.savedAt || 0)-(b.studio2?.savedAt || 0)).at(-1) || null
} }
export function reconcileSelection(selected, assets) { return assets.find(a=>a.id===selected?.id) || assets.at(-1) || null } export function reconcileSelection(selected, assets) { return assets.find(a=>a.id===selected?.id) || assets.at(-1) || null }
export function activeJobs(jobs) { return jobs.filter(j=>['waking','submitting','rendering','saving'].includes(j.state)) } export function activeJobs(jobs) { return jobs.filter(j=>['waking','enhancing','submitting','rendering','saving'].includes(j.state)) }
export function previewHidden(asset, revealed, xaigen) { export function previewHidden(asset, revealed, xaigen) {
if (!xaigen || !asset?.id) return false if (!xaigen || !asset?.id) return false
return !revealed?.has?.(asset.id) return !revealed?.has?.(asset.id)
+3 -1
View File
@@ -15,7 +15,7 @@ test('deleted library files never return from job outputs; selection falls back
let assets=folderAssets({stills:[keep],clips:[]},jobs,'f');assert.deepEqual(assets.map(a=>a.id),['keep']);assert.equal(reconcileSelection(deleted,assets).id,'keep');assert.equal(reconcileSelection(deleted,[]),null) let assets=folderAssets({stills:[keep],clips:[]},jobs,'f');assert.deepEqual(assets.map(a=>a.id),['keep']);assert.equal(reconcileSelection(deleted,assets).id,'keep');assert.equal(reconcileSelection(deleted,[]),null)
}) })
test('bench includes only active execution phases and clear finished retains internal cleanup records',()=>{ test('bench includes only active execution phases and clear finished retains internal cleanup records',()=>{
assert.deepEqual(activeJobs(['waiting','waking','submitting','rendering','saving','complete','cancelled','failed'].map(state=>({state}))).map(j=>j.state),['waking','submitting','rendering','saving']) assert.deepEqual(activeJobs(['waiting','waking','enhancing','submitting','rendering','saving','complete','cancelled','failed'].map(state=>({state}))).map(j=>j.state),['waking','enhancing','submitting','rendering','saving'])
const rows=[{id:'done',state:'complete',request:{folderId:'f'},cleanupPending:[{}]},{id:'active',state:'rendering',request:{folderId:'f'}}],saved=[] const rows=[{id:'done',state:'complete',request:{folderId:'f'},cleanupPending:[{}]},{id:'active',state:'rendering',request:{folderId:'f'}}],saved=[]
const src=readFileSync(new URL('../server/api/studio-2/clear-finished.post.ts',import.meta.url),'utf8').replace(/^import .*;\r?\n/gm,'').replace('export default ','const handler=') const src=readFileSync(new URL('../server/api/studio-2/clear-finished.post.ts',import.meta.url),'utf8').replace(/^import .*;\r?\n/gm,'').replace('export default ','const handler=')
const js=ts.transpileModule(src,{compilerOptions:{target:ts.ScriptTarget.ES2022}}).outputText const js=ts.transpileModule(src,{compilerOptions:{target:ts.ScriptTarget.ES2022}}).outputText
@@ -144,6 +144,8 @@ test('custom negative chips survive normalizeStyles and bench exposes Load actio
assert.match(runner,/ensureQwen21EditPrompt/) assert.match(runner,/ensureQwen21EditPrompt/)
assert.match(runner,/stitchQwen21PeEditPrompt/) assert.match(runner,/stitchQwen21PeEditPrompt/)
assert.match(runner,/images\.image_1/) assert.match(runner,/images\.image_1/)
assert.match(runner,/comfyPromptId/)
assert.match(runner,/generateComfyPromptId/)
assert.doesNotMatch(runner,/Keep the subject in <image1>/) assert.doesNotMatch(runner,/Keep the subject in <image1>/)
const t2i=readFileSync(new URL('../server/assets/studio2_qwen21_t2i.json',import.meta.url),'utf8') 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.match(t2i,/EmptyLatentImage/);assert.match(t2i,/"latent_image":\s*\["16",\s*0\]/)
+16 -1
View File
@@ -1,11 +1,26 @@
import test from 'node:test' import test from 'node:test'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import {queueJobs,jobView,elapsedSeconds} from '../shared/studio2/queue-view.mjs' import {queueJobs,jobView,elapsedSeconds,jobStageLabel,jobProgressView} from '../shared/studio2/queue-view.mjs'
test('queue shows every waiting job in order alongside the running job',()=>{ test('queue shows every waiting job in order alongside the running job',()=>{
const jobs=[{id:'late',state:'waiting',queuedAt:30},{id:'done',state:'complete',queuedAt:1},{id:'early',state:'waiting',queuedAt:10},{id:'active',state:'rendering',queuedAt:20},{id:'held',state:'held',queuedAt:40}] const jobs=[{id:'late',state:'waiting',queuedAt:30},{id:'done',state:'complete',queuedAt:1},{id:'early',state:'waiting',queuedAt:10},{id:'active',state:'rendering',queuedAt:20},{id:'held',state:'held',queuedAt:40}]
assert.deepEqual(queueJobs(jobs).map(j=>j.id),['active','early','late','held']) assert.deepEqual(queueJobs(jobs).map(j=>j.id),['active','early','late','held'])
assert.equal(queueJobs(Array.from({length:15},(_,i)=>({id:i,state:'waiting',queuedAt:i}))).length,15) assert.equal(queueJobs(Array.from({length:15},(_,i)=>({id:i,state:'waiting',queuedAt:i}))).length,15)
}) })
test('enhancing stays on the active queue with the same job id as generate',()=>{
const pe={id:'one',state:'enhancing',queuedAt:10}
const behind={id:'two',state:'waiting',queuedAt:20}
assert.deepEqual(queueJobs([behind,pe]).map(j=>j.id),['one','two'])
assert.equal(jobStageLabel('enhancing'),'Enhancing prompt…')
assert.equal(jobStageLabel('rendering'),'Generating…')
assert.equal(jobStageLabel('submitting'),'Generating…')
assert.deepEqual(jobProgressView(pe),{indeterminate:true,percent:null})
assert.deepEqual(jobProgressView({state:'enhancing',progress:{step:2,maxStep:4,percent:50}}),{indeterminate:false,percent:50})
assert.deepEqual(jobProgressView({state:'rendering',progress:{step:3,maxStep:6,percent:50}}),{indeterminate:false,percent:50})
assert.equal(jobProgressView({state:'waiting'}),null)
// PE → generate is a stage change on the same row, not a vanish frame
const same=[{id:'one',state:'submitting',queuedAt:10},{id:'two',state:'waiting',queuedAt:20}]
assert.deepEqual(queueJobs(same).map(j=>j.id),['one','two'])
})
test('authoritative completion ends a stale rendering row and freezes its timer',()=>{ test('authoritative completion ends a stale rendering row and freezes its timer',()=>{
const record={state:'rendering',queuedAt:1000,startedAt:2000,updatedAt:5000} const record={state:'rendering',queuedAt:1000,startedAt:2000,updatedAt:5000}
for(const status of ['complete','error','cancelled']){ for(const status of ['complete','error','cancelled']){