Let queued video, image, and music jobs open in an inspect modal.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-09-03 00:15:02 -05:00
co-authored by Cursor
parent 53990192e4
commit f05f8f3597
12 changed files with 1257 additions and 37 deletions
+440
View File
@@ -0,0 +1,440 @@
<template>
<Teleport to="body">
<div
v-if="jobId"
class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/70 p-4 sm:p-8"
@click.self="close"
>
<div class="glass w-full max-w-3xl rounded-3xl p-5 sm:p-6">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="text-[11px] uppercase tracking-[0.18em] text-zinc-500">{{ kindLabel }}</p>
<h2 class="font-display text-2xl font-bold text-zinc-100">{{ draft.name || 'Queued job' }}</h2>
<p class="mt-1 text-sm text-zinc-400">{{ statusLine }}</p>
</div>
<button type="button" class="rounded-full border border-white/10 px-3 py-1 text-sm text-zinc-400 hover:text-white" @click="close">Close</button>
</div>
<p v-if="error" class="mt-4 rounded-2xl border border-red-400/30 bg-red-400/10 px-4 py-3 text-sm text-red-200">{{ error }}</p>
<p v-else-if="notice" class="mt-4 rounded-2xl border border-amber-300/30 bg-amber-400/10 px-4 py-3 text-sm text-amber-100">{{ notice }}</p>
<p v-else-if="loading" class="mt-4 text-sm text-zinc-400">Loading job…</p>
<div v-if="job" class="mt-5 space-y-4">
<div v-if="thumbUrl" class="overflow-hidden rounded-2xl border border-white/10 bg-black/40">
<img :src="thumbUrl" alt="" class="max-h-56 w-full object-contain">
</div>
<p v-if="job.lastError" class="rounded-2xl border border-red-400/30 bg-red-400/10 px-4 py-3 text-sm text-red-200">{{ job.lastError }}</p>
<p v-if="!editable && job.kind !== 'music'" class="text-xs text-zinc-500">This one is generating. You can copy it or save a template. Waiting jobs can be edited here.</p>
<p v-else-if="!editable && job.kind === 'music'" class="text-xs text-zinc-500">Music that’s already on the GPU can’t be rewritten. Copy it, save a template, or load it into Music.</p>
<label class="block text-sm">
<span class="mb-1 block font-medium text-zinc-300">Name</span>
<input
v-model="draft.name"
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 focus:ring-2 disabled:opacity-70"
:disabled="!editable"
maxlength="80"
>
</label>
<template v-if="job.kind === 'music'">
<label class="block text-sm">
<span class="mb-1 block font-medium text-zinc-300">Tags</span>
<textarea v-model="draft.tags" rows="3" class="max-h-32 w-full overflow-y-auto rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 [overflow-wrap:anywhere] focus:ring-2 disabled:opacity-70" :disabled="!editable" />
</label>
<label class="flex items-center gap-2 text-sm text-zinc-300">
<input v-model="draft.instrumental" type="checkbox" class="accent-amber-400" :disabled="!editable">
Instrumental
</label>
<label v-if="!draft.instrumental" class="block text-sm">
<span class="mb-1 block font-medium text-zinc-300">Lyrics</span>
<textarea v-model="draft.lyrics" rows="8" class="max-h-64 w-full overflow-y-auto rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 [overflow-wrap:anywhere] focus:ring-2 disabled:opacity-70" :disabled="!editable" />
</label>
</template>
<template v-else>
<label v-if="draft.promptPre" class="block text-sm">
<span class="mb-1 block font-medium text-zinc-300">Prompt pre</span>
<textarea v-model="draft.promptPre" rows="2" class="max-h-28 w-full overflow-y-auto rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 [overflow-wrap:anywhere] focus:ring-2 disabled:opacity-70" :disabled="!editable" />
</label>
<label class="block text-sm">
<span class="mb-1 block font-medium text-zinc-300">{{ shots.length > 1 ? (job.kind === 'edit' ? 'Pass 1' : 'Shot 1') : 'Prompt' }}</span>
<textarea v-model="draft.prompt" rows="5" class="max-h-48 w-full overflow-y-auto rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 [overflow-wrap:anywhere] focus:ring-2 disabled:opacity-70" :disabled="!editable" />
</label>
<label v-if="draft.promptPost" class="block text-sm">
<span class="mb-1 block font-medium text-zinc-300">Prompt post</span>
<textarea v-model="draft.promptPost" rows="2" class="max-h-28 w-full overflow-y-auto rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 [overflow-wrap:anywhere] focus:ring-2 disabled:opacity-70" :disabled="!editable" />
</label>
<label v-if="job.kind === 'edit'" class="block text-sm">
<span class="mb-1 block font-medium text-zinc-300">Negative</span>
<textarea v-model="draft.negative" rows="2" class="max-h-28 w-full overflow-y-auto rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 [overflow-wrap:anywhere] focus:ring-2 disabled:opacity-70" :disabled="!editable" />
</label>
</template>
<div v-if="extraShots.length" class="space-y-3">
<p class="text-xs text-zinc-500">{{ job.kind === 'edit' ? 'Later passes' : 'Later shots' }} queued with this job.</p>
<div v-for="(shot, index) in extraShots" :key="index" class="rounded-2xl border border-white/10 bg-zinc-950/50 px-3 py-3">
<p class="text-sm font-medium text-zinc-200">{{ job.kind === 'edit' ? `Pass ${index + 2}` : `Shot ${index + 2}` }}</p>
<textarea v-model="shot.prompt" rows="3" class="mt-2 max-h-36 w-full overflow-y-auto rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 [overflow-wrap:anywhere] focus:ring-2 disabled:opacity-70" :disabled="!editable" />
<label v-if="job.kind !== 'edit'" class="mt-2 block text-xs text-zinc-400">
Duration
<input v-model.number="shot.duration" type="number" min="0.5" max="120" step="0.1" class="mt-1 w-28 rounded-lg border border-white/10 bg-zinc-950 px-2 py-1 text-sm disabled:opacity-70" :disabled="!editable">
</label>
</div>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<label v-if="job.kind !== 'edit' || draft.duration" class="block text-sm">
<span class="mb-1 block font-medium text-zinc-300">Duration</span>
<input v-model.number="draft.duration" type="number" min="0.5" max="180" step="0.1" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm disabled:opacity-70" :disabled="!editable">
</label>
<label class="block text-sm">
<span class="mb-1 block font-medium text-zinc-300">Seed</span>
<input v-model="draft.seed" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm disabled:opacity-70" :disabled="!editable">
</label>
<label class="block text-sm">
<span class="mb-1 block font-medium text-zinc-300">Steps</span>
<input v-model.number="draft.steps" type="number" min="1" max="100" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm disabled:opacity-70" :disabled="!editable">
</label>
<label class="block text-sm">
<span class="mb-1 block font-medium text-zinc-300">CFG</span>
<input v-model.number="draft.cfg" type="number" min="0" max="20" step="0.1" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm disabled:opacity-70" :disabled="!editable">
</label>
<label v-if="job.kind === 'video'" class="block text-sm">
<span class="mb-1 block font-medium text-zinc-300">FPS</span>
<input v-model.number="draft.fps" type="number" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm disabled:opacity-70" :disabled="!editable">
</label>
<p v-if="metaLine" class="sm:col-span-2 text-xs text-zinc-500">{{ metaLine }}</p>
</div>
<label v-if="job.kind === 'video'" class="flex items-center gap-2 text-sm text-zinc-300">
<input v-model="draft.turbo" type="checkbox" class="accent-amber-400" :disabled="!editable">
Turbo
</label>
<label v-if="job.kind === 'video'" class="flex items-center gap-2 text-sm text-zinc-300">
<input v-model="draft.sound" type="checkbox" class="accent-amber-400" :disabled="!editable">
Sound
</label>
<div v-if="job.kind !== 'music'">
<LoraSelect
v-if="editable"
:model-value="draft.loraStack"
:names="loraNames"
:triggers="loraTriggers"
:aliases="loraAliases"
:engine="loraEngine"
label="LoRA"
compact
@update:model-value="draft.loraStack = $event"
/>
<p v-else-if="loraLine" class="text-xs text-zinc-500">{{ loraLine }}</p>
</div>
<div class="rounded-2xl border border-white/10 bg-zinc-950/40 p-3">
<label class="block text-sm">
<span class="mb-1 block font-medium text-zinc-300">Template name</span>
<input v-model="presetName" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 focus:ring-2" maxlength="80" placeholder="Save these settings as a preset">
</label>
</div>
<div class="flex flex-wrap gap-2">
<button type="button" class="rounded-full border border-white/15 px-3 py-1.5 text-xs text-zinc-200 hover:border-amber-300/40" @click="copyJob">Copy</button>
<button type="button" class="rounded-full border border-white/15 px-3 py-1.5 text-xs text-zinc-200 hover:border-amber-300/40" :disabled="busy" @click="saveTemplate">Save as template</button>
<button v-if="editable" type="button" class="rounded-full bg-amber-400 px-3 py-1.5 text-xs font-semibold text-zinc-950 disabled:opacity-50" :disabled="busy" @click="saveChanges">Save changes</button>
<button type="button" class="rounded-full border border-sky-300/40 px-3 py-1.5 text-xs text-sky-100 hover:border-sky-200" @click="loadAway">{{ job.kind === 'music' ? 'Load into Music' : 'Load into Studio' }}</button>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import {
applyQueuedShots,
LOAD_MUSIC_JOB_KEY,
LOAD_QUEUED_JOB_KEY,
queuedJobCopyText,
queuedJobEditable,
queuedJobKindLabel,
queuedJobPresetBody,
queuedJobShots,
type QueuedInspectJob,
type QueuedInspectPayload,
type QueuedShotDraft
} from '~/utils/queuedJob'
import { loraMatchesUse, loraStackLabel, persistLoraFields, readLoraStack, type LoraStackItem } from '~/utils/loras'
const props = defineProps<{
jobId?: string | null
}>()
const emit = defineEmits<{
close: []
saved: []
loaded: []
}>()
const job = ref<QueuedInspectJob | null>(null)
const loading = ref(false)
const busy = ref(false)
const error = ref('')
const notice = ref('')
const presetName = ref('')
const loraNames = ref<string[]>([])
const loraTriggers = ref<Record<string, string>>({})
const loraAliases = ref<Record<string, string>>({})
const draft = reactive({
name: '',
prompt: '',
promptPre: '',
promptPost: '',
negative: '',
tags: '',
lyrics: '',
instrumental: false,
duration: 5,
seed: '',
steps: 20,
cfg: 1,
fps: 24,
turbo: true,
sound: true,
loraStack: [] as LoraStackItem[]
})
const extraShots = ref<QueuedShotDraft[]>([])
const kindLabel = computed(() => job.value ? queuedJobKindLabel(job.value) : 'Job')
const editable = computed(() => job.value ? queuedJobEditable(job.value) : false)
const shots = computed(() => job.value ? queuedJobShots(currentPayload(), job.value.kind) : [])
const thumbUrl = computed(() => {
const stillId = job.value?.payload?.stillId
if (!stillId || job.value?.payload?.hideThumbnail || job.value?.payload?.folderLocked) return ''
return `/api/library/stills/${stillId}`
})
const loraLine = computed(() => loraStackLabel(draft.loraStack, loraAliases.value))
const loraEngine = computed(() => {
const engine = job.value?.payload?.engine
return engine === 'flux' || engine === 'krea' ? engine : ''
})
const metaLine = computed(() => {
const payload = job.value?.payload
if (!payload) return ''
return [
payload.workflow,
payload.v2Mode,
payload.engine,
payload.aspect,
payload.samplerName,
payload.scheduler
].filter(Boolean).join(' · ')
})
const statusLine = computed(() => {
if (!job.value) return ''
if (job.value.status === 'waiting') return 'Waiting in the queue'
if (job.value.status === 'held') return 'Paused'
if (job.value.status === 'running') return 'Generating'
if (job.value.status === 'error') return 'Failed'
return job.value.status
})
function currentPayload(): QueuedInspectPayload {
const base = { ...(job.value?.payload || {}) }
base.name = draft.name
base.prompt = draft.prompt
base.promptPre = draft.promptPre
base.promptPost = draft.promptPost
base.negative = draft.negative
base.tags = draft.tags
base.lyrics = draft.lyrics
base.instrumental = draft.instrumental
base.duration = draft.duration
base.steps = draft.steps
base.cfg = draft.cfg
base.fps = draft.fps
base.turbo = draft.turbo
base.sound = draft.sound
const seed = Number(draft.seed)
if (Number.isFinite(seed)) base.seed = seed
const fields = persistLoraFields(draft.loraStack)
base.loraName = fields.loraName
base.loraStack = fields.loraStack
if (!job.value) return base
return applyQueuedShots(job.value.kind, [
{ prompt: draft.prompt, duration: draft.duration, loraName: fields.loraName, loraStack: fields.loraStack },
...extraShots.value
], base)
}
function hydrate(next: QueuedInspectJob) {
job.value = next
const payload = next.payload || {}
draft.name = payload.name || next.name || ''
draft.prompt = payload.prompt || next.prompt || ''
draft.promptPre = payload.promptPre || ''
draft.promptPost = payload.promptPost || ''
draft.negative = payload.negative || ''
draft.tags = payload.tags || (next.kind === 'music' ? (payload.prompt || next.prompt) : '') || ''
draft.lyrics = payload.lyrics || ''
draft.instrumental = payload.instrumental === true
draft.duration = Number(payload.duration) || 5
draft.seed = payload.seed != null ? String(payload.seed) : ''
draft.steps = Number(payload.steps) || 20
draft.cfg = Number(payload.cfg) || 1
draft.fps = Number(payload.fps) || 24
draft.turbo = payload.turbo !== false
draft.sound = payload.sound !== false
draft.loraStack = readLoraStack(payload)
extraShots.value = queuedJobShots(payload, next.kind).slice(1).map(item => ({ ...item }))
presetName.value = draft.name || kindLabel.value
}
async function loadJob(id: string) {
loading.value = true
error.value = ''
notice.value = ''
job.value = null
try {
const data = await $fetch<QueuedInspectJob>(`/api/studio-queue/${id}`)
hydrate(data)
const listed = await $fetch<{ video?: string[]; image?: string[] }>('/api/loras').catch(() => ({ video: [], image: [] }))
const library = await $fetch<{ loraTriggers?: Record<string, string>; loraAliases?: Record<string, string> }>('/api/library?light=1').catch(() => ({}))
if (library.loraTriggers) loraTriggers.value = library.loraTriggers
if (library.loraAliases) loraAliases.value = library.loraAliases
const video = listed.video || []
const image = listed.image || []
if (data.kind === 'edit') {
const engine = data.payload?.engine === 'krea' ? 'krea' : data.payload?.engine === 'flux' ? 'flux' : ''
loraNames.value = engine
? image.filter(name => loraMatchesUse(name, engine))
: image
} else {
loraNames.value = video.filter(name => loraMatchesUse(name, 'minimax') || loraMatchesUse(name, 'ltx'))
}
} catch (err: any) {
error.value = err?.data?.statusMessage || err?.statusMessage || err?.message || 'Could not load that job'
} finally {
loading.value = false
}
}
async function copyJob() {
if (!job.value) return
const text = queuedJobCopyText({ ...job.value, name: draft.name, prompt: draft.prompt, payload: currentPayload() })
try {
await navigator.clipboard.writeText(text)
notice.value = 'Copied to the clipboard.'
} catch {
error.value = 'Could not copy. Select the fields and copy them yourself.'
}
}
async function saveTemplate() {
if (!job.value) return
const name = presetName.value.trim()
if (!name) {
error.value = 'Name the template first.'
return
}
busy.value = true
error.value = ''
try {
await $fetch('/api/generation-presets', {
method: 'POST',
body: queuedJobPresetBody({ ...job.value, name: draft.name, payload: currentPayload() }, name)
})
notice.value = `Saved template “${name}”.`
} catch (err: any) {
error.value = err?.data?.statusMessage || err?.statusMessage || err?.message || 'Could not save that template'
} finally {
busy.value = false
}
}
async function saveChanges() {
if (!job.value || !editable.value) return
busy.value = true
error.value = ''
try {
const payload = currentPayload()
const saved = await $fetch<QueuedInspectJob>(`/api/studio-queue/${job.value.id}`, {
method: 'PATCH',
body: {
name: payload.name,
prompt: job.value.kind === 'music' ? payload.tags : payload.prompt,
tags: payload.tags,
lyrics: payload.lyrics,
instrumental: payload.instrumental,
promptPre: payload.promptPre,
promptPost: payload.promptPost,
negative: payload.negative,
duration: payload.duration,
seed: payload.seed,
steps: payload.steps,
cfg: payload.cfg,
fps: payload.fps,
turbo: payload.turbo,
sound: payload.sound,
loraStack: payload.loraStack,
extensions: payload.extensions,
passes: payload.passes
}
})
hydrate({ ...job.value, ...saved, payload: saved.payload || payload, source: 'studio' })
notice.value = 'Saved. The queue will use this the next time it runs.'
emit('saved')
} catch (err: any) {
error.value = err?.data?.statusMessage || err?.statusMessage || err?.message || 'Could not save those changes'
} finally {
busy.value = false
}
}
function loadAway() {
if (!job.value) return
try {
if (job.value.kind === 'music') {
sessionStorage.setItem(LOAD_MUSIC_JOB_KEY, JSON.stringify({
name: draft.name,
tags: draft.tags,
lyrics: draft.lyrics,
instrumental: draft.instrumental,
duration: draft.duration,
steps: draft.steps,
seed: draft.seed,
folderId: job.value.payload.folderId || ''
}))
if (window.location.pathname !== '/music') {
void navigateTo('/music')
}
} else {
sessionStorage.setItem(LOAD_QUEUED_JOB_KEY, job.value.id)
if (window.location.pathname !== '/') {
void navigateTo('/')
}
}
} catch { /* ignore */ }
emit('loaded')
close()
}
function close() {
emit('close')
}
function onKey(event: KeyboardEvent) {
if (event.key === 'Escape' && props.jobId) {
event.preventDefault()
close()
}
}
watch(() => props.jobId, (id) => {
if (id) void loadJob(id)
else job.value = null
}, { immediate: true })
onMounted(() => window.addEventListener('keydown', onKey))
onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
</script>