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']){