From 99b61e67d7e900b74e97a34db3127b0a77eeaed6 Mon Sep 17 00:00:00 2001 From: Towsty Date: Sat, 26 Sep 2026 14:42:22 -0500 Subject: [PATCH] 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 --- assets/css/display.css | 31 ++++++++++++++++++++++++++++++ components/studio2/Bench.vue | 17 ++++++++++++++-- components/studio2/JobProgress.vue | 8 +++++++- components/studio2/Mobile.vue | 10 ++++++++++ server/utils/studio2/runner.ts | 17 +++++++++++++++- shared/studio2/queue-view.mjs | 31 +++++++++++++++++++++++++++++- shared/studio2/viewer.mjs | 2 +- tests/studio2-bench.test.mjs | 4 +++- tests/studio2-queue-view.test.mjs | 17 +++++++++++++++- 9 files changed, 129 insertions(+), 8 deletions(-) diff --git a/assets/css/display.css b/assets/css/display.css index d7b59a8..4dc8798 100644 --- a/assets/css/display.css +++ b/assets/css/display.css @@ -590,6 +590,37 @@ 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 { border: 0; background: transparent; diff --git a/components/studio2/Bench.vue b/components/studio2/Bench.vue index 58ad219..70bcc4c 100644 --- a/components/studio2/Bench.vue +++ b/components/studio2/Bench.vue @@ -302,6 +302,14 @@ +
+ + {{ card.elapsed }} +

No active jobs

@@ -374,7 +382,7 @@ import {filterLorasForStudio,filterLorasForUse} from '~/utils/loras' import {sectionsFrom} from '~/shared/studio2/contracts.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 {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 {applyUploadedStill} from '~/shared/studio2/upload.mjs' import {defaultNegatives,parseBatch} from '~/shared/studio2/styles.mjs' @@ -534,6 +542,7 @@ const slotPairHint=computed(()=>{ }) const queueCards=computed(()=>{ const cards:any[]=[] + const now=Date.now() for(const job of benchJobs.value){ 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' @@ -541,6 +550,8 @@ const queueCards=computed(()=>{ const modeLabel=mode.charAt(0).toUpperCase()+mode.slice(1) const aspect=job.request?.settings?.aspect || 'auto' const state=job.state==='waiting'?'queued':job.state + const secs=elapsedSeconds(job,now) + const progress=jobProgressView(job) cards.push({ id:job.id, job, @@ -548,7 +559,9 @@ const queueCards=computed(()=>{ title:action.slice(0,72), meta:`${modeLabel} · ${eng} · ${aspect}`, 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) }) } diff --git a/components/studio2/JobProgress.vue b/components/studio2/JobProgress.vue index d5e550b..4da4f2b 100644 --- a/components/studio2/JobProgress.vue +++ b/components/studio2/JobProgress.vue @@ -2,7 +2,8 @@

{{ elapsed }} {{ job.startedAt?'elapsed':'waiting' }} · {{ job.request.settings.duration }}s target · Step {{ job.progress.step }}/{{ job.progress.maxStep }} ({{ job.progress.percent }}%)

- + +
@@ -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 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 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 +}) diff --git a/components/studio2/Mobile.vue b/components/studio2/Mobile.vue index a22d5bf..13b02c0 100644 --- a/components/studio2/Mobile.vue +++ b/components/studio2/Mobile.vue @@ -270,6 +270,14 @@ ● {{ card.statusLabel }} +
+ + {{ card.elapsed }} +

No active jobs

@@ -581,5 +589,7 @@ watch(() => props.ctx.form.mode, () => { sheet.value = '' }) .m-job-id { color: var(--muted); font-size: 12px; } .m-job-title { font-weight: 600; } .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; } diff --git a/server/utils/studio2/runner.ts b/server/utils/studio2/runner.ts index feedaae..ed65529 100644 --- a/server/utils/studio2/runner.ts +++ b/server/utils/studio2/runner.ts @@ -125,9 +125,14 @@ async function runQwen21PromptEnhance(r: any, job: Job) { } } 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) const queued = await queuePrompt(graph, job.clientId) 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) const history = await waitPromptHistory(r, job, queued.prompt_id) const result = harvestQwen21Pe(history, queued.prompt_id, edit) @@ -142,11 +147,19 @@ async function runQwen21PromptEnhance(r: any, job: Job) { wh_ratio: result.wh_ratio || '', ...(edit ? { ratio_follow: result.ratio_follow || '' } : {}), 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.promptId = '' + job.promptId = '' + r.progress = null saveRecord(r) 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 { @@ -394,6 +407,8 @@ async function run(r: any, job: Job) { } job.promptId = queued.prompt_id; r.promptId = queued.prompt_id; + r.generateComfyPromptId = queued.prompt_id; + r.progress = null; r.renderStartedAt = Date.now(); update(r, 'rendering'); } diff --git a/shared/studio2/queue-view.mjs b/shared/studio2/queue-view.mjs index f5e2d31..7c4850a 100644 --- a/shared/studio2/queue-view.mjs +++ b/shared/studio2/queue-view.mjs @@ -1,5 +1,6 @@ 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) { 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 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 +} diff --git a/shared/studio2/viewer.mjs b/shared/studio2/viewer.mjs index 54f9918..80a6325 100644 --- a/shared/studio2/viewer.mjs +++ b/shared/studio2/viewer.mjs @@ -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 } 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) { if (!xaigen || !asset?.id) return false return !revealed?.has?.(asset.id) diff --git a/tests/studio2-bench.test.mjs b/tests/studio2-bench.test.mjs index a35074c..80cb57c 100644 --- a/tests/studio2-bench.test.mjs +++ b/tests/studio2-bench.test.mjs @@ -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) }) 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 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 @@ -144,6 +144,8 @@ test('custom negative chips survive normalizeStyles and bench exposes Load actio assert.match(runner,/ensureQwen21EditPrompt/) assert.match(runner,/stitchQwen21PeEditPrompt/) assert.match(runner,/images\.image_1/) + assert.match(runner,/comfyPromptId/) + assert.match(runner,/generateComfyPromptId/) assert.doesNotMatch(runner,/Keep the subject in /) 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\]/) diff --git a/tests/studio2-queue-view.test.mjs b/tests/studio2-queue-view.test.mjs index 08da2bf..b28fde4 100644 --- a/tests/studio2-queue-view.test.mjs +++ b/tests/studio2-queue-view.test.mjs @@ -1,11 +1,26 @@ import test from 'node:test' 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',()=>{ 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.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',()=>{ const record={state:'rendering',queuedAt:1000,startedAt:2000,updatedAt:5000} for(const status of ['complete','error','cancelled']){