diff --git a/components/studio2/Bench.vue b/components/studio2/Bench.vue index db1e35f..082bd0c 100644 --- a/components/studio2/Bench.vue +++ b/components/studio2/Bench.vue @@ -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 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 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('/api/studio-2/jobs'), + $fetch('/api/studio-2/upscales'), + $fetch('/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(`/api/generate/${j.liveJobId}`)] as const} + catch{return [j.liveJobId,null] as const} + })) + const nextLive:Record={} + for(const [id,snap] of liveEntries){if(snap)nextLive[id]=snap} + liveMusic.value=nextLive +} 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='' + // 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{ 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 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 } - if(photoRoleWarning.value){error.value=photoRoleWarning.value;return} syncPhotosBindings() if(form.settings.seedMode!=='locked')form.settings.seed=randomSeed() const body=applyPhotosToRequest({ @@ -1325,13 +1372,30 @@ async function queue(){ settings:{...form.settings,...resolvedSize.value}, batch:video.value || form.mode==='edit'?parseBatch(batchText.value):[] }) - const queued=await $fetch('/api/studio-2/jobs',{method:'POST',body}) - form.settings.seed=queued.seed;form.settings.width=queued.width;form.settings.height=queued.height - await refresh() - }catch(e:any){error.value=e.data?.statusMessage||e.message} - finally{submitting.value=false} + logTiming('POST /api/studio-2/jobs start') + const queued=await Promise.race([ + $fetch('/api/studio-2/jobs',{method:'POST',body}), + new Promise((_,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 + jobs.value=jobs.value.map(j=>j.id===localId?{...j,id:queued.id,state:'waiting'}:j) + focusedJobId.value=queued.id + } + // 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 refresh()} +async function cancel(job:any){await $fetch('/api/studio-2/cancel',{method:'POST',body:{id:job.id}});await refreshJobs()} async function refresh(){ const [nextJobs,nextLibrary,nextUpscales,queue]=await Promise.all([ @@ -1369,22 +1433,31 @@ watch(()=>[ music.tags,music.lyrics,music.duration,music.seed,music.name ],()=>saveBenchDraft(),{deep:true}) let timer:ReturnType,clock:ReturnType,disposed=false -onMounted(async()=>{ +onMounted(()=>{ let storage;try{storage=localStorage}catch{} loraMemory=createLoraMemory(xaigen,storage);loraMemory.sync(form);form.settings.seed=randomSeed() - clock=setInterval(()=>{now.value=Date.now()},1000);void loadLoras(true) - library.value=await $fetch('/api/studio-2/library') - restoreBenchDraft() - 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. + // 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) - await applyIncomingGenerationLoad() - const id=String(route.query.still||route.query.clip||route.query.track||'') - 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(route.query.task==='music')form.mode='music' - const poll=async()=>{try{await refresh();const health=await $fetch('/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() + 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') + restoreBenchDraft() + if(!form.folderId)form.folderId=library.value.folders[0]?.id||'' + restoreBenchDraft() + loadRevealed(form.folderId) + await applyIncomingGenerationLoad() + const id=String(route.query.still||route.query.clip||route.query.track||'') + 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(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('/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){ window.addEventListener('keydown',onStageHotkey) window.addEventListener('pointerdown',onStagePointerDown,true) diff --git a/server/api/studio-2/loras.get.ts b/server/api/studio-2/loras.get.ts index aa7019e..ce18452 100644 --- a/server/api/studio-2/loras.get.ts +++ b/server/api/studio-2/loras.get.ts @@ -1,19 +1,19 @@ -import { readFileSync,writeFileSync,renameSync } from 'node:fs' +import { readFileSync, writeFileSync, renameSync } from 'node:fs' import { join } from 'node:path' import { cachedLoras } from '~/shared/studio2/lora-cache.mjs' import { listStudioLoras } from '../../utils/loras' import { studio2Root } from '../../utils/studio2/store' import { studio2LoraCatalog } from '../../utils/studio2/lora-catalog' -export default defineEventHandler(async event => { +export default defineEventHandler(async (event) => { const { owner } = assertLibraryOwner(event) 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({ read: () => { try { return JSON.parse(readFileSync(path, 'utf8')) } catch { return null } }, write: value => { writeFileSync(path + '.tmp', JSON.stringify(value)); renameSync(path + '.tmp', path) }, live: () => listStudioLoras({ fresh: true }), - fresh: true + fresh }) return { ...listed, ...studio2LoraCatalog(owner) } }) diff --git a/server/utils/studio2/size.ts b/server/utils/studio2/size.ts index 6378870..3d5caae 100644 --- a/server/utils/studio2/size.ts +++ b/server/utils/studio2/size.ts @@ -1,25 +1,46 @@ -import { readFileSync } from 'node:fs' +import { openSync, readSync, closeSync } from 'node:fs' import { execFile } from 'node:child_process' import { promisify } from 'node:util' import { getClip, getStill, clipVideoPath, stillPath } from '../library' import { imageDimensions } from '../resolution' import { resolveSize } from '~/shared/studio2/input.mjs' 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) { - const video=['video','extend'].includes(request.mode) - let dimensions:any + const video = ['video', 'extend'].includes(request.mode) + let dimensions: any if (video && request.startClipId) { - getClip(owner,request.startClipId) - 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] + 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 }) + dimensions = JSON.parse(result.stdout).streams?.[0] + } } else { - const id=request.imageAId || request.identityStillId + const id = request.imageAId || request.identityStillId if (id) { - getStill(owner,id) - dimensions=imageDimensions(readFileSync(stillPath(owner,id))) + const still = getStill(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 (video && request.startClipId && !dimensions?.width) throw new Error('Cannot measure the previous clip.') - Object.assign(request.settings,resolveSize(request.settings.aspect,dimensions,undefined,request.engine)) + Object.assign(request.settings, resolveSize(request.settings.aspect, dimensions, undefined, request.engine)) }