Files
aigen/components/QueuedJobModal.vue
T
TowstyandCursor 1206afe8fd Keep family pre/post text on shot queues and wrap the mid prompt at Comfy time.
Later shots were starting a new job without those wrappers, so only the first clip got them.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-03 07:17:00 -05:00

449 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 class="block text-sm">
<span class="mb-1 block font-medium text-zinc-300">Pre-text</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 class="block text-sm">
<span class="mb-1 block font-medium text-zinc-300">Post-text</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 { restorePromptParts } from '~/utils/promptParts'
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.promptMid = 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 || ''
const parts = restorePromptParts(payload.promptMid || payload.prompt || next.prompt || '', {
pre: payload.promptPre,
prompt: payload.promptMid || payload.prompt || next.prompt || '',
post: payload.promptPost
})
draft.prompt = parts.prompt
draft.promptPre = parts.pre
draft.promptPost = parts.post
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,
promptMid: job.value.kind === 'music' ? undefined : 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>