Add image-to-text Describe on the bench via exclusive llama.cpp caption jobs.

This commit is contained in:
Towsty
2026-09-30 22:50:19 -05:00
parent 9481791ac5
commit 15ef891b26
15 changed files with 937 additions and 35 deletions
+75 -3
View File
@@ -234,6 +234,18 @@
>
<button type="button" @click="applyUseImage()">Use image</button>
<button type="button" @click="applyUsePrompt()">Use prompt</button>
<button
v-if="selected.kind==='still'"
type="button"
:disabled="submitting"
@click="describeSelected"
>Describe</button>
<label v-if="selected.kind==='still'" class="inline-flex items-center gap-1 text-[var(--type-sm)] text-[var(--muted)]">
<span class="sr-only">Caption style</span>
<select v-model="captionStyle" class="field-control !w-auto !py-1 !text-[var(--type-sm)]" aria-label="Caption style">
<option v-for="style in captionStyles" :key="style" :value="style">{{ style }}</option>
</select>
</label>
<div class="stage-split">
<button
type="button"
@@ -382,6 +394,11 @@
/>
<span v-if="card.elapsed != null" class="job-elapsed">{{ card.elapsed }}</span>
</div>
<p v-if="card.resultText" class="mt-2 whitespace-pre-wrap text-[var(--type-sm)]">{{ card.resultText }}</p>
<div v-if="card.resultText" class="mt-2 flex flex-wrap gap-2">
<button type="button" class="text-[var(--type-sm)] text-[var(--amber)]" @click.stop="copyCaption(card.resultText)">Copy</button>
<button type="button" class="text-[var(--type-sm)] text-[var(--amber)]" @click.stop="useCaptionAsPrompt(card.resultText)">Use as prompt</button>
</div>
</div>
<p v-if="!queueCards.length" class="px-1 text-[var(--type-sm)] text-[var(--muted)]">No active jobs</p>
</div>
@@ -460,6 +477,7 @@ import {aspectOptions,resolveSize,bindClip,restoreAll,useAsSource,hydrateStudio2
import {folderAssets,newestOutput,assetLabel,reconcileSelection,previewHidden} from '~/shared/studio2/viewer.mjs'
import {queueJobs,jobStageLabel,jobProgressView,elapsedSeconds} from '~/shared/studio2/queue-view.mjs'
import {gpuStatus} from '~/shared/studio2/status.mjs'
import {CAPTION_STYLES} from '~/shared/caption.mjs'
import {applyUploadedStill} from '~/shared/studio2/upload.mjs'
import {defaultNegatives,parseBatch} from '~/shared/studio2/styles.mjs'
import {randomSeed} from '~/shared/studio2/seed.mjs'
@@ -614,6 +632,9 @@ const promptArea=ref<HTMLTextAreaElement|null>(null)
const promptCaret=ref({start:0,end:0})
const music=reactive({tags:DEFAULT_MUSIC_TAGS,lyrics:DEFAULT_MUSIC_LYRICS,duration:60,seed:'',name:''})
const tagsPlaceholder=DEFAULT_MUSIC_TAGS,lyricsPlaceholder=DEFAULT_MUSIC_LYRICS
const captionStyles=CAPTION_STYLES
const captionStyle=ref('descriptive')
const liveCaption=ref<Record<string,any>>({})
let loraMemory=createLoraMemory(xaigen)
const currentLoraUse=computed(()=>loraUse(form))
const pickerLoras=computed(()=>!xaigen && currentLoraUse.value==='ltx'?[]:filterLorasForUse(filterLorasForStudio([...loraNames.value.image,...loraNames.value.video],xaigen),currentLoraUse.value))
@@ -725,7 +746,10 @@ const benchJobs=computed(()=>queueJobs(folderJobs.value))
const musicJobs=computed(()=>studioQueue.value
.filter((j:any)=>j.kind==='music' && j.folderId===form.folderId && !['complete','done'].includes(j.status))
.map((j:any)=>({...j,live:j.liveJobId?liveMusic.value[j.liveJobId]:null})))
const gpu=computed(()=>gpuStatus(jobs.value,healthState.value,upscales.value,musicJobs.value))
const captionJobs=computed(()=>studioQueue.value
.filter((j:any)=>j.kind==='caption' && j.folderId===form.folderId)
.map((j:any)=>({...j,live:j.liveJobId?liveCaption.value[j.liveJobId]:null})))
const gpu=computed(()=>gpuStatus(jobs.value,healthState.value,upscales.value,musicJobs.value,captionJobs.value))
const gpuChipLabel=computed(()=>{
if(!healthState.value) return 'GPU ?'
const raw=String(healthState.value?.comfy?.gpu || healthState.value?.gpuName || healthState.value?.comfy?.device || '').trim()
@@ -792,6 +816,22 @@ const queueCards=computed(()=>{
cancelable:!['complete','done','error','cancelled'].includes(job.status)
})
}
for(const job of captionJobs.value){
const live=job.live
const text=live?.resultText || job.resultText || ''
const state=live?.status || job.status
cards.push({
id:job.id,
job,
code:'#'+String(job.id).replace(/\D/g,'').slice(-3).padStart(3,'0'),
title:job.name || `Describe · ${job.captionStyle || job.payload?.captionStyle || 'caption'}`,
meta:`Caption · ${job.captionStyle || job.payload?.captionStyle || 'descriptive'}`,
state,
statusLabel:live?.message || state,
resultText:text,
cancelable:!['complete','done','error','cancelled'].includes(job.status) && !['complete','error','cancelled'].includes(String(state))
})
}
for(const job of upscales.value.filter((j:any)=>j.folderId===form.folderId && ['queued','running'].includes(j.status))){
cards.push({
id:job.id,
@@ -807,7 +847,7 @@ const queueCards=computed(()=>{
return cards
})
const queueCount=computed(()=>queueCards.value.length)
const canClearFinished=computed(()=>folderJobs.value.some(j=>['complete','failed','cancelled'].includes(j.state)) || musicJobs.value.some(j=>['complete','done','error','cancelled'].includes(j.status)))
const canClearFinished=computed(()=>folderJobs.value.some(j=>['complete','failed','cancelled'].includes(j.state)) || musicJobs.value.some(j=>['complete','done','error','cancelled'].includes(j.status)) || captionJobs.value.some(j=>['complete','done','error','cancelled'].includes(j.status) || ['complete','error','cancelled'].includes(j.live?.status)))
const transportLabel=computed(()=>{
if(selected.value?.kind==='clip') return `${formatTime(videoTime.value)} / ${formatTime(videoDuration.value)}`
if(selected.value?.kind==='track') return 'Audio'
@@ -1000,6 +1040,23 @@ function applyUseImage(item?:any){
uploadMessage.value=`Added ${assetLabel(asset)} to Photos.`
saveBenchDraft(true)
}
async function copyCaption(text:string){
try{await navigator.clipboard.writeText(text)}catch{error.value='Could not copy caption.'}
}
function useCaptionAsPrompt(text:string){
const current=String(form.promptSections.action||'').trim()
form.promptSections.action=current?`${current}\n\n${text}`:text
}
async function describeSelected(){
const id=selected.value?.kind==='still'?selected.value.id:''
if(!id){error.value='Select a still to describe.';return}
submitting.value=true;error.value=''
try{
await $fetch('/api/generate/caption',{method:'POST',body:{folderId:form.folderId,stillId:id,sourcePath:id,captionStyle:captionStyle.value}})
await refreshJobs()
}catch(e:any){error.value=e.data?.statusMessage||e.message}
finally{submitting.value=false}
}
function applyUsePrompt(item?:any){
const asset=resolveActionAsset(item)
if(!asset){
@@ -1369,6 +1426,14 @@ async function refreshJobs(){
const nextLive:Record<string,any>={}
for(const [id,snap] of liveEntries){if(snap)nextLive[id]=snap}
liveMusic.value=nextLive
const captionRows=studioQueue.value.filter((j:any)=>j.kind==='caption' && j.liveJobId)
const captionEntries=await Promise.all(captionRows.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 nextCaption:Record<string,any>={}
for(const [id,snap] of captionEntries){if(snap)nextCaption[id]=snap}
liveCaption.value=nextCaption
ingestNewOutputs(nextJobs)
}
/** Merge freshly completed outputs into the local catalog — never refetch the whole library. */
@@ -1559,7 +1624,14 @@ async function queue(){
logTiming('done')
}
}
async function cancel(job:any){await $fetch('/api/studio-2/cancel',{method:'POST',body:{id:job.id}});await refreshJobs()}
async function cancel(job:any){
if(job?.kind==='music' || job?.kind==='caption'){
await $fetch(`/api/studio-queue/${job.id}`,{method:'DELETE'})
}else{
await $fetch('/api/studio-2/cancel',{method:'POST',body:{id:job.id}})
}
await refreshJobs()
}
async function loadLibraryOnce(){
// Current folder page only — never the yearly Inbox JSON on /