Queue Generate with an immediate local card and stop blocking reload on library or object_info.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1304,16 +1304,63 @@ function selectOutput(item:any){stageMenu.value='';selected.value=item;if(form.m
|
|||||||
function restore(item:any){Object.assign(form,restoreAll(form,item,xaigen,loraMemory));form.guides=form.guides||[];batchText.value=''}
|
function restore(item:any){Object.assign(form,restoreAll(form,item,xaigen,loraMemory));form.guides=form.guides||[];batchText.value=''}
|
||||||
function focusJob(job:any){selectOutput(job.outputs?.at(-1)||null);focusedJobId.value=job.id}
|
function focusJob(job:any){selectOutput(job.outputs?.at(-1)||null);focusedJobId.value=job.id}
|
||||||
async function loadLoras(fresh=false){try{loraNames.value=await $fetch('/api/studio-2/loras'+(fresh?'?fresh=1':''))}catch{error.value='Could not refresh LoRAs. The last loaded list is still available.'}}
|
async function loadLoras(fresh=false){try{loraNames.value=await $fetch('/api/studio-2/loras'+(fresh?'?fresh=1':''))}catch{error.value='Could not refresh LoRAs. The last loaded list is still available.'}}
|
||||||
async function clearFinished(){await $fetch('/api/studio-2/clear-finished',{method:'POST'});await refresh()}
|
async function clearFinished(){await $fetch('/api/studio-2/clear-finished',{method:'POST'});await refreshJobs()}
|
||||||
|
async function refreshJobs(){
|
||||||
|
const [nextJobs,nextUpscales,queue]=await Promise.all([
|
||||||
|
$fetch<any[]>('/api/studio-2/jobs'),
|
||||||
|
$fetch<any[]>('/api/studio-2/upscales'),
|
||||||
|
$fetch<any>('/api/studio-queue?full=1').catch(()=>({jobs:[]}))
|
||||||
|
])
|
||||||
|
jobs.value=nextJobs;upscales.value=nextUpscales
|
||||||
|
studioQueue.value=Array.isArray(queue)?queue:(queue?.jobs||[])
|
||||||
|
const musicRows=studioQueue.value.filter((j:any)=>j.kind==='music' && j.liveJobId && !['complete','done'].includes(j.status))
|
||||||
|
const liveEntries=await Promise.all(musicRows.map(async(j:any)=>{
|
||||||
|
try{return [j.liveJobId,await $fetch<any>(`/api/generate/${j.liveJobId}`)] as const}
|
||||||
|
catch{return [j.liveJobId,null] as const}
|
||||||
|
}))
|
||||||
|
const nextLive:Record<string,any>={}
|
||||||
|
for(const [id,snap] of liveEntries){if(snap)nextLive[id]=snap}
|
||||||
|
liveMusic.value=nextLive
|
||||||
|
}
|
||||||
async function queue(){
|
async function queue(){
|
||||||
|
const t0=typeof performance!=='undefined'?performance.now():Date.now()
|
||||||
|
const logTiming=(label:string)=>{
|
||||||
|
const t=typeof performance!=='undefined'?performance.now():Date.now()
|
||||||
|
console.info(`[bench-queue] ${label} +${Math.round(t-t0)}ms`)
|
||||||
|
}
|
||||||
submitting.value=true;error.value=''
|
submitting.value=true;error.value=''
|
||||||
|
// Optimistic card in <100ms — never await library / object_info before the list updates
|
||||||
|
const localId=`local-${Date.now()}`
|
||||||
|
if(form.mode!=='music'){
|
||||||
|
const optimistic={
|
||||||
|
id:localId,
|
||||||
|
state:'waiting',
|
||||||
|
queuedAt:Date.now(),
|
||||||
|
request:structuredClone({
|
||||||
|
...form,
|
||||||
|
promptSections:video.value?form.promptSections:{action:form.promptSections.action},
|
||||||
|
settings:{...form.settings,...resolvedSize.value}
|
||||||
|
}),
|
||||||
|
outputs:[],
|
||||||
|
purgeResult:'Queued'
|
||||||
|
}
|
||||||
|
jobs.value=[optimistic,...jobs.value]
|
||||||
|
focusedJobId.value=localId
|
||||||
|
logTiming('local-card')
|
||||||
|
}
|
||||||
try{
|
try{
|
||||||
if(form.mode==='music'){
|
if(form.mode==='music'){
|
||||||
|
logTiming('POST /api/generate/music start')
|
||||||
await $fetch('/api/generate/music',{method:'POST',body:{engine:'yue2',tags:music.tags,lyrics:music.lyrics,duration:music.duration,seed:music.seed||'random',folderId:form.folderId,name:music.name}})
|
await $fetch('/api/generate/music',{method:'POST',body:{engine:'yue2',tags:music.tags,lyrics:music.lyrics,duration:music.duration,seed:music.seed||'random',folderId:form.folderId,name:music.name}})
|
||||||
await refresh()
|
logTiming('POST music ack')
|
||||||
|
void refresh()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if(photoRoleWarning.value){
|
||||||
|
jobs.value=jobs.value.filter(j=>j.id!==localId)
|
||||||
|
error.value=photoRoleWarning.value
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if(photoRoleWarning.value){error.value=photoRoleWarning.value;return}
|
|
||||||
syncPhotosBindings()
|
syncPhotosBindings()
|
||||||
if(form.settings.seedMode!=='locked')form.settings.seed=randomSeed()
|
if(form.settings.seedMode!=='locked')form.settings.seed=randomSeed()
|
||||||
const body=applyPhotosToRequest({
|
const body=applyPhotosToRequest({
|
||||||
@@ -1325,13 +1372,30 @@ async function queue(){
|
|||||||
settings:{...form.settings,...resolvedSize.value},
|
settings:{...form.settings,...resolvedSize.value},
|
||||||
batch:video.value || form.mode==='edit'?parseBatch(batchText.value):[]
|
batch:video.value || form.mode==='edit'?parseBatch(batchText.value):[]
|
||||||
})
|
})
|
||||||
const queued=await $fetch<any>('/api/studio-2/jobs',{method:'POST',body})
|
logTiming('POST /api/studio-2/jobs start')
|
||||||
|
const queued=await Promise.race([
|
||||||
|
$fetch<any>('/api/studio-2/jobs',{method:'POST',body}),
|
||||||
|
new Promise<never>((_,rej)=>setTimeout(()=>rej(new Error('queue_ack_timeout')),12000))
|
||||||
|
]).catch(async(err)=>{
|
||||||
|
logTiming(`POST fail/timeout: ${err?.message||err}`)
|
||||||
|
// Keep the local card queued; poll will reconcile
|
||||||
|
if(String(err?.message||err).includes('queue_ack_timeout')) return null
|
||||||
|
throw err
|
||||||
|
})
|
||||||
|
logTiming('POST ack')
|
||||||
|
if(queued){
|
||||||
form.settings.seed=queued.seed;form.settings.width=queued.width;form.settings.height=queued.height
|
form.settings.seed=queued.seed;form.settings.width=queued.width;form.settings.height=queued.height
|
||||||
await refresh()
|
jobs.value=jobs.value.map(j=>j.id===localId?{...j,id:queued.id,state:'waiting'}:j)
|
||||||
}catch(e:any){error.value=e.data?.statusMessage||e.message}
|
focusedJobId.value=queued.id
|
||||||
finally{submitting.value=false}
|
|
||||||
}
|
}
|
||||||
async function cancel(job:any){await $fetch('/api/studio-2/cancel',{method:'POST',body:{id:job.id}});await refresh()}
|
// Jobs only — do not refetch the whole library when a job starts
|
||||||
|
void refreshJobs().then(()=>logTiming('refreshJobs done'))
|
||||||
|
}catch(e:any){
|
||||||
|
jobs.value=jobs.value.filter(j=>j.id!==localId)
|
||||||
|
error.value=e.data?.statusMessage||e.message
|
||||||
|
}finally{submitting.value=false;logTiming('done')}
|
||||||
|
}
|
||||||
|
async function cancel(job:any){await $fetch('/api/studio-2/cancel',{method:'POST',body:{id:job.id}});await refreshJobs()}
|
||||||
|
|
||||||
async function refresh(){
|
async function refresh(){
|
||||||
const [nextJobs,nextLibrary,nextUpscales,queue]=await Promise.all([
|
const [nextJobs,nextLibrary,nextUpscales,queue]=await Promise.all([
|
||||||
@@ -1369,14 +1433,21 @@ watch(()=>[
|
|||||||
music.tags,music.lyrics,music.duration,music.seed,music.name
|
music.tags,music.lyrics,music.duration,music.seed,music.name
|
||||||
],()=>saveBenchDraft(),{deep:true})
|
],()=>saveBenchDraft(),{deep:true})
|
||||||
let timer:ReturnType<typeof setTimeout>,clock:ReturnType<typeof setInterval>,disposed=false
|
let timer:ReturnType<typeof setTimeout>,clock:ReturnType<typeof setInterval>,disposed=false
|
||||||
onMounted(async()=>{
|
onMounted(()=>{
|
||||||
let storage;try{storage=localStorage}catch{}
|
let storage;try{storage=localStorage}catch{}
|
||||||
loraMemory=createLoraMemory(xaigen,storage);loraMemory.sync(form);form.settings.seed=randomSeed()
|
loraMemory=createLoraMemory(xaigen,storage);loraMemory.sync(form);form.settings.seed=randomSeed()
|
||||||
clock=setInterval(()=>{now.value=Date.now()},1000);void loadLoras(true)
|
// First paint: chrome + empty stage + draft from localStorage — do not await library / object_info
|
||||||
|
restoreBenchDraft()
|
||||||
|
if(!form.folderId && library.value.folders?.[0]?.id) form.folderId=library.value.folders[0].id
|
||||||
|
loadRevealed(form.folderId)
|
||||||
|
clock=setInterval(()=>{now.value=Date.now()},1000)
|
||||||
|
// Deferred heavy work after shell is interactive
|
||||||
|
void (async()=>{
|
||||||
|
void loadLoras(false)
|
||||||
|
try{
|
||||||
library.value=await $fetch('/api/studio-2/library')
|
library.value=await $fetch('/api/studio-2/library')
|
||||||
restoreBenchDraft()
|
restoreBenchDraft()
|
||||||
if(!form.folderId)form.folderId=library.value.folders[0]?.id||''
|
if(!form.folderId)form.folderId=library.value.folders[0]?.id||''
|
||||||
// Folder watch may have cleared photos when folderId was assigned — re-apply draft photos/prompt.
|
|
||||||
restoreBenchDraft()
|
restoreBenchDraft()
|
||||||
loadRevealed(form.folderId)
|
loadRevealed(form.folderId)
|
||||||
await applyIncomingGenerationLoad()
|
await applyIncomingGenerationLoad()
|
||||||
@@ -1384,7 +1455,9 @@ onMounted(async()=>{
|
|||||||
const asset=[...library.value.stills,...library.value.clips,...(library.value.tracks||[])].find((a:any)=>a.id===id)
|
const asset=[...library.value.stills,...library.value.clips,...(library.value.tracks||[])].find((a:any)=>a.id===id)
|
||||||
if(asset){selected.value={...asset,kind:route.query.clip?'clip':route.query.track?'track':'still'};if(route.query.use==='1'&&selected.value.kind!=='track')restore(selected.value);if(xaigen)toggleReveal(asset.id)}
|
if(asset){selected.value={...asset,kind:route.query.clip?'clip':route.query.track?'track':'still'};if(route.query.use==='1'&&selected.value.kind!=='track')restore(selected.value);if(xaigen)toggleReveal(asset.id)}
|
||||||
if(route.query.task==='music')form.mode='music'
|
if(route.query.task==='music')form.mode='music'
|
||||||
|
}catch(e:any){error.value=e.data?.statusMessage||e.message}
|
||||||
const poll=async()=>{try{await refresh();const health=await $fetch<any>('/api/health');healthState.value=health}catch(e:any){healthState.value=null;error.value=e.data?.statusMessage||e.message}if(!disposed)timer=setTimeout(poll,3000)};void poll()
|
const poll=async()=>{try{await refresh();const health=await $fetch<any>('/api/health');healthState.value=health}catch(e:any){healthState.value=null;error.value=e.data?.statusMessage||e.message}if(!disposed)timer=setTimeout(poll,3000)};void poll()
|
||||||
|
})()
|
||||||
if(import.meta.client){
|
if(import.meta.client){
|
||||||
window.addEventListener('keydown',onStageHotkey)
|
window.addEventListener('keydown',onStageHotkey)
|
||||||
window.addEventListener('pointerdown',onStagePointerDown,true)
|
window.addEventListener('pointerdown',onStagePointerDown,true)
|
||||||
|
|||||||
@@ -5,15 +5,15 @@ import { listStudioLoras } from '../../utils/loras'
|
|||||||
import { studio2Root } from '../../utils/studio2/store'
|
import { studio2Root } from '../../utils/studio2/store'
|
||||||
import { studio2LoraCatalog } from '../../utils/studio2/lora-catalog'
|
import { studio2LoraCatalog } from '../../utils/studio2/lora-catalog'
|
||||||
|
|
||||||
export default defineEventHandler(async event => {
|
export default defineEventHandler(async (event) => {
|
||||||
const { owner } = assertLibraryOwner(event)
|
const { owner } = assertLibraryOwner(event)
|
||||||
const path = join(studio2Root(), `loras-${owner}.cache`)
|
const path = join(studio2Root(), `loras-${owner}.cache`)
|
||||||
// Same live Comfy scan as Settings (/api/loras?fresh=1). Always fresh so CACHE_MS cannot freeze adds.
|
const fresh = String(getQuery(event).fresh || '') === '1'
|
||||||
const listed = await cachedLoras({
|
const listed = await cachedLoras({
|
||||||
read: () => { try { return JSON.parse(readFileSync(path, 'utf8')) } catch { return null } },
|
read: () => { try { return JSON.parse(readFileSync(path, 'utf8')) } catch { return null } },
|
||||||
write: value => { writeFileSync(path + '.tmp', JSON.stringify(value)); renameSync(path + '.tmp', path) },
|
write: value => { writeFileSync(path + '.tmp', JSON.stringify(value)); renameSync(path + '.tmp', path) },
|
||||||
live: () => listStudioLoras({ fresh: true }),
|
live: () => listStudioLoras({ fresh: true }),
|
||||||
fresh: true
|
fresh
|
||||||
})
|
})
|
||||||
return { ...listed, ...studio2LoraCatalog(owner) }
|
return { ...listed, ...studio2LoraCatalog(owner) }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,22 +1,43 @@
|
|||||||
import { readFileSync } from 'node:fs'
|
import { openSync, readSync, closeSync } from 'node:fs'
|
||||||
import { execFile } from 'node:child_process'
|
import { execFile } from 'node:child_process'
|
||||||
import { promisify } from 'node:util'
|
import { promisify } from 'node:util'
|
||||||
import { getClip, getStill, clipVideoPath, stillPath } from '../library'
|
import { getClip, getStill, clipVideoPath, stillPath } from '../library'
|
||||||
import { imageDimensions } from '../resolution'
|
import { imageDimensions } from '../resolution'
|
||||||
import { resolveSize } from '~/shared/studio2/input.mjs'
|
import { resolveSize } from '~/shared/studio2/input.mjs'
|
||||||
const exec = promisify(execFile)
|
const exec = promisify(execFile)
|
||||||
|
|
||||||
|
function readImageHeader(path: string, maxBytes = 65536) {
|
||||||
|
const fd = openSync(path, 'r')
|
||||||
|
try {
|
||||||
|
const buf = Buffer.alloc(maxBytes)
|
||||||
|
const n = readSync(fd, buf, 0, maxBytes, 0)
|
||||||
|
return buf.subarray(0, n)
|
||||||
|
} finally {
|
||||||
|
closeSync(fd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function resolveRequestSize(owner: string, request: any) {
|
export async function resolveRequestSize(owner: string, request: any) {
|
||||||
const video = ['video', 'extend'].includes(request.mode)
|
const video = ['video', 'extend'].includes(request.mode)
|
||||||
let dimensions: any
|
let dimensions: any
|
||||||
if (video && request.startClipId) {
|
if (video && request.startClipId) {
|
||||||
getClip(owner,request.startClipId)
|
const clip = getClip(owner, request.startClipId)
|
||||||
|
if (clip.width && clip.height) {
|
||||||
|
dimensions = { width: clip.width, height: clip.height }
|
||||||
|
} else {
|
||||||
const result = await exec('ffprobe', ['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height', '-of', 'json', clipVideoPath(owner, request.startClipId)], { windowsHide: true, timeout: 15000 })
|
const result = await exec('ffprobe', ['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height', '-of', 'json', clipVideoPath(owner, request.startClipId)], { windowsHide: true, timeout: 15000 })
|
||||||
dimensions = JSON.parse(result.stdout).streams?.[0]
|
dimensions = JSON.parse(result.stdout).streams?.[0]
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const id = request.imageAId || request.identityStillId
|
const id = request.imageAId || request.identityStillId
|
||||||
if (id) {
|
if (id) {
|
||||||
getStill(owner,id)
|
const still = getStill(owner, id)
|
||||||
dimensions=imageDimensions(readFileSync(stillPath(owner,id)))
|
if (still.width && still.height) {
|
||||||
|
dimensions = { width: still.width, height: still.height }
|
||||||
|
} else {
|
||||||
|
// Header only — never read the full archive PNG into memory on Generate click
|
||||||
|
dimensions = imageDimensions(readImageHeader(stillPath(owner, id)))
|
||||||
|
}
|
||||||
if (!dimensions) throw new Error('Cannot measure the source still.')
|
if (!dimensions) throw new Error('Cannot measure the source still.')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user