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
+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) {