Show pending Studio 2 jobs and freeze finished timers
This commit is contained in:
@@ -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)))
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
<template>
|
||||
<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>
|
||||
<p>{{ elapsed }} elapsed · {{ 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" />
|
||||
<p v-if="job.error" role="alert" class="text-red-300">{{ job.error }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {elapsedSeconds} from '~/shared/studio2/queue-view.mjs'
|
||||
const props=defineProps<{job:any;now:number}>();defineEmits(['select','cancel'])
|
||||
const terminal=computed(()=>['complete','failed','cancelled'].includes(props.job.state))
|
||||
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=Math.max(0,Math.floor(((terminal.value?props.job.updatedAt:props.now)-(props.job.startedAt||props.job.queuedAt))/1000));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`})
|
||||
</script>
|
||||
|
||||
@@ -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); });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
Reference in New Issue
Block a user