From 3da3656140c8a70d5a6c6139571e7f8245888200 Mon Sep 17 00:00:00 2001 From: Towsty Date: Sat, 12 Sep 2026 10:39:33 -0500 Subject: [PATCH] Show pending Studio 2 jobs and freeze finished timers --- components/studio2/Bench.vue | 3 ++- components/studio2/JobProgress.vue | 5 +++-- server/api/studio-2/jobs.get.ts | 4 +++- server/utils/studio2/runner.ts | 3 ++- shared/studio2/queue-view.mjs | 16 ++++++++++++++++ tests/studio2-queue-view.test.mjs | 17 +++++++++++++++++ 6 files changed, 43 insertions(+), 5 deletions(-) create mode 100644 shared/studio2/queue-view.mjs create mode 100644 tests/studio2-queue-view.test.mjs diff --git a/components/studio2/Bench.vue b/components/studio2/Bench.vue index 22ac219..af9dbc6 100644 --- a/components/studio2/Bench.vue +++ b/components/studio2/Bench.vue @@ -61,6 +61,7 @@ import {compilePrompt,sectionsFrom} from '~/shared/studio2/contracts.mjs' import {aspectOptions,resolveSize,bindClip,restoreAsset,extendSelected,changeSource,restoreAll,useAsHero,useAsSource} from '~/shared/studio2/input.mjs' import {folderAssets,newestOutput,assetLabel,reconcileSelection,activeJobs,previewHidden} from '~/shared/studio2/viewer.mjs' +import {queueJobs} from '~/shared/studio2/queue-view.mjs' import {gpuStatus} from '~/shared/studio2/status.mjs' import {applyUploadedStill} from '~/shared/studio2/upload.mjs' const xaigen=useRuntimeConfig().public.studio==='xaigen',hideOutput=ref(false),picker=ref('') @@ -78,7 +79,7 @@ const tasks=['generate','edit','compose','refine','video','extend'],settingsOpen const video=computed(()=>['video','extend'].includes(form.mode)),stills=computed(()=>library.value.stills.filter((s:any)=>s.folderId===form.folderId)),clips=computed(()=>library.value.clips.filter((s:any)=>s.folderId===form.folderId)),compiled=computed(()=>compilePrompt(form.promptSections,video.value,form)) const stillUrl=(id:string)=>`/api/library/stills/${id}` const folderJobs=computed(()=>jobs.value.filter(j=>j.request.folderId===form.folderId)) -const benchJobs=computed(()=>activeJobs(folderJobs.value)) +const benchJobs=computed(()=>queueJobs(folderJobs.value)) const sourceSlot=computed(()=>{const clip=clips.value.find((a:any)=>a.id===form.startClipId);return form.mode==='extend'?(clip?{...clip,kind:'clip'}:null):inputStill.value}) const pickerAssets=computed(()=>picker.value==='startClipId'?clips.value.map((a:any)=>({...a,kind:'clip'})):stills.value.map((a:any)=>({...a,kind:'still'}))) const activeJob=computed(()=>folderJobs.value.find(j=>!['complete','failed','cancelled'].includes(j.state))) diff --git a/components/studio2/JobProgress.vue b/components/studio2/JobProgress.vue index 9834e50..d5e550b 100644 --- a/components/studio2/JobProgress.vue +++ b/components/studio2/JobProgress.vue @@ -1,15 +1,16 @@ diff --git a/server/api/studio-2/jobs.get.ts b/server/api/studio-2/jobs.get.ts index dbe623b..39058d9 100644 --- a/server/api/studio-2/jobs.get.ts +++ b/server/api/studio-2/jobs.get.ts @@ -1,3 +1,5 @@ +import { jobView } from '~/shared/studio2/queue-view.mjs'; +import { getJob } from '../../utils/jobs'; import { records } from '../../utils/studio2/store'; import { listStudioJobs } from '../../utils/studioQueue'; export default defineEventHandler(event => { const { owner } = assertLibraryOwner(event); const rows = listStudioJobs(owner); return records(owner).filter(r => !r.dismissed).filter(r => { try { @@ -6,4 +8,4 @@ export default defineEventHandler(event => { const { owner } = assertLibraryOwne } catch { return false; -} }).map(r => { const q = rows.find(q => q.id === r.queueId); return { ...r, state: q?.status === 'complete' ? 'complete' : q?.status === 'cancelled' ? 'cancelled' : q?.status === 'error' ? 'failed' : r.state, error: r.error || q?.lastError }; }); }); +} }).map(r => { const q = rows.find(q => q.id === r.queueId); const live=r.liveId?getJob(r.liveId):null; if (['waiting','queued','held'].includes(r.state) && !q && !live) return null; return jobView(r,q,live); }).filter(Boolean); }); diff --git a/server/utils/studio2/runner.ts b/server/utils/studio2/runner.ts index 2deaa11..920c857 100644 --- a/server/utils/studio2/runner.ts +++ b/server/utils/studio2/runner.ts @@ -28,7 +28,7 @@ type DiskFile = { subfolder: string; type: string; }; -function update(r: any, state: string) { r.state = state; r.updatedAt = Date.now(); saveRecord(r); } +function update(r: any, state: string) { r.state = state; r.updatedAt = Date.now(); if (['complete','failed','cancelled'].includes(state)) r.finishedAt ??= r.updatedAt; saveRecord(r); } // Labels whose pixels must never be resampled: the hero identity still, and the // extend/video start frame (which is either the exact anchor PNG or an already-canvas-sized // extracted last frame). Fitting these every hop is what destroys identity across chained extends. @@ -281,6 +281,7 @@ export async function startStudio2Job(item: StudioJob) { job.library = { ownerKey: item.ownerKey, folderId: r.request.folderId, extensions: [], queueAutoRun: false } as any; job.status = 'running'; r.startedAt = Date.now(); + delete r.finishedAt; r.liveId = job.id; r.clientId = job.clientId; saveRecord(r); diff --git a/shared/studio2/queue-view.mjs b/shared/studio2/queue-view.mjs new file mode 100644 index 0000000..f5e2d31 --- /dev/null +++ b/shared/studio2/queue-view.mjs @@ -0,0 +1,16 @@ +const terminal = new Set(['complete','failed','cancelled']) +const running = new Set(['waking','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)) +} +export function jobView(record, queue, live) { + const statuses={complete:'complete',error:'failed',cancelled:'cancelled'} + const state=statuses[queue?.status] || (!live?.saving && statuses[live?.status]) || (queue?.status==='held'?'held':record.state) + const finishedAt=terminal.has(state)?record.finishedAt || (statuses[queue?.status]?queue.updatedAt:0) || (state==='complete'?record.savedAt:0) || record.updatedAt || record.startedAt || record.queuedAt:undefined + return {...record,state,finishedAt,error:record.error || queue?.lastError || live?.error} +} +export function elapsedSeconds(job, now) { + const start=job.startedAt || job.queuedAt + 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 +} diff --git a/tests/studio2-queue-view.test.mjs b/tests/studio2-queue-view.test.mjs new file mode 100644 index 0000000..08da2bf --- /dev/null +++ b/tests/studio2-queue-view.test.mjs @@ -0,0 +1,17 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import {queueJobs,jobView,elapsedSeconds} 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('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']){ + const result=jobView(record,{status,updatedAt:12000});assert.equal(elapsedSeconds(result,20000),10);assert.equal(elapsedSeconds(result,2000000),10);assert.equal(queueJobs([result]).length,0) + } + const live=jobView(record,null,{status:'complete'});assert.equal(live.state,'complete');assert.equal(elapsedSeconds(live,999999),3) + assert.equal(jobView(record,null,{status:'complete',saving:true}).state,'rendering') + assert.equal(elapsedSeconds({state:'complete',startedAt:2000,finishedAt:12000,updatedAt:200000},500000),10) +})