>([])
+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
(`/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()
diff --git a/pages/music.vue b/pages/music.vue
index 8d0d1a3..d959801 100644
--- a/pages/music.vue
+++ b/pages/music.vue
@@ -124,14 +124,32 @@
{{ blockReason }}
Waiting in the job queue.
+
+ ·
Open Queue
+
+
+
+
Output
{{ status || 'Waiting for a generate.' }}
+
@@ -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([])
+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)
diff --git a/pages/queue.vue b/pages/queue.vue
index c7db14e..567e6b2 100644
--- a/pages/queue.vue
+++ b/pages/queue.vue
@@ -11,7 +11,7 @@
Queue
- 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.
@@ -57,7 +57,7 @@
>
-
+
+
+
@@ -477,6 +482,7 @@ type PendingRemove = {
const videoLoras = ref
([])
const loraTriggers = ref>({})
const loraAliases = ref>({})
+const inspectJobId = ref('')
const jobs = ref([])
const orphans = ref([])
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) {
diff --git a/server/api/studio-queue.get.ts b/server/api/studio-queue.get.ts
index 139089d..7120026 100644
--- a/server/api/studio-queue.get.ts
+++ b/server/api/studio-queue.get.ts
@@ -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
diff --git a/server/api/studio-queue/[id].delete.ts b/server/api/studio-queue/[id].delete.ts
index bff40bf..db01be8 100644
--- a/server/api/studio-queue/[id].delete.ts
+++ b/server/api/studio-queue/[id].delete.ts
@@ -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 }
})
diff --git a/server/api/studio-queue/[id].get.ts b/server/api/studio-queue/[id].get.ts
index 34c874a..5166cb8 100644
--- a/server/api/studio-queue/[id].get.ts
+++ b/server/api/studio-queue/[id].get.ts
@@ -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' })
}
diff --git a/server/api/studio-queue/[id].patch.ts b/server/api/studio-queue/[id].patch.ts
new file mode 100644
index 0000000..3a07ef9
--- /dev/null
+++ b/server/api/studio-queue/[id].patch.ts
@@ -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>(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
+})
diff --git a/server/utils/generationPresets.ts b/server/utils/generationPresets.ts
index 8bedfbd..73b0f45 100644
--- a/server/utils/generationPresets.ts
+++ b/server/utils/generationPresets.ts
@@ -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>()
@@ -155,9 +163,24 @@ function sanitizeImageV2Settings(raw: unknown): ImageV2PresetSettings {
}
}
+function sanitizeMusicSettings(raw: unknown): MusicPresetSettings {
+ const rec = raw && typeof raw === 'object' ? raw as Record : {}
+ 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)
}
diff --git a/server/utils/queuedInspect.ts b/server/utils/queuedInspect.ts
new file mode 100644
index 0000000..dec7f63
--- /dev/null
+++ b/server/utils/queuedInspect.ts
@@ -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)
+}
diff --git a/utils/generationPresets.ts b/utils/generationPresets.ts
index aa346c0..a39b1cf 100644
--- a/utils/generationPresets.ts
+++ b/utils/generationPresets.ts
@@ -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'
}
diff --git a/utils/queuedJob.ts b/utils/queuedJob.ts
new file mode 100644
index 0000000..91d43f3
--- /dev/null
+++ b/utils/queuedJob.ts
@@ -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
+ }
+ }
+}