Let queued video, image, and music jobs open in an inspect modal.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
+15
-19
@@ -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)"
|
||||
@@ -66,17 +66,17 @@
|
||||
>
|
||||
<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>
|
||||
<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,21 +579,11 @@ 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'
|
||||
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'
|
||||
}
|
||||
return job.status
|
||||
}
|
||||
if (job.kind === 'edit') {
|
||||
const passes = job.shotCount || 1
|
||||
const passLine = passes > 1 ? `${passes} passes` : 'image edit'
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user