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>
+154 -7
View File
@@ -1177,7 +1177,7 @@
<div class="flex items-start justify-between gap-3">
<div>
<h3 class="text-sm font-semibold text-zinc-100">Job queue</h3>
<p class="mt-1 text-xs text-zinc-500">Each submit is one job. Shots inside a job live on the Queue page.</p>
<p class="mt-1 text-xs text-zinc-500">Click a job to inspect it. Shots inside a job live on the Queue page.</p>
</div>
<NuxtLink to="/queue" class="shrink-0 text-xs text-amber-200 hover:text-white">Open Queue</NuxtLink>
</div>
@@ -1192,17 +1192,17 @@
? 'border-sky-300/30 bg-sky-400/5'
: 'border-white/10 bg-zinc-950/50'"
>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-zinc-100">{{ job.name }}</p>
<button type="button" class="min-w-0 flex-1 text-left" @click="inspectJobId = job.id">
<p class="truncate text-sm font-medium text-zinc-100 hover:text-white">{{ job.name }}</p>
<p class="mt-0.5 text-xs text-zinc-500">
<span v-if="job.kind === 'music'" class="mr-1 text-amber-200/80">MUSIC ·</span>
<span v-else-if="job.kind === 'edit'" class="mr-1 text-amber-200/80">{{ job.imagePipeline === 'v2' ? 'IMAGE V2' : ((job.shotCount || 1) > 1 ? 'EDIT' : 'IMAGE') }} ·</span>
{{ studioJobStatusLabel(job) }}
· {{ studioJobShotLine(job) }}
</p>
</div>
</button>
<button
v-if="job.status === 'waiting'"
v-if="job.status === 'waiting' && job.kind !== 'music'"
type="button"
class="shrink-0 rounded-full border border-sky-300/40 px-2.5 py-1 text-xs text-sky-100 hover:border-sky-200 hover:text-white"
@click="cutInStudioJob(job.id)"
@@ -1210,7 +1210,7 @@
{{ job.cutIn ? 'Next' : (canCutInStudioJob ? 'Run next' : 'Start now') }}
</button>
<button
v-if="job.status === 'error' || (job.status === 'held' && !job.holdForCutIn)"
v-if="job.status === 'error' || (job.status === 'held' && !job.holdForCutIn && job.kind !== 'music')"
type="button"
class="shrink-0 rounded-full border border-amber-300/40 px-2.5 py-1 text-xs text-amber-100 hover:border-amber-200 hover:text-white"
@click="retryStudioJob(job.id)"
@@ -2193,6 +2193,13 @@
</div>
</Teleport>
<QueuedJobModal
:job-id="inspectJobId"
@close="inspectJobId = ''"
@saved="refreshStudioQueue"
@loaded="onQueuedJobLoaded"
/>
<div class="pointer-events-none fixed bottom-4 right-4 z-50 space-y-2">
<div
v-for="toast in toasts"
@@ -2258,6 +2265,7 @@ import {
VIDEO_CFG_STEP,
type GenerationPreset
} from '~/utils/generationPresets'
import { LOAD_QUEUED_JOB_KEY } from '~/utils/queuedJob'
import {
clipSettingsRows,
inferStillPipeline,
@@ -2781,6 +2789,7 @@ const studioJobs = ref<Array<{
pendingCount?: number
imagePipeline?: string
}>>([])
const inspectJobId = ref('')
const outputStudioJobId = ref('')
const videoAwaitingBeast = ref(false)
const pickerOpen = ref(false)
@@ -3980,6 +3989,7 @@ onMounted(async () => {
await loadPromptPresets()
if (route.query.clip || route.query.still) applyIncomingLibraryItem()
else applyIncomingDirectorScript()
await applyIncomingQueuedJob()
window.addEventListener('keydown', onStudioKey)
})
@@ -4416,6 +4426,27 @@ function applyIncomingDirectorScript() {
} catch { /* ignore */ }
}
async function applyIncomingQueuedJob() {
let id = ''
try {
id = sessionStorage.getItem(LOAD_QUEUED_JOB_KEY) || ''
if (id) sessionStorage.removeItem(LOAD_QUEUED_JOB_KEY)
} catch {
return
}
if (!id) return
try {
await restoreStudioJob(id)
toast('Queued job loaded into Input.')
} catch (error: any) {
toast(error?.data?.statusMessage || error?.message || 'Could not load that queued job')
}
}
function onQueuedJobLoaded() {
void applyIncomingQueuedJob()
}
async function loadGenerationPresets() {
const [video, image, imagev2] = await Promise.all([
$fetch<{ presets?: GenerationPreset[] }>('/api/generation-presets', { query: { kind: 'video' } }).catch(() => ({ presets: [] })),
@@ -5205,7 +5236,7 @@ async function refreshStudioQueue() {
}>
}>('/api/studio-queue').catch(() => ({ jobs: [] }))
studioJobs.value = data.jobs || []
const running = studioJobs.value.find(job => job.status === 'running' && job.liveJobId)
const running = studioJobs.value.find(job => job.status === 'running' && job.liveJobId && job.kind !== 'music')
if (running?.liveJobId) videoAwaitingBeast.value = false
if (watchingLiveVideo() && running?.liveJobId && running.liveJobId !== videoJobId.value) {
return
@@ -5274,6 +5305,102 @@ async function dismissStudioJob(id: string) {
}
}
async function restoreStudioEditJob(payload: {
prompt?: string
name?: string
folderId?: string
aspect?: string
width?: number
height?: number
stillId?: string
stillFilename?: string
hideInput?: boolean
promptPre?: string
promptPost?: string
loraName?: string
loraStack?: LoraStackItem[]
negative?: string
imagePipeline?: 'v1' | 'v2'
v2Mode?: 'edit' | 'compose' | 'refine' | 'generate'
v2Task?: 'scene' | 'identity' | 'outfit' | 'face_lock' | 'refine' | 't2i'
engine?: 'flux' | 'krea'
snofsModel?: number
snofsClip?: number
consistencyModel?: number
consistencyClip?: number
refineStrength?: number
scaleToTotalPixels?: boolean
scaleMegapixels?: number
steps?: number
cfg?: number
turbo?: boolean
referenceStillId?: string
}) {
closeExtend()
applyPromptRestore(payload.prompt || '', payload)
clipName.value = payload.name || ''
if (payload.folderId) {
folderId.value = payload.folderId
browseFolderId.value = payload.folderId
}
const v2 = payload.imagePipeline === 'v2'
if (v2) {
studioMode.value = 'editv2'
outputFocus.value = 'edit'
const mode = payload.v2Mode === 'compose' || payload.v2Mode === 'refine' || payload.v2Mode === 'generate'
? payload.v2Mode
: 'edit'
setV2Mode(mode)
persistImageV2LoraStack(imageV2LoraStack.value, v2Engine.value)
v2Engine.value = parseImageV2Engine(payload.engine, 'flux')
if (typeof payload.negative === 'string') v2Negative.value = payload.negative
if (payload.steps != null) v2Steps.value = clampImageSteps(payload.steps, v2Engine.value === 'krea' ? IMAGE_V2_KREA_STEPS : IMAGE_V2_STEPS_DEFAULT)
if (payload.cfg != null) v2Cfg.value = clampImageCfg(payload.cfg, v2Engine.value === 'krea' ? IMAGE_V2_KREA_CFG : IMAGE_V2_CFG_DEFAULT)
if (payload.scaleMegapixels != null) v2Megapixels.value = clampImageScaleMegapixels(payload.scaleMegapixels, 1)
v2ScaleOn.value = mode !== 'generate' && payload.scaleToTotalPixels !== false
v2Turbo.value = payload.turbo === true
if ((mode === 'refine' || (v2Engine.value === 'krea' && mode !== 'generate')) && payload.refineStrength != null) {
v2Denoise.value = clampImageV2Denoise(payload.refineStrength)
}
aspect.value = parseImageAspect(payload.aspect)
if (mode === 'generate') {
const size = generateSizeForAspect(aspect.value)
v2Width.value = size.width
v2Height.value = size.height
}
if (mode === 'compose' && payload.v2Task) v2Task.value = payload.v2Task
imageV2LoraStack.value = mergeLegacyImageV2Loras(readLoraStack(payload), {
engine: v2Engine.value,
mode,
xaigen: v2ConceptLoraEnabled.value,
snofsModel: payload.snofsModel,
snofsClip: payload.snofsClip,
consistencyModel: payload.consistencyModel,
consistencyClip: payload.consistencyClip,
kreaConcept: String(runtimeConfig.public.kreaConceptLora || IMAGE_V2_KREA_CONCEPT_LORA || '').trim()
})
} else {
studioMode.value = 'edit'
outputFocus.value = 'edit'
if (payload.steps != null) editSteps.value = clampImageSteps(payload.steps)
if (payload.cfg != null) editCfg.value = clampImageCfg(payload.cfg)
if (typeof payload.negative === 'string') editNegative.value = payload.negative
editScaleToTotalPixels.value = payload.scaleToTotalPixels === true
if (payload.scaleMegapixels != null) editScaleMegapixels.value = clampImageScaleMegapixels(payload.scaleMegapixels)
imageLoraStack.value = filterLoraStackForStudio(readLoraStack(payload), ltxEnabled.value)
aspect.value = parseImageAspect(payload.aspect)
}
const generate = v2 && payload.v2Mode === 'generate'
if (!generate && payload.stillId) {
const blob = await $fetch<Blob>(`/api/library/stills/${payload.stillId}`, { responseType: 'blob' })
if (payload.hideInput && showPrivacyToggles.value) hideInputPreview.value = true
readFile(new File([blob], stillFileName(stills.value.find(item => item.id === payload.stillId)) || payload.stillFilename || 'still.png', { type: blob.type || 'image/png' }), { stillId: payload.stillId })
}
if (v2 && payload.v2Mode === 'compose' && payload.referenceStillId) {
await loadStillAsEditRef(payload.referenceStillId).catch(() => null)
}
}
async function restoreStudioJob(id: string) {
const job = await $fetch<{
payload?: {
@@ -5309,10 +5436,30 @@ async function restoreStudioJob(id: string) {
loraStack?: LoraStackItem[]
shotLoras?: string[]
shotLoraStacks?: LoraStackItem[][]
negative?: string
imagePipeline?: 'v1' | 'v2'
v2Mode?: 'edit' | 'compose' | 'refine' | 'generate'
v2Task?: 'scene' | 'identity' | 'outfit' | 'face_lock' | 'refine' | 't2i'
engine?: 'flux' | 'krea'
snofsModel?: number
snofsClip?: number
consistencyModel?: number
consistencyClip?: number
refineStrength?: number
scaleToTotalPixels?: boolean
scaleMegapixels?: number
steps?: number
referenceStillId?: string
}
kind?: string
imagePipeline?: string
}>(`/api/studio-queue/${id}`)
const payload = job.payload
if (!payload) throw new Error('That job has no saved inputs')
if (job.kind === 'edit' || payload.imagePipeline === 'v2') {
await restoreStudioEditJob(payload)
return
}
studioMode.value = 'video'
outputFocus.value = 'video'
closeExtend()
+76 -1
View File
@@ -124,14 +124,32 @@
<p v-if="blockReason" class="text-xs text-zinc-400">{{ blockReason }}</p>
<p v-else-if="queued" class="text-xs text-zinc-400">
Waiting in the job queue.
<button type="button" class="text-amber-200 hover:text-amber-100" @click="inspectJobId = studioJobId || liveMusicId">Inspect</button>
·
<NuxtLink to="/queue" class="text-amber-200 hover:text-amber-100">Open Queue</NuxtLink>
</p>
<div v-if="musicPresets.length" class="rounded-2xl border border-white/10 bg-zinc-950/40 p-4">
<label class="block text-sm">
<span class="mb-1 block font-medium text-zinc-300">Templates</span>
<select v-model="presetId" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm" @change="applyPreset">
<option value="">Load a saved template</option>
<option v-for="preset in musicPresets" :key="preset.id" :value="preset.id">{{ preset.name }}</option>
</select>
</label>
</div>
</section>
<section class="glass min-w-0 max-w-full space-y-4 rounded-3xl p-5 sm:p-6">
<div>
<h2 class="font-display text-2xl font-bold">Output</h2>
<p class="mt-1 text-sm text-zinc-400">{{ status || 'Waiting for a generate.' }}</p>
<button
v-if="studioJobId || liveMusicId"
type="button"
class="mt-2 text-xs text-amber-200 hover:text-amber-100"
@click="inspectJobId = studioJobId || liveMusicId"
>Inspect this job</button>
</div>
<div v-if="busy" class="h-2 overflow-hidden rounded-full bg-zinc-800">
<div class="progress-glow h-full rounded-full bg-amber-400 transition-[width]" :style="{ width: `${Math.max(progress, 4)}%` }" />
@@ -150,6 +168,7 @@
</p>
</section>
</main>
<QueuedJobModal :job-id="inspectJobId" @close="inspectJobId = ''" />
</div>
</template>
@@ -167,6 +186,8 @@ import {
} from '~/utils/music'
import { ensureGenerationNotifyPermission, notifyGenerationComplete } from '~/utils/generationNotify'
import type { LibraryFolder } from '~/utils/libraryBrowse'
import { LOAD_MUSIC_JOB_KEY } from '~/utils/queuedJob'
import type { GenerationPreset, MusicPresetSettings } from '~/utils/generationPresets'
const tagsPlaceholder = DEFAULT_MUSIC_TAGS
const lyricsPlaceholder = DEFAULT_MUSIC_LYRICS
@@ -197,6 +218,10 @@ const studioJobId = ref('')
const queued = ref(false)
const queueCount = ref(0)
const downloadName = ref('track.flac')
const inspectJobId = ref('')
const liveMusicId = ref('')
const musicPresets = ref<GenerationPreset[]>([])
const presetId = ref('')
let source: EventSource | null = null
let listenGen = 0
let settled = false
@@ -253,12 +278,15 @@ async function refreshStudioQueue() {
jobs?: Array<{
id: string
status: string
kind?: string
liveJobId?: string
lastError?: string
}>
waitingCount?: number
}>('/api/studio-queue').catch(() => ({ jobs: [] as Array<{ id: string; status: string; liveJobId?: string; lastError?: string }>, waitingCount: 0 }))
}>('/api/studio-queue').catch(() => ({ jobs: [] as Array<{ id: string; status: string; kind?: string; liveJobId?: string; lastError?: string }>, waitingCount: 0 }))
const rows = data.jobs || []
const musicRow = rows.find(job => job.kind === 'music' && (job.status === 'waiting' || job.status === 'running' || job.status === 'held'))
liveMusicId.value = musicRow?.id || ''
queueCount.value = data.waitingCount || rows.filter(job => job.status === 'waiting').length
if (!studioJobId.value) return
const row = rows.find(job => job.id === studioJobId.value)
@@ -406,8 +434,55 @@ watch(instrumental, (on) => {
if (on && !lyrics.value.trim()) lyrics.value = DEFAULT_MUSIC_LYRICS
})
function applyIncomingMusic() {
try {
const raw = sessionStorage.getItem(LOAD_MUSIC_JOB_KEY)
if (!raw) return
sessionStorage.removeItem(LOAD_MUSIC_JOB_KEY)
const loaded = JSON.parse(raw) as {
name?: string
tags?: string
lyrics?: string
instrumental?: boolean
duration?: number
steps?: number
seed?: string
folderId?: string
}
if (typeof loaded.tags === 'string') tags.value = loaded.tags
if (typeof loaded.lyrics === 'string') lyrics.value = loaded.lyrics
instrumental.value = loaded.instrumental === true
if (typeof loaded.duration === 'number') duration.value = loaded.duration
if (typeof loaded.steps === 'number') steps.value = loaded.steps
if (typeof loaded.seed === 'string') seed.value = loaded.seed
if (typeof loaded.name === 'string') name.value = loaded.name
if (loaded.folderId && folders.value.some(folder => folder.id === loaded.folderId)) folderId.value = loaded.folderId
status.value = 'Loaded from the queue'
} catch { /* ignore */ }
}
async function loadMusicPresets() {
const data = await $fetch<{ presets?: GenerationPreset[] }>('/api/generation-presets', { query: { kind: 'music' } }).catch(() => ({ presets: [] }))
musicPresets.value = data.presets || []
}
function applyPreset() {
const preset = musicPresets.value.find(item => item.id === presetId.value)
if (!preset) return
const settings = preset.settings as MusicPresetSettings
if (typeof settings.tags === 'string') tags.value = settings.tags
if (typeof settings.lyrics === 'string') lyrics.value = settings.lyrics
instrumental.value = settings.instrumental === true
if (typeof settings.duration === 'number') duration.value = settings.duration
if (typeof settings.steps === 'number') steps.value = settings.steps
if (typeof settings.seed === 'number') seed.value = String(settings.seed)
name.value = preset.name
}
onMounted(async () => {
await loadLibrary()
await loadMusicPresets()
applyIncomingMusic()
await refreshStudioQueue()
stopQueuePoll()
queuePoll = setInterval(() => { void refreshStudioQueue() }, 8000)
+21 -25
View File
@@ -11,7 +11,7 @@
<div>
<h2 class="font-display text-2xl font-bold">Queue</h2>
<p class="mt-1 max-w-2xl text-sm text-zinc-400">
Jobs you submitted from Studio or Music. A long shot script is one job; its shots are the list underneath. Run next inserts a waiting job after the current shot as its own clip.
Click a job to inspect it, fix a waiting one, copy it, or save a template. A long shot script is one job; its shots are the list underneath.
</p>
</div>
<div class="flex flex-wrap items-center gap-2 sm:gap-3">
@@ -57,7 +57,7 @@
>
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0">
<div class="flex items-start gap-3">
<button type="button" class="flex min-w-0 items-start gap-3 text-left" @click="inspectJobId = job.id">
<img
v-if="jobThumb(job)"
:src="jobThumb(job)"
@@ -65,18 +65,18 @@
class="h-12 w-12 shrink-0 rounded-lg bg-black/40 object-cover"
>
<div class="min-w-0">
<p class="text-[11px] uppercase tracking-[0.18em] text-zinc-500">{{ jobKindLabel(job) }}</p>
<h3 class="truncate font-display text-lg font-semibold text-zinc-100">{{ job.name }}</h3>
<p class="mt-1 text-sm text-zinc-300">{{ jobLine(job) }}</p>
<p v-if="job.shots && liveMessage[job.shots.id]" class="mt-1 text-xs text-amber-100">{{ liveMessage[job.shots.id] }}</p>
<p v-else-if="liveMessage[job.id]" class="mt-1 text-xs text-amber-100">{{ liveMessage[job.id] }}</p>
<p v-if="job.lastError" class="mt-1 text-xs text-red-300">{{ job.lastError }}</p>
<p class="text-[11px] uppercase tracking-[0.18em] text-zinc-500">{{ jobKindLabel(job) }}</p>
<h3 class="truncate font-display text-lg font-semibold text-zinc-100 hover:text-white">{{ job.name }}</h3>
<p class="mt-1 text-sm text-zinc-300">{{ jobLine(job) }}</p>
<p v-if="job.shots && liveMessage[job.shots.id]" class="mt-1 text-xs text-amber-100">{{ liveMessage[job.shots.id] }}</p>
<p v-else-if="liveMessage[job.id]" class="mt-1 text-xs text-amber-100">{{ liveMessage[job.id] }}</p>
<p v-if="job.lastError" class="mt-1 text-xs text-red-300">{{ job.lastError }}</p>
</div>
</div>
</button>
</div>
<div class="flex flex-wrap gap-2">
<button
v-if="job.status === 'waiting'"
v-if="job.status === 'waiting' && job.kind !== 'music'"
type="button"
class="rounded-full border border-sky-300/40 px-3 py-1.5 text-xs text-sky-100 hover:border-sky-200 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="Boolean(busyId)"
@@ -410,6 +410,11 @@
</div>
</div>
</Teleport>
<QueuedJobModal
:job-id="inspectJobId"
@close="inspectJobId = ''"
@saved="loadQueues"
/>
</div>
</template>
@@ -477,6 +482,7 @@ type PendingRemove = {
const videoLoras = ref<string[]>([])
const loraTriggers = ref<Record<string, string>>({})
const loraAliases = ref<Record<string, string>>({})
const inspectJobId = ref('')
const jobs = ref<StudioJobRow[]>([])
const orphans = ref<Queue[]>([])
const waitingCount = ref(0)
@@ -573,20 +579,10 @@ function progressLine(queue: Queue) {
function jobLine(job: StudioJobRow) {
if (job.kind === 'music') {
if (job.status === 'waiting') {
return job.cutIn
? 'Next after the current shot · ACE-Step'
: 'Waiting · ACE-Step'
}
if (job.status === 'held') {
if (jobIsHeldPaused(job)) return 'Paused. This track will not continue until you resume.'
return job.holdForCutIn ? 'Paused so another job can cut in' : 'Paused'
}
if (job.status === 'running') {
if (jobIsLatched(job)) return 'Generating track · will pause after this'
return 'Generating track'
}
return job.status
if (job.status === 'waiting') return job.cutIn ? 'Next after the current shot · ACE-Step' : 'Waiting · ACE-Step'
if (job.status === 'held') return 'Paused'
if (job.status === 'error') return job.lastError || 'Failed'
return 'Generating track'
}
if (job.kind === 'edit') {
const passes = job.shotCount || 1
@@ -822,7 +818,7 @@ function jobKindLabel(job: StudioJobRow) {
if (job.imagePipeline === 'v2') return 'IMAGE V2'
return (job.shotCount || 1) > 1 ? 'EDIT' : 'IMAGE'
}
return 'Job'
return 'VIDEO'
}
function jobThumb(job: StudioJobRow) {
+1
View File
@@ -40,6 +40,7 @@ export default defineEventHandler((event) => {
]
return {
...summarizeStudioJob(job),
source: 'studio' as const,
...(full ? {
shots: shotSummary,
plannedShots: plannedShots && plannedShots.length > 1 ? plannedShots : undefined
+9
View File
@@ -1,8 +1,17 @@
import { cancelLiveMusicJob, findInspectJob } from '~/server/utils/queuedInspect'
import { cancelStudioJob } from '~/server/utils/studioQueue'
export default defineEventHandler(async (event) => {
const { owner } = assertLibraryOwner(event)
const id = String(getRouterParam(event, 'id') || '')
const job = findInspectJob(owner, id)
if (!job) {
throw createError({ statusCode: 404, statusMessage: 'Queued job not found' })
}
if (job.source === 'live' && job.kind === 'music') {
await cancelLiveMusicJob(owner, id)
return { ok: true }
}
await cancelStudioJob(owner, id)
return { ok: true }
})
+2 -2
View File
@@ -1,9 +1,9 @@
import { listStudioJobs } from '~/server/utils/studioQueue'
import { findInspectJob } from '~/server/utils/queuedInspect'
export default defineEventHandler((event) => {
const { owner } = assertLibraryOwner(event)
const id = String(getRouterParam(event, 'id') || '')
const job = listStudioJobs(owner).find(item => item.id === id)
const job = findInspectJob(owner, id)
if (!job) {
throw createError({ statusCode: 404, statusMessage: 'Queued job not found' })
}
+111
View File
@@ -0,0 +1,111 @@
import { persistLoraFields, normalizeLoraStack } from '~/utils/loras'
import { listStudioJobs, patchStudioJob, type StudioJobPayload } from '~/server/utils/studioQueue'
const EDITABLE = new Set(['waiting', 'held', 'error'])
function text(raw: unknown, max = 8000) {
return String(raw ?? '').replace(/\r\n/g, '\n').slice(0, max)
}
function optionalNumber(raw: unknown) {
if (raw == null || raw === '') return undefined
const value = Number(raw)
return Number.isFinite(value) ? value : undefined
}
function optionalBool(raw: unknown) {
if (raw === true || raw === 'true' || raw === 1 || raw === '1') return true
if (raw === false || raw === 'false' || raw === 0 || raw === '0') return false
return undefined
}
export default defineEventHandler(async (event) => {
const { owner } = assertLibraryOwner(event)
const id = String(getRouterParam(event, 'id') || '')
const current = listStudioJobs(owner).find(item => item.id === id)
if (!current) {
throw createError({ statusCode: 404, statusMessage: 'Queued job not found' })
}
if (!EDITABLE.has(current.status)) {
throw createError({ statusCode: 409, statusMessage: 'That job is already generating. Copy it or save a template.' })
}
const body = await readBody<Record<string, unknown>>(event).catch(() => ({}))
const job = await patchStudioJob(owner, id, (row) => {
const payload = row.payload
if (body.name != null) {
const name = text(body.name, 80).trim()
payload.name = name
row.name = name || payload.prompt.slice(0, 80)
}
if (body.prompt != null) {
payload.prompt = text(body.prompt)
row.prompt = payload.prompt
}
if (body.tags != null && row.kind === 'music') {
payload.prompt = text(body.tags, 2000)
row.prompt = payload.prompt
}
if (body.lyrics != null) payload.lyrics = text(body.lyrics)
const instrumental = optionalBool(body.instrumental)
if (instrumental != null) payload.instrumental = instrumental
if (body.promptPre != null) payload.promptPre = text(body.promptPre)
if (body.promptMid != null) payload.promptMid = text(body.promptMid)
if (body.promptPost != null) payload.promptPost = text(body.promptPost)
if (body.negative != null) payload.negative = text(body.negative, 2000)
const duration = optionalNumber(body.duration)
if (duration != null) payload.duration = Math.min(120, Math.max(0.5, duration))
const steps = optionalNumber(body.steps)
if (steps != null) payload.steps = Math.min(100, Math.max(1, Math.round(steps)))
const cfg = optionalNumber(body.cfg)
if (cfg != null) payload.cfg = Math.min(20, Math.max(0, Math.round(cfg * 10) / 10))
const fps = optionalNumber(body.fps)
if (fps === 12 || fps === 24 || fps === 30) payload.fps = fps
const seed = optionalNumber(body.seed)
if (seed != null) payload.seed = Math.max(0, Math.min(2_147_483_647, Math.floor(seed)))
const turbo = optionalBool(body.turbo)
if (turbo != null) payload.turbo = turbo
const sound = optionalBool(body.sound)
if (sound != null) payload.sound = sound
if (body.samplerName != null) payload.samplerName = text(body.samplerName, 40)
if (body.scheduler != null) payload.scheduler = text(body.scheduler, 40)
if (Array.isArray(body.loraStack) || body.loraName != null) {
const fields = persistLoraFields(body.loraStack ?? payload.loraStack ?? body.loraName)
payload.loraName = fields.loraName
payload.loraStack = fields.loraStack
}
if (Array.isArray(body.passes) && row.kind === 'edit') {
payload.passes = body.passes.map((item) => ({
prompt: text((item as { prompt?: unknown })?.prompt)
}))
row.shotCount = 1 + payload.passes.length
}
if (Array.isArray(body.extensions) && row.kind !== 'edit') {
payload.extensions = body.extensions.map((item, index) => {
const rec = (item && typeof item === 'object' ? item : {}) as {
prompt?: unknown
duration?: unknown
loraName?: unknown
loraStack?: unknown
}
const prev = payload.extensions?.[index]
const fields = persistLoraFields(rec.loraStack || rec.loraName || prev?.loraStack || prev?.loraName)
const shotDuration = optionalNumber(rec.duration)
return {
...prev,
prompt: text(rec.prompt),
duration: shotDuration != null ? Math.min(120, Math.max(0.5, shotDuration)) : (prev?.duration || payload.duration),
loraName: fields.loraName,
loraStack: fields.loraStack
}
}) as StudioJobPayload['extensions']
row.shotCount = 1 + payload.extensions.length
}
if (normalizeLoraStack(payload.loraStack).length && !payload.loraName) {
payload.loraName = payload.loraStack?.[0]?.name
}
if (body.prompt != null && !row.name) row.name = payload.prompt.slice(0, 80)
})
return job
})
+23
View File
@@ -13,6 +13,7 @@ import {
type GenerationPreset,
type GenerationPresetKind,
type ImagePresetSettings,
type MusicPresetSettings,
type VideoPresetSettings
} from '~/utils/generationPresets'
import {
@@ -35,6 +36,13 @@ import {
parseImageV2Task,
type ImageV2PresetSettings
} from '~/utils/imageV2'
import {
clampMusicCfg,
clampMusicDuration,
clampMusicSteps,
sanitizeMusicLyrics,
sanitizeMusicTags
} from '~/utils/music'
import { coerceVideoWorkflow, isXaigenStudio, parseVideoWorkflow } from '~/utils/videoModels'
const writeChains = new Map<string, Promise<unknown>>()
@@ -155,9 +163,24 @@ function sanitizeImageV2Settings(raw: unknown): ImageV2PresetSettings {
}
}
function sanitizeMusicSettings(raw: unknown): MusicPresetSettings {
const rec = raw && typeof raw === 'object' ? raw as Record<string, unknown> : {}
const instrumental = rec.instrumental === true
return {
tags: sanitizeMusicTags(rec.tags),
lyrics: sanitizeMusicLyrics(rec.lyrics, instrumental),
instrumental,
duration: clampMusicDuration(rec.duration),
steps: clampMusicSteps(rec.steps),
seed: typeof rec.seed === 'number' && Number.isFinite(rec.seed) ? Math.max(0, Math.floor(rec.seed)) : undefined,
cfg: rec.cfg == null ? undefined : clampMusicCfg(rec.cfg)
}
}
function sanitizeSettings(kind: GenerationPresetKind, raw: unknown) {
if (kind === 'image') return sanitizeImageSettings(raw)
if (kind === 'imagev2') return sanitizeImageV2Settings(raw)
if (kind === 'music') return sanitizeMusicSettings(raw)
return sanitizeVideoSettings(raw)
}
+118
View File
@@ -0,0 +1,118 @@
import { emitJob, getJob, listJobs, type Job } from '~/server/utils/jobs'
import { listStudioJobs, type StudioJob } from '~/server/utils/studioQueue'
const LIVE_ERROR_KEEP_MS = 60 * 60 * 1000
function liveMusicActive(job: Job) {
if (job.kind !== 'music') return false
if (job.status === 'queued' || job.status === 'uploading' || job.status === 'running') return true
if (job.status === 'error' && Date.now() - job.startedAt < LIVE_ERROR_KEEP_MS) return true
return false
}
export function listLiveMusicJobs(owner: string) {
return listJobs()
.filter(job => liveMusicActive(job) && job.library?.ownerKey === owner)
.sort((a, b) => b.startedAt - a.startedAt)
}
export function summarizeLiveMusicJob(job: Job) {
const lib = job.library
const tags = String(lib?.tags || lib?.prompt || '').trim()
return {
id: job.id,
createdAt: job.startedAt,
updatedAt: job.startedAt,
status: job.status === 'error' ? 'error' : 'running',
kind: 'music' as const,
name: (lib?.name || '').trim() || tags.slice(0, 80) || 'Music',
prompt: tags,
shotCount: 1,
liveJobId: job.id,
source: 'live' as const,
lastError: job.error,
hideThumbnail: true,
folderLocked: lib?.folderLocked === true
}
}
export function inspectStudioJob(job: StudioJob) {
return {
source: 'studio' as const,
id: job.id,
createdAt: job.createdAt,
updatedAt: job.updatedAt,
status: job.status,
kind: job.kind === 'edit' ? 'edit' as const : 'video' as const,
name: job.name,
prompt: job.prompt,
shotCount: job.shotCount,
familyId: job.familyId,
shotQueueId: job.shotQueueId,
liveJobId: job.liveJobId,
lastError: job.lastError,
imagePipeline: job.payload.imagePipeline || 'v1',
cutIn: job.cutIn === true,
holdForCutIn: job.holdForCutIn === true,
pauseAfterCurrent: job.pauseAfterCurrent === true,
pausedByUser: job.pausedByUser === true,
payload: job.kind === 'music'
? { ...job.payload, tags: job.payload.prompt }
: job.payload
}
}
export function inspectLiveMusicJob(job: Job) {
const lib = job.library
const tags = String(lib?.tags || lib?.prompt || '').trim()
return {
source: 'live' as const,
id: job.id,
createdAt: job.startedAt,
updatedAt: job.startedAt,
status: job.status === 'error' ? 'error' : job.status === 'cancelled' ? 'cancelled' : 'running',
kind: 'music' as const,
name: (lib?.name || '').trim() || tags.slice(0, 80) || 'Music',
prompt: tags,
shotCount: 1,
liveJobId: job.id,
lastError: job.error,
imagePipeline: undefined,
payload: {
name: lib?.name || '',
prompt: tags,
tags,
lyrics: lib?.lyrics || '',
instrumental: lib?.instrumental === true,
folderId: lib?.folderId || '',
duration: lib?.duration,
steps: lib?.steps,
seed: lib?.seed,
cfg: lib?.cfg,
engine: lib?.engine,
lyricsStrength: lib?.lyricsStrength,
hideThumbnail: true,
folderLocked: lib?.folderLocked === true
}
}
}
export function findInspectJob(owner: string, id: string) {
const studio = listStudioJobs(owner).find(item => item.id === id)
if (studio) return inspectStudioJob(studio)
const live = getJob(id)
if (live?.kind === 'music' && live.library?.ownerKey === owner) return inspectLiveMusicJob(live)
return null
}
export async function cancelLiveMusicJob(owner: string, id: string) {
const live = getJob(id)
if (!live || live.kind !== 'music' || live.library?.ownerKey !== owner) {
throw createError({ statusCode: 404, statusMessage: 'Queued job not found' })
}
live.status = 'cancelled'
if (live.library) live.library.stopAfterCurrent = true
emitJob(live, { type: 'error', error: 'Job interrupted.', message: 'Job interrupted.' })
const { interruptComfy } = await import('~/server/utils/comfy')
await interruptComfy().catch(() => false)
}
+13 -2
View File
@@ -2,7 +2,17 @@ import type { LoraStackItem } from '~/utils/loras'
import type { VideoWorkflowId } from '~/utils/videoModels'
import type { ImageV2PresetSettings } from '~/utils/imageV2'
export type GenerationPresetKind = 'video' | 'image' | 'imagev2'
export type GenerationPresetKind = 'video' | 'image' | 'imagev2' | 'music'
export type MusicPresetSettings = {
tags?: string
lyrics?: string
instrumental?: boolean
duration?: number
steps?: number
seed?: number
cfg?: number
}
export type VideoPresetSettings = {
workflow?: VideoWorkflowId
@@ -86,12 +96,13 @@ export type GenerationPreset = {
createdAt: number
updatedAt: number
loraStack: LoraStackItem[]
settings: VideoPresetSettings | ImagePresetSettings | ImageV2PresetSettings
settings: VideoPresetSettings | ImagePresetSettings | ImageV2PresetSettings | MusicPresetSettings
}
export function parseGenerationPresetKind(raw: unknown): GenerationPresetKind {
if (raw === 'image') return 'image'
if (raw === 'imagev2') return 'imagev2'
if (raw === 'music') return 'music'
return 'video'
}
+289
View File
@@ -0,0 +1,289 @@
import { persistLoraFields, readLoraStack, type LoraStackItem } from '~/utils/loras'
import {
clampMusicDuration,
clampMusicSteps,
sanitizeMusicLyrics,
sanitizeMusicTags
} from '~/utils/music'
export const LOAD_QUEUED_JOB_KEY = 'aigen-load-queued-job'
export const LOAD_MUSIC_JOB_KEY = 'aigen-load-music-job'
export type QueuedInspectKind = 'video' | 'edit' | 'music'
export type QueuedInspectSource = 'studio' | 'live'
export type QueuedShotDraft = {
prompt: string
duration: number
loraName?: string
loraStack?: LoraStackItem[]
}
export type QueuedInspectPayload = {
prompt?: string
promptMid?: string
promptPre?: string
promptPost?: string
name?: string
folderId?: string
aspect?: string
width?: number
height?: number
steps?: number
turbo?: boolean
seed?: number
cfg?: number
fps?: number
samplerName?: string
scheduler?: string
duration?: number
sound?: boolean
workflow?: string
stillId?: string
stillFilename?: string
hideThumbnail?: boolean
folderLocked?: boolean
loraName?: string
loraStack?: LoraStackItem[]
negative?: string
passes?: { prompt: string }[]
imagePipeline?: 'v1' | 'v2'
v2Mode?: string
v2Task?: string
engine?: string
snofsModel?: number
snofsClip?: number
consistencyModel?: number
consistencyClip?: number
refineStrength?: number
scaleToTotalPixels?: boolean
scaleMegapixels?: number
extensions?: QueuedShotDraft[]
tags?: string
lyrics?: string
instrumental?: boolean
lyricsStrength?: number
}
export type QueuedInspectJob = {
source: QueuedInspectSource
id: string
status: string
kind: QueuedInspectKind
name: string
prompt: string
shotCount: number
liveJobId?: string
shotQueueId?: string
lastError?: string
imagePipeline?: string
payload: QueuedInspectPayload
}
export function queuedJobKindLabel(job: { kind?: string; imagePipeline?: string; shotCount?: number }) {
if (job.kind === 'music') return 'Music'
if (job.kind === 'edit') {
if (job.imagePipeline === 'v2') return 'Image v2'
return (job.shotCount || 1) > 1 ? 'Image edit' : 'Image'
}
return 'Video'
}
export function queuedJobEditable(job: { source?: string; status?: string }) {
return job.source === 'studio' && (job.status === 'waiting' || job.status === 'held' || job.status === 'error')
}
export function queuedJobShots(payload: QueuedInspectPayload, kind: string): QueuedShotDraft[] {
if (kind === 'edit') {
const first = { prompt: payload.prompt || '', duration: 0 }
const rest = (payload.passes || []).map(item => ({ prompt: item.prompt || '', duration: 0 }))
return rest.length ? [first, ...rest] : [first]
}
if (kind === 'music') return []
const first = {
prompt: payload.prompt || '',
duration: Number(payload.duration) || 5,
loraName: payload.loraName,
loraStack: payload.loraStack
}
const rest = (payload.extensions || []).map(item => ({
prompt: item.prompt || '',
duration: Number(item.duration) || 5,
loraName: item.loraName,
loraStack: item.loraStack
}))
return [first, ...rest]
}
export function applyQueuedShots(kind: string, shots: QueuedShotDraft[], payload: QueuedInspectPayload) {
const next = { ...payload }
if (kind === 'music') {
next.prompt = payload.tags || payload.prompt || ''
return next
}
if (kind === 'edit') {
next.prompt = shots[0]?.prompt || ''
next.passes = shots.slice(1).map(item => ({ prompt: item.prompt || '' }))
return next
}
next.prompt = shots[0]?.prompt || ''
next.duration = Number(shots[0]?.duration) || next.duration || 5
next.extensions = shots.slice(1).map((item) => {
const fields = persistLoraFields(item.loraStack || item.loraName)
return {
prompt: item.prompt || '',
duration: Number(item.duration) || 5,
loraName: fields.loraName,
loraStack: fields.loraStack
}
})
return next
}
function line(label: string, value: unknown) {
const text = String(value ?? '').trim()
if (!text) return ''
return `${label}: ${text}`
}
export function queuedJobCopyText(job: QueuedInspectJob) {
const payload = job.payload || {}
const kind = queuedJobKindLabel(job)
const lines = [
line('Kind', kind),
line('Name', job.name || payload.name),
line('Status', job.status),
job.lastError ? line('Error', job.lastError) : '',
line('Workflow', payload.workflow),
line('Mode', payload.v2Mode),
line('Engine', payload.engine),
line('Task', payload.v2Task),
line('Aspect', payload.aspect),
payload.duration != null ? line('Duration', `${payload.duration}s`) : '',
payload.steps != null ? line('Steps', payload.steps) : '',
payload.cfg != null ? line('CFG', payload.cfg) : '',
payload.fps != null ? line('FPS', payload.fps) : '',
payload.seed != null ? line('Seed', payload.seed) : '',
payload.turbo != null ? line('Turbo', payload.turbo ? 'yes' : 'no') : '',
payload.sound != null && job.kind !== 'music' ? line('Sound', payload.sound ? 'on' : 'off') : '',
line('Sampler', payload.samplerName),
line('Scheduler', payload.scheduler),
line('Negative', payload.negative)
].filter(Boolean)
const stack = readLoraStack(payload)
if (stack.length) {
lines.push(`LoRAs: ${stack.map(item => `${item.name} ${item.strengthModel}/${item.strengthClip}`).join(', ')}`)
}
if (job.kind === 'music') {
lines.push(line('Tags', payload.tags || job.prompt))
if (payload.instrumental) lines.push('Instrumental: yes')
else lines.push(line('Lyrics', payload.lyrics))
} else {
if (payload.promptPre) lines.push(line('Prompt pre', payload.promptPre))
lines.push(line('Prompt', payload.prompt || job.prompt))
if (payload.promptPost) lines.push(line('Prompt post', payload.promptPost))
const shots = queuedJobShots(payload, job.kind)
if (shots.length > 1) {
lines.push('')
shots.forEach((shot, index) => {
const label = job.kind === 'edit' ? `Pass ${index + 1}` : `Shot ${index + 1}`
lines.push(`${label} (${shot.duration || 0}s): ${shot.prompt}`)
})
}
}
return lines.filter(Boolean).join('\n')
}
export function queuedJobPresetKind(job: { kind?: string; imagePipeline?: string }): 'video' | 'image' | 'imagev2' | 'music' {
if (job.kind === 'music') return 'music'
if (job.kind === 'edit') return job.imagePipeline === 'v2' ? 'imagev2' : 'image'
return 'video'
}
export function queuedJobPresetBody(job: QueuedInspectJob, name: string) {
const payload = job.payload || {}
const kind = queuedJobPresetKind(job)
const loraStack = readLoraStack(payload)
if (kind === 'music') {
return {
name,
kind,
overwrite: true,
loraStack: [],
settings: {
tags: sanitizeMusicTags(payload.tags || job.prompt),
lyrics: sanitizeMusicLyrics(payload.lyrics, payload.instrumental === true),
instrumental: payload.instrumental === true,
duration: clampMusicDuration(payload.duration),
steps: clampMusicSteps(payload.steps),
seed: payload.seed,
cfg: payload.cfg
}
}
}
if (kind === 'imagev2') {
return {
name,
kind,
overwrite: true,
loraStack,
settings: {
mode: payload.v2Mode,
engine: payload.engine,
task: payload.v2Task,
prompt: payload.prompt || job.prompt,
negative: payload.negative,
snofsModel: payload.snofsModel,
snofsClip: payload.snofsClip,
consistencyModel: payload.consistencyModel,
consistencyClip: payload.consistencyClip,
steps: payload.steps,
cfg: payload.cfg,
megapixels: payload.scaleMegapixels,
scaleToTotalPixels: payload.scaleToTotalPixels,
turbo: payload.turbo === true,
strength: payload.refineStrength,
aspect: payload.aspect,
width: payload.width,
height: payload.height
}
}
}
if (kind === 'image') {
return {
name,
kind,
overwrite: true,
loraStack,
settings: {
prompt: payload.prompt || job.prompt,
steps: payload.steps,
cfg: payload.cfg,
negative: payload.negative,
scaleToTotalPixels: payload.scaleToTotalPixels,
scaleMegapixels: payload.scaleMegapixels,
aspect: payload.aspect
}
}
}
return {
name,
kind,
overwrite: true,
loraStack,
settings: {
workflow: payload.workflow,
turbo: payload.turbo !== false,
cfg: payload.cfg,
fps: payload.fps,
samplerName: payload.samplerName,
scheduler: payload.scheduler,
duration: payload.duration,
aspect: payload.aspect,
sound: payload.sound !== false
}
}
}