Compare commits

...
3 Commits
13 changed files with 127 additions and 16 deletions
+3 -3
View File
@@ -1,8 +1,8 @@
<template>
<div v-if="clipId" class="space-y-2">
<button type="button" class="rounded-2xl border border-amber-300/40 px-4 py-2 text-sm text-amber-100 disabled:opacity-50" :disabled="busy || loading || !state" @click="openDialog">Upscale</button>
<NuxtLink v-if="state?.source.upscaledFromClipId" :to="`/?clip=${state.source.upscaledFromClipId}`" class="block text-xs text-amber-200 underline">Upscaled copy · Open original</NuxtLink>
<div v-if="state?.job" class="max-w-sm text-xs text-zinc-400" aria-live="polite">
<NuxtLink v-if="state?.source.upscaledFromClipId && !queueOnly" :to="`/?clip=${state.source.upscaledFromClipId}`" class="block text-xs text-amber-200 underline">Upscaled copy · Open original</NuxtLink>
<div v-if="state?.job && !queueOnly" class="max-w-sm text-xs text-zinc-400" aria-live="polite">
<span class="capitalize">Upscale {{ state.job.status }}</span>
<span v-if="state.job.width"> · {{ state.job.width }} × {{ state.job.height }}</span>
<span v-if="state.job.elapsedMs"> · {{ Math.round(state.job.elapsedMs / 1000) }}s</span>
@@ -36,7 +36,7 @@
</template>
<script setup lang="ts">
import { upscaleOptions, upscaleDimensions, upscaleName } from '~/shared/video-upscale.mjs'
const props = defineProps<{ clipId: string; generationBusy?: boolean }>()
const props = defineProps<{ clipId: string; generationBusy?: boolean; queueOnly?: boolean }>()
const emit = defineEmits<{ complete: [] }>()
const dialog = ref<HTMLDialogElement>(), dialogOpen = ref(false), state = ref<any>(null), error = ref(''), loading = ref(true), submitting = ref(false)
const form = reactive(upscaleOptions())
+13 -8
View File
@@ -8,17 +8,17 @@
<NuxtLink to="/library" class="text-sm text-amber-200">Library</NuxtLink><NuxtLink to="/" class="text-sm text-zinc-400">Studio 1</NuxtLink>
</div>
</header>
<div class="flex flex-wrap justify-between gap-3 rounded-xl border border-white/10 px-4 py-2 text-sm"><span>GPU · {{ gpuLabel }}</span><span>{{ activeJob ? `${activeJob.request.promptSections.action.slice(0,60)} · ${activeJob.state}` : 'No active Studio 2 job' }}</span><div v-if="xaigen" class="flex gap-4"><label><input v-model="form.hideInput" type="checkbox"> Hide input stills</label><label><input v-model="hideOutput" type="checkbox"> Hide output preview</label></div></div>
<div class="flex flex-wrap justify-between gap-3 rounded-xl border border-white/10 px-4 py-2 text-sm"><Studio2GpuStatus :status="gpu" /><div v-if="xaigen" class="flex gap-4"><label><input v-model="form.hideInput" type="checkbox"> Hide input stills</label><label><input v-model="hideOutput" type="checkbox"> Hide output preview</label></div></div>
<nav class="flex flex-wrap gap-2"><button v-for="task in tasks" :key="task" class="rounded-full border px-4 py-2 capitalize" :class="form.mode===task?'border-amber-300 bg-amber-300 text-black':'border-white/15'" @click="chooseTask(task)">{{ task }}</button></nav>
<div class="grid min-w-0 gap-6 xl:grid-cols-[minmax(0,3fr)_minmax(24rem,2fr)] 2xl:gap-8">
<section class="min-w-0 space-y-4">
<div class="flex min-h-80 items-center justify-center overflow-hidden rounded-3xl border border-white/10 bg-black">
<p v-if="selected && xaigen && (hideOutput || (selected.role==='input' && form.hideInput))" class="text-zinc-400">Output loaded · preview hidden</p>
<video v-else-if="selected?.kind==='clip'" :src="`/api/library/clips/${selected.id}/video`" controls class="max-h-[65vh] w-full object-contain" />
<video v-else-if="selected?.kind==='clip'" :key="selected.id" :src="`/api/library/clips/${selected.id}/video`" controls class="max-h-[65vh] w-full object-contain" />
<img v-else-if="selected" :src="stillUrl(selected.id)" class="max-h-[65vh] max-w-full object-contain" alt="Current output">
<p v-else class="text-zinc-500">Select a result to view it here</p>
</div>
<div v-if="selected" class="flex flex-wrap gap-3"><button class="rounded-full border border-white/20 px-4 py-2 text-sm" @click="restore(selected)">Use as input</button><button v-if="selected.kind==='still'" class="rounded-full border border-white/20 px-4 py-2 text-sm" @click="Object.assign(form,useAsSource(form,selected))">Use as source still</button><button v-if="selected.kind==='still'" class="rounded-full border border-white/20 px-4 py-2 text-sm" @click="Object.assign(form,useAsHero(form,selected))">Use as hero</button><button class="rounded-full border border-white/20 px-4 py-2 text-sm" @click="detailsOpen=true">Details</button><button class="rounded-full border border-red-400/40 px-4 py-2 text-sm text-red-200" @click="deleteOutput">Delete</button><VideoUpscaleControl v-if="selected.kind==='clip'" :clip-id="selected.id" /></div>
<div v-if="selected" class="flex flex-wrap items-start gap-3"><button class="rounded-full border border-white/20 px-4 py-2 text-sm" @click="restore(selected)">Use as input</button><button v-if="selected.kind==='still'" class="rounded-full border border-white/20 px-4 py-2 text-sm" @click="Object.assign(form,useAsSource(form,selected))">Use as source still</button><button v-if="selected.kind==='still'" class="rounded-full border border-white/20 px-4 py-2 text-sm" @click="Object.assign(form,useAsHero(form,selected))">Use as hero</button><button class="rounded-full border border-white/20 px-4 py-2 text-sm" @click="detailsOpen=true">Details</button><button class="rounded-full border border-red-400/40 px-4 py-2 text-sm text-red-200" @click="deleteOutput">Delete</button><VideoUpscaleControl v-if="selected.kind==='clip'" :clip-id="selected.id" queue-only @complete="refresh" /></div>
<div class="flex min-w-0 max-w-full gap-2 overflow-x-auto" aria-label="Current family outputs"><button v-for="o in family" :key="o.id" @click="selectOutput(o)" class="shrink-0 rounded-lg border p-1" :class="selected?.id===o.id?'border-amber-300':'border-white/10'"><span v-if="previewHidden(o,xaigen,form.hideInput,hideOutput)" class="flex h-20 w-24 items-center justify-center p-1 text-xs text-zinc-400">{{ o.role==='input'?'Still loaded · preview hidden':'Output hidden' }}</span><img v-else :src="o.kind==='clip'?`/api/library/clips/${o.id}/thumb`:stillUrl(o.id)" class="h-20 w-24 rounded object-cover" alt="Family output"></button></div>
<section aria-label="Source" class="rounded-2xl border border-white/10 p-3"><h2 class="mb-3 text-xs uppercase tracking-wider text-zinc-400">Source</h2><div class="flex flex-wrap gap-4">
<Studio2AssetSlot label="Hero" :asset="stills.find((a:any)=>a.id===form.identityStillId)" :hidden="inputHidden" upload :disabled="uploading" @pick="picker='identityStillId'" @upload="upload($event,'hero')" />
@@ -40,6 +40,8 @@
<p v-if="form.mode==='extend'" class="text-sm text-amber-200">{{ form.startClipId ? 'Extend · '+(clips.find((c:any)=>c.id===form.startClipId)?.name || form.startClipId)+' · last frame' : 'Select the clip in the family strip or Previous clip.' }}</p>
<p v-if="!video && form.engine==='krea'" class="text-sm text-zinc-400">Krea does not attach the hero identity reference.</p>
<button :disabled="submitting" @click="queue" class="w-full rounded-2xl bg-amber-300 p-4 font-semibold text-black disabled:opacity-50">{{ submitting?'Queuing…':'Queue' }}</button>
<h2 class="text-sm font-semibold text-zinc-300">Job queue</h2>
<div v-for="job in upscales.filter(j=>j.folderId===form.folderId && ['queued','running'].includes(j.status))" :key="job.id" class="space-y-2 rounded-xl border border-amber-300/25 bg-zinc-900 p-4 text-sm" role="status"><div class="flex justify-between gap-3"><strong>Video upscale</strong><span class="capitalize text-amber-200">{{ job.status }}</span></div><p class="truncate text-zinc-300">{{ job.name }}</p><p class="text-xs text-zinc-400">{{ job.width?job.width+' × '+job.height+' · ':'' }}{{ Math.round((job.elapsedMs||0)/1000) }}s elapsed</p><progress class="h-2 w-full accent-amber-300" max="100" :value="job.progress || 0" /><p class="text-xs text-zinc-400">{{ job.message }}</p></div>
<Studio2JobProgress v-for="job in benchJobs" :key="job.id" :job="job" :now="now" @select="focusJob(job)" @cancel="cancel(job)" />
<button v-if="folderJobs.some(j=>['complete','failed','cancelled'].includes(j.state))" class="text-sm text-zinc-400" @click="clearFinished">Clear finished</button>
</section>
@@ -59,6 +61,8 @@
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('')
const inputHidden=computed(()=>xaigen && form.hideInput)
@@ -66,7 +70,8 @@ const uploading=ref(false),uploadMessage=ref('')
import {parseBatch,stylePrompt} from '~/shared/studio2/styles.mjs'
import {randomSeed} from '~/shared/studio2/seed.mjs'
const measured=reactive<Record<string,{width:number;height:number}>>({}),seenOutputs=new Set<string>()
const loraNames=ref<any>({image:[],video:[]}),now=ref(Date.now()),gpuLabel=ref('Checking…')
const loraNames=ref<any>({image:[],video:[]}),now=ref(Date.now()),healthState=ref<any>(null),upscales=ref<any[]>([])
const gpu=computed(()=>gpuStatus(jobs.value,healthState.value,upscales.value))
const library=ref<any>({folders:[],stills:[],clips:[]}),jobs=ref<any[]>([]),selected=ref<any>(null)
const form=reactive<any>({mode:'generate',engine:'flux',imageStyles:{positive:[],negative:[]},lockFace:true,lockOutfit:true,hideInput:false,folderId:'',identityStillId:'',imageAId:'',imageBId:'',maskId:'',startClipId:'',endStillId:'',guides:[],promptSections:sectionsFrom({}),settings:{aspect:'auto',fps:24,steps:24,cfg:4,seed:null,seedMode:'random',duration:5,turbo:true,loraStack:[]}})
const route=useRoute()
@@ -74,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)))
@@ -118,8 +123,8 @@ async function upload(event:Event,target:'source'|'hero'){
}
async function refresh(){
const [nextJobs,nextLibrary]=await Promise.all([$fetch<any[]>('/api/studio-2/jobs'),$fetch<any>('/api/studio-2/library')])
jobs.value=nextJobs;library.value=nextLibrary
const [nextJobs,nextLibrary,nextUpscales]=await Promise.all([$fetch<any[]>('/api/studio-2/jobs'),$fetch<any>('/api/studio-2/library'),$fetch<any[]>('/api/studio-2/upscales')])
jobs.value=nextJobs;library.value=nextLibrary;upscales.value=nextUpscales
const fresh=newestOutput(nextJobs,seenOutputs,form.folderId)
for(const job of nextJobs)for(const output of job.outputs||[])seenOutputs.add(output.id)
const existingFresh=family.value.find(a=>a.id===fresh?.id)
@@ -131,6 +136,6 @@ watch(()=>form.identityStillId,id=>{if(import.meta.client&&form.folderId)localSt
watch(hideOutput,value=>{if(import.meta.client)localStorage.setItem('studio2-hide-output',String(value))})
watch(()=>form.hideInput,value=>{if(import.meta.client)localStorage.setItem('studio2-hide-input',String(value))})
let timer:ReturnType<typeof setTimeout>,clock:ReturnType<typeof setInterval>,disposed=false
onMounted(async()=>{form.settings.seed=randomSeed();form.hideInput=xaigen && localStorage.getItem('studio2-hide-input')==='true';hideOutput.value=xaigen && localStorage.getItem('studio2-hide-output')==='true';clock=setInterval(()=>{now.value=Date.now()},1000);void loadLoras();library.value=await $fetch('/api/studio-2/library');form.folderId=library.value.folders[0]?.id||'';const id=String(route.query.still||route.query.clip||'');const asset=[...library.value.stills,...library.value.clips].find((a:any)=>a.id===id);if(asset){selected.value={...asset,kind:route.query.clip?'clip':'still'};if(route.query.use==='1')restore(selected.value)};const poll=async()=>{try{await refresh();const health=await $fetch<any>('/api/health');gpuLabel.value=health.comfy?.busy?'Busy':health.comfy?.ok?'Ready':'Sleeping / unavailable'}catch(e:any){error.value=e.data?.statusMessage||e.message}if(!disposed)timer=setTimeout(poll,3000)};void poll()})
onMounted(async()=>{form.settings.seed=randomSeed();form.hideInput=xaigen && localStorage.getItem('studio2-hide-input')==='true';hideOutput.value=xaigen && localStorage.getItem('studio2-hide-output')==='true';clock=setInterval(()=>{now.value=Date.now()},1000);void loadLoras();library.value=await $fetch('/api/studio-2/library');form.folderId=library.value.folders[0]?.id||'';const id=String(route.query.still||route.query.clip||'');const asset=[...library.value.stills,...library.value.clips].find((a:any)=>a.id===id);if(asset){selected.value={...asset,kind:route.query.clip?'clip':'still'};if(route.query.use==='1')restore(selected.value)};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()})
onBeforeUnmount(()=>{disposed=true;clearTimeout(timer);clearInterval(clock)})
</script>
+9
View File
@@ -0,0 +1,9 @@
<template>
<div role="status" aria-live="polite" class="flex min-w-0 flex-wrap items-center gap-3">
<span class="inline-flex shrink-0 items-center gap-2 rounded-full border px-4 py-2 text-base font-semibold" :class="status.tone==='ready'?'border-emerald-400/50 bg-emerald-400/15 text-emerald-200':status.tone==='busy'?'border-amber-300/50 bg-amber-300/15 text-amber-200':'border-zinc-600 bg-zinc-800 text-zinc-200'"><span class="h-2.5 w-2.5 rounded-full bg-current" :class="{'animate-pulse':status.tone==='busy'}" />GPU · {{ status.label }}</span>
<span class="max-w-2xl truncate text-sm text-zinc-300" :title="status.detail">{{ status.detail }}</span>
</div>
</template>
<script setup lang="ts">
defineProps<{status:{label:string;tone:string;detail:string}}>()
</script>
+3 -2
View File
@@ -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>
+2 -1
View File
@@ -1,5 +1,6 @@
import { existsSync } from 'node:fs'
import { sendPathWithRange } from '~/server/utils/httpRange'
import { inlineFilename } from '~/shared/content-disposition.mjs'
export default defineEventHandler((event) => {
const { owner } = assertLibraryOwner(event)
@@ -11,7 +12,7 @@ export default defineEventHandler((event) => {
throw createError({ statusCode: 404, statusMessage: 'Video file is missing' })
}
return sendPathWithRange(event, path, 'video/mp4', {
'Content-Disposition': `inline; filename="${safeDownloadName(clipTitle(clip))}.mp4"`,
'Content-Disposition': inlineFilename(`${safeDownloadName(clipTitle(clip))}.mp4`),
'Cache-Control': 'private, max-age=0, must-revalidate'
})
})
+3 -1
View File
@@ -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 === '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); });
+18
View File
@@ -0,0 +1,18 @@
import {upscaleRecords} from '../../utils/videoUpscale';
import {listStudioJobs} from '../../utils/studioQueue';
import {getClip} from '../../utils/library';
export default defineEventHandler(event=>{
const {owner}=assertLibraryOwner(event),records=upscaleRecords(owner);
const rows=listStudioJobs(owner).filter(j=>j.payload.upscale);
const ids=new Set([...rows.map(r=>r.id),...records.map(r=>r.id)]);
return [...ids].flatMap(id=>{
const row=rows.find(r=>r.id===id),record=records.find(r=>r.id===id);
try {
const clip=getClip(owner,row?.payload.upscale?.sourceId || record?.sourceId);
assertFolderAccess(event,clip.folderId);
const state=['waiting','held','running'].includes(row?.status || '')?row!.status:record?.status || row?.status;
return [{id,folderId:clip.folderId,name:clip.name || 'Video',status:({waiting:'queued',held:'queued',complete:'done',error:'failed',cancelled:'failed'} as any)[state || ''] || state,
progress:record?.progress || 0,width:record?.width,height:record?.height,elapsedMs:record?.elapsedMs || (record?.createdAt?Date.now()-record.createdAt:0),message:record?.message || row?.waitReason,error:record?.error || row?.lastError,outputClipId:record?.outputClipId}];
}catch{return [];}
});
});
+2 -1
View File
@@ -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);
+7
View File
@@ -0,0 +1,7 @@
/** ASCII header fallback plus UTF-8 filename; titles may contain emoji or smart punctuation. */
export function inlineFilename(name) {
const clean=String(name).replace(/[\r\n\x00-\x1f\x7f]/g,'').toWellFormed()
const ascii=clean.replace(/[^\x20-\x7e]/g,'_').replace(/["\\]/g,'_') || 'video.mp4'
const encoded=encodeURIComponent(clean || 'video.mp4').replace(/['()*]/g,c=>'%'+c.charCodeAt(0).toString(16).toUpperCase())
return `inline; filename="${ascii}"; filename*=UTF-8''${encoded}`
}
+16
View File
@@ -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
}
+11
View File
@@ -0,0 +1,11 @@
export function gpuStatus(jobs, health, upscales=[]) {
if (!health) return {label:'Status unavailable',tone:'unknown',detail:'Checking GPU connection…'}
const upscale=upscales.find(j=>j.status==='running')
if(upscale)return {label:'Upscaling',tone:'busy',detail:upscale.name || 'Video upscale'}
const job=jobs.find(j=>['waking','submitting','rendering','saving'].includes(j.state))
if(job)return {label:{waking:'Waking up',submitting:'Submitting',rendering:'Generating',saving:'Saving'}[job.state],tone:'busy',detail:job.request?.promptSections?.action || 'Generation in progress'}
if(health.comfy?.busy || health.imageComfy?.busy)return {label:'Busy',tone:'busy',detail:'GPU is working on another job'}
if(health.comfy?.ok || health.imageComfy?.ok)return {label:'Ready',tone:'ready',detail:'No job running'}
if(health.comfy?.asleep)return {label:'Sleeping',tone:'sleeping',detail:'Wakes when a job starts'}
return {label:'Offline',tone:'unknown',detail:'GPU connection unavailable'}
}
+17
View File
@@ -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)
})
+23
View File
@@ -0,0 +1,23 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {validateHeaderValue} from 'node:http'
import {gpuStatus} from '../shared/studio2/status.mjs'
import {inlineFilename} from '../shared/content-disposition.mjs'
import {readFileSync} from 'node:fs'
test('waiting records never replace the idle GPU status after completion',()=>{
const waiting={state:'waiting',request:{promptSections:{action:'Old waiting prompt'}}},running={state:'rendering',request:{promptSections:{action:'Current video'}}},health={comfy:{ok:true}};
assert.equal(gpuStatus([waiting,running],health).detail,'Current video');
assert.deepEqual(gpuStatus([waiting,{...running,state:'complete'}],health),{label:'Ready',tone:'ready',detail:'No job running'});
assert.equal(gpuStatus([waiting],health,[{status:'running',name:'Upscale current clip'}]).label,'Upscaling');
assert.equal(gpuStatus([waiting],{comfy:{asleep:true}}).label,'Sleeping');
});
test('Unicode video titles produce valid response headers and retain UTF-8 filename',()=>{
for(const title of ['A scene… “hello” — 🎥.mp4','日本語.mp4','bad\r\nheader".mp4']){
const header=inlineFilename(title);assert.doesNotThrow(()=>validateHeaderValue('Content-Disposition',header));assert.match(header,/filename\*=UTF-8''/);assert.doesNotMatch(header,/[\r\n]/);
}
assert.match(inlineFilename('Scene….mp4'),/%E2%80%A6/);
});
test('Studio 2 upscale button delegates progress to the queue without stretching actions',()=>{
const bench=readFileSync(new URL('../components/studio2/Bench.vue',import.meta.url),'utf8'),control=readFileSync(new URL('../components/VideoUpscaleControl.vue',import.meta.url),'utf8');
assert.match(bench,/:clip-id="selected.id" queue-only/);assert.match(bench,/items-start gap-3/);assert.match(bench,/Job queue/);assert.match(control,/state\?\.job && !queueOnly/);
});