Add ACE-Step music generation to the studio and library.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-09-02 23:58:18 -05:00
co-authored by Cursor
parent c79cca39ae
commit 5555155884
25 changed files with 2157 additions and 44 deletions
+52
View File
@@ -243,6 +243,58 @@ function findVideo(value: unknown, depth = 0): { filename: string; subfolder: st
return null
}
function isAudioFile(item: { filename?: string; format?: string } | null | undefined) {
if (!item) return false
const name = String(item.filename || '').toLowerCase()
const format = String(item.format || '').toLowerCase()
return name.endsWith('.wav') || name.endsWith('.flac') || name.endsWith('.mp3') || name.endsWith('.ogg') || name.endsWith('.opus')
|| format.includes('audio') || format.includes('wav') || format.includes('flac')
}
function normalizeAudio(item: { filename?: string; subfolder?: string; type?: string }) {
return {
filename: String(item.filename),
subfolder: String(item.subfolder || ''),
type: String(item.type || 'output')
}
}
function findAudio(value: unknown, depth = 0): { filename: string; subfolder: string; type: string } | null {
if (!value || typeof value !== 'object' || depth > 8) return null
if (Array.isArray(value)) {
const files = value.filter((item): item is { filename?: string; format?: string; subfolder?: string; type?: string } => Boolean(item && typeof item === 'object'))
const match = files.find(item => {
const name = String(item.filename || '').toLowerCase()
return name.endsWith('.flac') || name.endsWith('.wav')
}) || files.find(isAudioFile)
if (match?.filename) return normalizeAudio(match)
for (const item of value) {
const nested = findAudio(item, depth + 1)
if (nested) return nested
}
return null
}
const record = value as { filename?: string; format?: string; subfolder?: string; type?: string }
if (record.filename && isAudioFile(record)) return normalizeAudio(record)
for (const nested of Object.values(value as Record<string, unknown>)) {
const found = findAudio(nested, depth + 1)
if (found) return found
}
return null
}
export function extractAudio(history: Record<string, unknown> | null, promptId: string) {
if (!history) return null
const wrapped = history[promptId] as { outputs?: Record<string, unknown> } | undefined
if (wrapped) {
return findAudio(wrapped.outputs || {}) || findAudio(wrapped)
}
if ((history as { outputs?: unknown }).outputs) {
return findAudio((history as { outputs?: unknown }).outputs) || findAudio(history)
}
return findAudio(history)
}
export function extractVideo(history: Record<string, unknown> | null, promptId: string) {
if (!history) return null
const wrapped = history[promptId] as { outputs?: Record<string, unknown> } | undefined
+17 -5
View File
@@ -12,6 +12,7 @@ export interface JobEvent {
mediaType?: string
clipId?: string
stillId?: string
trackId?: string
hideThumbnail?: boolean
error?: string
elapsedMs?: number
@@ -31,7 +32,7 @@ export interface JobEvent {
export interface Job {
id: string
kind?: 'video' | 'edit'
kind?: 'video' | 'edit' | 'music'
promptId?: string
clientId: string
status: JobStatus
@@ -41,8 +42,10 @@ export interface Job {
maxStep: number
startedAt: number
video?: { filename: string; subfolder: string; type: string }
audio?: { filename: string; subfolder: string; type: string }
clipId?: string
stillId?: string
trackId?: string
hideThumbnail?: boolean
imageComfyHost?: string
library?: {
@@ -102,6 +105,12 @@ export interface Job {
loraStack?: import('~/utils/loras').LoraStackItem[]
shotLoras?: string[]
shotLoraStacks?: import('~/utils/loras').LoraStackItem[][]
tags?: string
lyrics?: string
instrumental?: boolean
audioExt?: string
engine?: string
lyricsStrength?: number
}
error?: string
socketReady?: boolean
@@ -115,9 +124,10 @@ export interface Job {
const jobs = new Map<string, Job>()
const MAX_JOBS = 40
export function createJob(): Job {
export function createJob(kind: Job['kind'] = 'video'): Job {
const job: Job = {
id: crypto.randomUUID(),
kind,
clientId: crypto.randomUUID(),
status: 'queued',
message: 'Queued',
@@ -175,6 +185,7 @@ export function listJobs() {
}
export function jobSnapshot(job: Job) {
const media = job.kind === 'music' ? job.audio : job.video
return {
jobId: job.id,
kind: job.kind || 'video',
@@ -186,11 +197,12 @@ export function jobSnapshot(job: Job) {
maxStep: job.maxStep,
promptId: job.promptId,
elapsedMs: Date.now() - job.startedAt,
filename: job.library?.folderLocked ? undefined : job.video?.filename,
subfolder: job.library?.folderLocked ? undefined : job.video?.subfolder,
mediaType: job.library?.folderLocked ? undefined : job.video?.type,
filename: job.library?.folderLocked ? undefined : media?.filename,
subfolder: job.library?.folderLocked ? undefined : media?.subfolder,
mediaType: job.library?.folderLocked ? undefined : media?.type,
clipId: job.clipId,
stillId: job.stillId,
trackId: job.trackId,
hideThumbnail: job.hideThumbnail,
error: job.error,
folderLocked: job.library?.folderLocked,
+207 -4
View File
@@ -66,6 +66,27 @@ export interface LibraryClip {
promptPost?: string
}
export interface LibraryTrack {
id: string
folderId: string
name: string
tags: string
lyrics: string
duration: number
steps: number
seed: number
cfg: number
instrumental: boolean
createdAt: number
filename: string
ext: string
engine?: string
samplerName?: string
scheduler?: string
lyricsStrength?: number
comfyFilename?: string
}
export type StillRole = 'input' | 'output'
export interface LibraryStill {
@@ -97,6 +118,7 @@ export interface PublicFolder {
unlocked: boolean
clipCount: number
imageCount: number
trackCount: number
}
export interface QueuedExtension {
@@ -146,6 +168,7 @@ interface Catalog {
folders: LibraryFolder[]
clips: LibraryClip[]
stills: LibraryStill[]
tracks: LibraryTrack[]
drafts: RetryDraft[]
loraAliases: Record<string, string>
loraTriggers: Record<string, string>
@@ -188,6 +211,7 @@ function emptyCatalog(): Catalog {
}],
clips: [],
stills: [],
tracks: [],
drafts: [],
loraAliases: {},
loraTriggers: {},
@@ -239,6 +263,30 @@ function normalizeStill(still: LibraryStill): LibraryStill {
}
}
function normalizeTrack(track: LibraryTrack): LibraryTrack {
const ext = String(track.ext || track.filename || 'flac').replace(/^\./, '').toLowerCase() || 'flac'
return {
id: track.id,
folderId: track.folderId,
name: String(track.name || '').trim() || 'Untitled track',
tags: String(track.tags || ''),
lyrics: String(track.lyrics || ''),
duration: Number(track.duration) || 0,
steps: Number(track.steps) || 0,
seed: Number(track.seed) || 0,
cfg: Number(track.cfg) || 0,
instrumental: track.instrumental === true,
createdAt: track.createdAt || Date.now(),
filename: String(track.filename || `audio.${ext}`),
ext,
engine: track.engine,
samplerName: track.samplerName,
scheduler: track.scheduler,
lyricsStrength: track.lyricsStrength,
comfyFilename: track.comfyFilename
}
}
function normalizeCatalog(parsed: Partial<Catalog> & { passwordHash?: string | null }): Catalog {
const folders = Array.isArray(parsed.folders) && parsed.folders.length
? parsed.folders.map(normalizeFolder)
@@ -252,6 +300,7 @@ function normalizeCatalog(parsed: Partial<Catalog> & { passwordHash?: string | n
folders,
clips: Array.isArray(parsed.clips) ? parsed.clips : [],
stills: Array.isArray(parsed.stills) ? parsed.stills.map(normalizeStill) : [],
tracks: Array.isArray(parsed.tracks) ? parsed.tracks.map(normalizeTrack) : [],
drafts: Array.isArray(parsed.drafts) ? parsed.drafts : [],
loraAliases: normalizeLoraAliases(parsed.loraAliases),
loraTriggers: normalizeLoraTriggers(parsed.loraTriggers),
@@ -284,6 +333,7 @@ function migrateLegacy(owner: string) {
function ensureOwner(owner: string) {
mkdirSync(join(ownerRoot(owner), 'files'), { recursive: true })
mkdirSync(join(ownerRoot(owner), 'stills'), { recursive: true })
mkdirSync(join(ownerRoot(owner), 'tracks'), { recursive: true })
migrateLegacy(owner)
}
@@ -392,7 +442,11 @@ function existingOutputNames(catalog: Catalog, folderId: string) {
.filter(item => item.folderId === folderId)
.map(item => String(item.name || '').trim())
.filter(Boolean)
return [...stills, ...clips]
const tracks = catalog.tracks
.filter(item => item.folderId === folderId)
.map(item => String(item.name || '').trim())
.filter(Boolean)
return [...stills, ...clips, ...tracks]
}
export function getLoraAliases(owner: string) {
@@ -481,7 +535,7 @@ export function folderUnlocked(event: H3Event, folder: LibraryFolder) {
return folderUnlocks(event)[folder.id] === passwordVersion(folder.passwordHash)
}
export function publicFolder(event: H3Event, folder: LibraryFolder, clipCount = 0, imageCount = 0): PublicFolder {
export function publicFolder(event: H3Event, folder: LibraryFolder, clipCount = 0, imageCount = 0, trackCount = 0): PublicFolder {
return {
id: folder.id,
name: folder.name,
@@ -490,7 +544,8 @@ export function publicFolder(event: H3Event, folder: LibraryFolder, clipCount =
protected: Boolean(folder.passwordHash),
unlocked: folderUnlocked(event, folder),
clipCount,
imageCount
imageCount,
trackCount
}
}
@@ -501,6 +556,7 @@ export function publicLibrary(event: H3Event, opts?: { light?: boolean }) {
const clipIds = new Set(catalog.clips.map(clip => clip.id.toLowerCase()))
const clipCounts = new Map<string, number>()
const imageCounts = new Map<string, number>()
const trackCounts = new Map<string, number>()
for (const clip of catalog.clips) {
clipCounts.set(clip.folderId, (clipCounts.get(clip.folderId) || 0) + 1)
}
@@ -508,11 +564,15 @@ export function publicLibrary(event: H3Event, opts?: { light?: boolean }) {
if (isPipelineStill(catalog, still, clipIds) || stillRole(still) !== 'output') continue
imageCounts.set(still.folderId, (imageCounts.get(still.folderId) || 0) + 1)
}
for (const track of catalog.tracks || []) {
trackCounts.set(track.folderId, (trackCounts.get(track.folderId) || 0) + 1)
}
const folders = catalog.folders.map(folder => publicFolder(
event,
folder,
clipCounts.get(folder.id) || 0,
imageCounts.get(folder.id) || 0
imageCounts.get(folder.id) || 0,
trackCounts.get(folder.id) || 0
))
const unlockedIds = new Set(folders.filter(folder => folder.unlocked).map(folder => folder.id))
return {
@@ -526,6 +586,12 @@ export function publicLibrary(event: H3Event, opts?: { light?: boolean }) {
stills: catalog.stills
.filter(still => unlockedIds.has(still.folderId) && !isPipelineStill(catalog, still, clipIds))
.map(still => ({ ...still, role: stillRole(still) })),
tracks: (catalog.tracks || [])
.filter(track => unlockedIds.has(track.folderId))
.map(track => ({
...track,
hasAudio: light ? true : existsSync(trackAudioPath(owner, track.id, track.ext))
})),
drafts: catalog.drafts,
loraAliases: catalog.loraAliases || {},
loraTriggers: catalog.loraTriggers || {},
@@ -599,15 +665,20 @@ export function deleteFolder(owner: string, id: string) {
if (!folder) throw createError({ statusCode: 404, statusMessage: 'Folder not found' })
const clips = catalog.clips.filter(clip => clip.folderId === id)
const stills = catalog.stills.filter(still => still.folderId === id)
const tracks = (catalog.tracks || []).filter(track => track.folderId === id)
catalog.folders = catalog.folders.filter(item => item.id !== id)
catalog.clips = catalog.clips.filter(clip => clip.folderId !== id)
catalog.stills = catalog.stills.filter(still => still.folderId !== id)
catalog.tracks = (catalog.tracks || []).filter(track => track.folderId !== id)
for (const clip of clips) {
rmSync(clipDir(owner, clip.id), { recursive: true, force: true })
}
for (const still of stills) {
removeStillFiles(owner, still.id)
}
for (const track of tracks) {
rmSync(trackDir(owner, track.id), { recursive: true, force: true })
}
return { fallbackFolderId: catalog.folders[0].id }
})
}
@@ -695,6 +766,25 @@ export function clipVideoPath(owner: string, id: string) {
return join(clipDir(owner, id), 'video.mp4')
}
export function trackDir(owner: string, id: string) {
const current = join(ownerRoot(owner), 'tracks', id)
if (existsSync(current)) return current
const legacy = join(libraryRoot(), 'tracks', id)
if (existsSync(legacy)) return legacy
return current
}
export function trackAudioPath(owner: string, id: string, ext = 'flac') {
const dir = trackDir(owner, id)
const wanted = join(dir, `audio.${String(ext || 'flac').replace(/^\./, '')}`)
if (existsSync(wanted)) return wanted
for (const fallback of ['flac', 'wav', 'mp3', 'ogg', 'opus']) {
const path = join(dir, `audio.${fallback}`)
if (existsSync(path)) return path
}
return wanted
}
export function clipLastFramePath(owner: string, id: string) {
return join(clipDir(owner, id), 'last-frame.png')
}
@@ -1653,6 +1743,103 @@ export function getClip(owner: string, id: string) {
return clip
}
export function getTrack(owner: string, id: string) {
const track = readCatalog(owner).tracks.find(item => item.id === id)
if (!track) throw createError({ statusCode: 404, statusMessage: 'Track not found' })
return track
}
export async function saveTrack(params: {
ownerKey: string
folderId: string
name?: string
tags: string
lyrics: string
duration: number
steps: number
seed: number
cfg: number
instrumental: boolean
audio: Buffer
ext?: string
engine?: string
samplerName?: string
scheduler?: string
lyricsStrength?: number
comfyFilename?: string
}) {
const catalog = readCatalog(params.ownerKey)
const folder = catalog.folders.find(item => item.id === params.folderId) || catalog.folders[0]
if (!folder) throw createError({ statusCode: 400, statusMessage: 'No library folder available' })
const ext = String(params.ext || 'flac').replace(/^\./, '').toLowerCase() || 'flac'
const track: LibraryTrack = {
id: crypto.randomUUID(),
folderId: folder.id,
name: clipTitle({ name: params.name || '', prompt: params.tags }),
tags: params.tags,
lyrics: params.lyrics,
duration: params.duration,
steps: params.steps,
seed: params.seed,
cfg: params.cfg,
instrumental: params.instrumental === true,
createdAt: Date.now(),
filename: `audio.${ext}`,
ext,
engine: params.engine,
samplerName: params.samplerName,
scheduler: params.scheduler,
lyricsStrength: params.lyricsStrength,
comfyFilename: params.comfyFilename
}
mkdirSync(trackDir(params.ownerKey, track.id), { recursive: true })
await writeFile(trackAudioPath(params.ownerKey, track.id, ext), params.audio)
if (!(typeof params.duration === 'number' && params.duration > 0)) {
try {
track.duration = await probeDuration(trackAudioPath(params.ownerKey, track.id, ext))
} catch {
/* duration stays as posted */
}
}
await mutate(params.ownerKey, (next) => {
if (!Array.isArray(next.tracks)) next.tracks = []
next.tracks.unshift(track)
})
return track
}
export function deleteTrack(owner: string, id: string) {
return mutate(owner, (catalog) => {
const track = catalog.tracks.find(item => item.id === id)
if (!track) throw createError({ statusCode: 404, statusMessage: 'Track not found' })
catalog.tracks = catalog.tracks.filter(item => item.id !== id)
rmSync(trackDir(owner, id), { recursive: true, force: true })
return track
})
}
export function moveTrack(owner: string, id: string, folderId: string) {
return mutate(owner, (catalog) => {
const track = catalog.tracks.find(item => item.id === id)
if (!track) throw createError({ statusCode: 404, statusMessage: 'Track not found' })
const folder = catalog.folders.find(item => item.id === folderId)
if (!folder) throw createError({ statusCode: 404, statusMessage: 'Folder not found' })
track.folderId = folderId
return track
})
}
export function renameTrack(owner: string, id: string, name: string) {
return mutate(owner, (catalog) => {
const track = catalog.tracks.find(item => item.id === id)
if (!track) throw createError({ statusCode: 404, statusMessage: 'Track not found' })
const cleaned = String(name || '').trim().slice(0, 80)
if (!cleaned) throw createError({ statusCode: 400, statusMessage: 'A name is required' })
track.name = cleaned
return track
})
}
function stampFamilyId(family: LibraryClip[], fallback?: string) {
const familyId = family.find(item => item.familyId)?.familyId || fallback || crypto.randomUUID()
for (const item of family) item.familyId = familyId
@@ -1791,6 +1978,22 @@ export async function downloadComfyVideo(video: { filename: string; subfolder: s
throw new Error(`Failed to fetch completed video from ComfyUI (${lastStatus})`)
}
export async function downloadComfyAudio(audio: { filename: string; subfolder: string; type: string }) {
const subfolders = [...new Set([audio.subfolder, 'audio', ''])]
let lastStatus = 0
for (const subfolder of subfolders) {
const params = new URLSearchParams({
filename: audio.filename,
subfolder,
type: audio.type || 'output'
})
const res = await comfyFetch(`/view?${params.toString()}`)
lastStatus = res.status
if (res.ok) return Buffer.from(await res.arrayBuffer())
}
throw new Error(`Failed to fetch completed audio from ComfyUI (${lastStatus})`)
}
export async function importMissingComfyVideos(owner: string, folderId?: string) {
const catalog = readCatalog(owner)
const folder = catalog.folders.find(item => item.id === folderId) || catalog.folders[0]
+352
View File
@@ -0,0 +1,352 @@
import { createJob, emitJob, type Job } from '~/server/utils/jobs'
import { extractAudio, fetchHistory, queuePrompt } from '~/server/utils/comfy'
import { comfyWsUrl } from '~/server/utils/comfy'
import { ensureComfyReady } from '~/server/utils/comfyLifecycle'
import { downloadComfyAudio, saveTrack } from '~/server/utils/library'
import { buildMusicWorkflow, musicFilenamePrefix } from '~/server/utils/musicWorkflow'
import { NODE_LABELS } from '~/server/utils/workflow'
import { musicExtension } from '~/utils/music'
import type { MusicEngine } from '~/utils/music'
export type MusicJobParams = {
ownerKey: string
folderId: string
name: string
tags: string
lyrics: string
duration: number
steps: number
seed: number
cfg: number
lyricsStrength: number
instrumental: boolean
folderLocked?: boolean
engine?: MusicEngine
samplerName?: string
scheduler?: string
}
function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms))
}
function classifyError(message: string) {
const lower = message.toLowerCase()
if (lower.includes('out of memory') || (lower.includes('cuda') && lower.includes('alloc')) || lower.includes('vram')) {
return 'ComfyUI VRAM allocation failed. Try fewer steps or a shorter duration.'
}
if (lower.includes('econnrefused') || lower.includes('unreachable') || lower.includes('fetch failed')) {
return 'ComfyUI host connection dropped. Confirm the desktop instance is running.'
}
if (lower.includes('timeout')) {
return 'Network timeout talking to ComfyUI. The job may still be running on the desktop.'
}
return message
}
function nodeLabel(node: string) {
if (!node) return ''
return NODE_LABELS[node] || ''
}
async function waitForSocket(job: Job, ms = 4000) {
const started = Date.now()
while (Date.now() - started < ms) {
if (job.socketReady) return
await sleep(100)
}
}
function watchMusicJob(job: Job): Promise<void> {
job.socketReady = false
job.saving = false
const ws = new WebSocket(comfyWsUrl(job.clientId))
let settled = false
let finishing = false
let pollTimer: ReturnType<typeof setInterval> | null = null
let timeout: ReturnType<typeof setTimeout> | null = null
return new Promise((resolve) => {
let localProgress = 0
const startedAt = Date.now()
let lastActivity = Date.now()
const IDLE_MS = 30 * 60 * 1000
const ABSOLUTE_MS = 3 * 60 * 60 * 1000
const markActivity = () => { lastActivity = Date.now() }
const emitLocal = (event: Parameters<typeof emitJob>[1], samplePct?: number) => {
markActivity()
if (typeof event.progress === 'number') localProgress = event.progress
if (event.message && typeof samplePct === 'number') {
event.message = `${event.message} (${samplePct}%)`
}
emitJob(job, event)
}
const cleanup = () => {
if (timeout) clearTimeout(timeout)
if (pollTimer) clearInterval(pollTimer)
timeout = null
pollTimer = null
try { ws.close() } catch { /* ignore */ }
}
const armIdle = () => {
if (timeout) clearTimeout(timeout)
if (settled) return
const absLeft = ABSOLUTE_MS - (Date.now() - startedAt)
if (absLeft <= 0) {
void fail('Timed out waiting for ComfyUI (3 hours).')
return
}
const idleLeft = IDLE_MS - (Date.now() - lastActivity)
timeout = setTimeout(() => {
if (settled || finishing) return
if (Date.now() - lastActivity >= IDLE_MS) {
void fail('Timed out waiting for ComfyUI progress (30 minutes with no updates).')
return
}
armIdle()
}, Math.max(1000, Math.min(idleLeft, absLeft)))
}
const fail = async (error: string) => {
if (settled || finishing) return
settled = true
cleanup()
job.status = job.status === 'cancelled' ? 'cancelled' : 'error'
job.error = classifyError(error)
emitJob(job, { type: 'error', error: job.error, message: job.error })
resolve()
}
const succeed = async () => {
if (settled || finishing || !job.promptId) return false
finishing = true
job.saving = true
try {
const history = await fetchHistory(job.promptId)
const audio = extractAudio(history, job.promptId)
if (!audio) {
finishing = false
job.saving = false
return false
}
if (settled) return false
settled = true
cleanup()
try {
job.audio = audio
emitLocal({ type: 'status', message: 'Saving to library...', progress: 98 })
if (job.library) {
const buffer = await downloadComfyAudio(audio)
const ext = musicExtension(audio.filename)
const track = await saveTrack({
ownerKey: job.library.ownerKey,
folderId: job.library.folderId,
name: job.library.name,
tags: job.library.tags || job.library.prompt,
lyrics: job.library.lyrics || '',
duration: job.library.duration || 0,
steps: job.library.steps,
seed: job.library.seed,
cfg: job.library.cfg || 0,
instrumental: job.library.instrumental === true,
audio: buffer,
ext,
engine: job.library.engine,
samplerName: job.library.samplerName,
scheduler: job.library.scheduler,
lyricsStrength: job.library.lyricsStrength,
comfyFilename: audio.filename
})
job.trackId = track.id
job.library.audioExt = ext
}
job.status = 'complete'
emitJob(job, {
type: 'complete',
message: job.library?.folderLocked
? 'Saved to the locked folder. Unlock it to listen.'
: 'Track ready',
progress: 100,
filename: job.library?.folderLocked ? undefined : audio.filename,
subfolder: job.library?.folderLocked ? undefined : audio.subfolder,
mediaType: job.library?.folderLocked ? undefined : audio.type,
trackId: job.trackId,
folderLocked: job.library?.folderLocked
})
} catch (saveError) {
const message = saveError instanceof Error ? saveError.message : String(saveError)
job.status = 'error'
job.error = `Audio generated but library save failed: ${message}`
emitJob(job, { type: 'error', error: job.error, message: job.error })
}
job.saving = false
resolve()
return true
} catch (error) {
finishing = false
job.saving = false
throw error
}
}
const pollHistory = async () => {
if (settled || finishing || !job.promptId) return
try {
const history = await fetchHistory(job.promptId)
if (extractAudio(history, job.promptId)) {
await succeed()
return
}
const entry = (history?.[job.promptId] || history) as {
status?: { status_str?: string; completed?: boolean }
} | undefined
const status = entry?.status?.status_str
if (status === 'error') await fail('ComfyUI reported an execution error')
else if (status === 'interrupted') await fail('Job interrupted.')
} catch {
// history can miss while the graph is still running
}
}
armIdle()
pollTimer = setInterval(() => { void pollHistory() }, 4000)
ws.addEventListener('open', () => {
job.socketReady = true
emitLocal({ type: 'status', message: 'Connected to ComfyUI', progress: Math.max(localProgress, 4) })
})
ws.addEventListener('error', () => {
job.socketReady = false
})
ws.addEventListener('message', (event) => {
let payload: { type?: string; data?: Record<string, any> }
try {
payload = JSON.parse(String(event.data))
} catch {
return
}
const type = String(payload.type || '')
const data = payload.data || {}
if (type === 'status') markActivity()
if (type === 'progress') {
const value = Number(data.value || 0)
const max = Number(data.max || 0)
const pct = max > 0 ? Math.min(95, Math.round((value / max) * 90) + 8) : Math.max(localProgress, 12)
const node = String(data.node || '')
emitLocal({
type: 'progress',
message: nodeLabel(node) || 'Sampling',
progress: pct,
step: value,
maxStep: max,
node
}, max > 0 ? Math.round((value / max) * 100) : undefined)
}
if (type === 'executing') {
const node = data.node == null ? '' : String(data.node)
if (!node && job.promptId) void succeed()
else if (node) {
emitLocal({
type: 'status',
message: nodeLabel(node) || 'Running node',
progress: Math.max(localProgress, 10),
node
})
}
}
if (type === 'executed') {
void succeed()
}
if (type === 'execution_error') {
const message = String(data.exception_message || data.message || 'ComfyUI node execution failed')
void fail(message)
}
if (type === 'execution_interrupted') {
job.status = 'cancelled'
void fail('Job interrupted.')
}
})
})
}
export async function startMusicJob(params: MusicJobParams) {
const job = createJob('music')
job.library = {
ownerKey: params.ownerKey,
folderId: params.folderId,
hideThumbnail: false,
folderLocked: params.folderLocked === true,
name: params.name,
prompt: params.tags,
tags: params.tags,
lyrics: params.lyrics,
instrumental: params.instrumental,
aspect: 'audio',
width: 0,
height: 0,
steps: params.steps,
turbo: false,
seed: params.seed,
cfg: params.cfg,
duration: params.duration,
sound: true,
engine: params.engine || 'ace-step',
samplerName: params.samplerName || 'euler',
scheduler: params.scheduler || 'simple',
lyricsStrength: params.lyricsStrength
}
job.maxStep = params.steps
job.status = 'queued'
emitJob(job, { type: 'status', message: 'Checking ComfyUI...', progress: 1 })
void (async () => {
try {
const done = watchMusicJob(job)
await ensureComfyReady((status) => {
emitJob(job, {
type: status.state === 'busy' ? 'busy' : 'status',
message: status.message,
progress: status.state === 'online' ? 3 : 1,
busy: status.state === 'busy',
queueRunning: status.queueRunning,
queuePending: status.queuePending
})
})
job.status = 'uploading'
emitJob(job, { type: 'status', message: 'Queueing ACE-Step...', progress: 6 })
await waitForSocket(job, 4000)
const graph = buildMusicWorkflow({
tags: params.tags,
lyrics: params.instrumental ? '' : params.lyrics,
duration: params.duration,
steps: params.steps,
seed: params.seed,
cfg: params.cfg,
lyricsStrength: params.instrumental ? 0 : params.lyricsStrength,
samplerName: params.samplerName,
scheduler: params.scheduler,
filenamePrefix: musicFilenamePrefix(),
engine: params.engine,
vocalMultiplier: params.instrumental ? 0.2 : 1
})
const queued = await queuePrompt(graph, job.clientId)
job.promptId = queued.prompt_id
job.status = 'running'
emitJob(job, { type: 'status', message: 'Job queued on ComfyUI', progress: 8 })
await done
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (job.status !== 'cancelled' && job.status !== 'complete') {
job.status = 'error'
job.error = classifyError(message)
emitJob(job, { type: 'error', error: job.error, message: job.error })
}
}
})()
return job
}
+68
View File
@@ -0,0 +1,68 @@
import workflowAceStep from '../assets/workflow_ace_step.json'
import type { MusicEngine } from '~/utils/music'
export type MusicWorkflowParams = {
tags: string
lyrics: string
duration: number
steps: number
seed: number
cfg: number
lyricsStrength: number
samplerName?: string
scheduler?: string
filenamePrefix?: string
checkpoint?: string
engine?: MusicEngine
vocalMultiplier?: number
}
type WorkflowNode = { class_type: string; inputs: Record<string, unknown>; _meta?: { title?: string } }
type WorkflowGraph = Record<string, WorkflowNode>
const ACE_V1_CKPT = 'ace_step_v1_3.5b.safetensors'
const ACE_15_CKPT = 'ace_step_1.5_turbo_aio.safetensors'
export function musicFilenamePrefix() {
return String(useRuntimeConfig().comfyAudioPrefix || process.env.COMFY_AUDIO_PREFIX || 'audio/AIGen')
}
export function buildMusicWorkflow(params: MusicWorkflowParams): WorkflowGraph {
const graph = structuredClone(workflowAceStep as WorkflowGraph)
const engine = params.engine || 'ace-step'
const checkpoint = params.checkpoint
|| (engine === 'ace-step-1.5' ? ACE_15_CKPT : ACE_V1_CKPT)
const prefix = params.filenamePrefix || musicFilenamePrefix()
const sampler = params.samplerName || 'euler'
const scheduler = params.scheduler || 'simple'
for (const node of Object.values(graph)) {
if (node.class_type === 'CheckpointLoaderSimple' && 'ckpt_name' in node.inputs) {
node.inputs.ckpt_name = checkpoint
}
if (node.class_type === 'TextEncodeAceStepAudio') {
node.inputs.tags = params.tags
node.inputs.lyrics = params.lyrics
node.inputs.lyrics_strength = params.lyricsStrength
}
if (node.class_type === 'EmptyAceStepLatentAudio' && 'seconds' in node.inputs) {
node.inputs.seconds = params.duration
}
if (node.class_type === 'KSampler') {
node.inputs.seed = params.seed
node.inputs.steps = params.steps
node.inputs.cfg = params.cfg
node.inputs.sampler_name = sampler
node.inputs.scheduler = scheduler
}
if (node.class_type === 'SaveAudio' && 'filename_prefix' in node.inputs) {
node.inputs.filename_prefix = prefix
}
}
return graph
}
export const MUSIC_NODE_LABELS: Record<string, string> = Object.fromEntries(
Object.entries(workflowAceStep as WorkflowGraph).map(([id, node]) => [id, node._meta?.title || `Node ${id}`])
)
+2
View File
@@ -2,6 +2,7 @@
import workflowV1 from '../assets/workflow_minimax_video.json'
import workflowV2 from '../assets/workflow_minimax_video_v2.json'
import workflowLtx from '../assets/workflow_ltx_video.json'
import { MUSIC_NODE_LABELS } from '~/server/utils/musicWorkflow'
import { buildIdentityPrompt } from '~/utils/identityPrompt'
import { applyUserLoraToGraph, resolveGraphLoraNames } from '~/server/utils/loras'
import type { LoraStackItem } from '~/utils/loras'
@@ -396,6 +397,7 @@ export const NODE_LABELS: Record<string, string> = {
...Object.fromEntries(labelsFrom(workflowV1 as WorkflowGraph)),
...Object.fromEntries(labelsFrom(workflowV2 as WorkflowGraph)),
...Object.fromEntries(labelsFrom(workflowLtx as WorkflowGraph)),
...MUSIC_NODE_LABELS,
...LABEL_OVERRIDES
}