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
+6 -3
View File
@@ -32,11 +32,13 @@ export default defineEventHandler(async (event) => {
const live = listJobs()
.filter(job => job.library?.ownerKey === owner && jobLooksLive(job, comfyBusy))
.sort((a, b) => b.startedAt - a.startedAt)
const videoLive = live.find(job => job.kind !== 'edit')
const videoLive = live.find(job => job.kind !== 'edit' && job.kind !== 'music')
const editLive = live.find(job => job.kind === 'edit')
const musicLive = live.find(job => job.kind === 'music')
let video = videoLive ? livePayload(videoLive) : null
const edit = editLive ? livePayload(editLive) : null
const music = musicLive ? livePayload(musicLive) : null
if (!video) {
const pending = listPendingJobs()
@@ -70,8 +72,9 @@ export default defineEventHandler(async (event) => {
}
return {
jobId: video?.jobId || edit?.jobId || null,
jobId: video?.jobId || edit?.jobId || music?.jobId || null,
video,
edit
edit,
music
}
})
+100
View File
@@ -0,0 +1,100 @@
import { startMusicJob } from '~/server/utils/musicChain'
import { comfyConfigured } from '~/server/utils/comfy'
import {
clampLyricsStrength,
clampMusicCfg,
clampMusicDuration,
clampMusicSteps,
parseMusicEngine,
parseMusicSeed,
sanitizeMusicLyrics,
sanitizeMusicTags
} from '~/utils/music'
export default defineEventHandler(async (event) => {
const body = await readBody<{
tags?: unknown
lyrics?: unknown
duration?: unknown
steps?: unknown
seed?: unknown
cfg?: unknown
lyricsStrength?: unknown
instrumental?: unknown
folderId?: unknown
name?: unknown
engine?: unknown
sampler_name?: unknown
scheduler?: unknown
}>(event).catch(() => ({} as Record<string, unknown>))
if (!comfyConfigured()) {
throw createError({
statusCode: 503,
statusMessage: 'Beast ComfyUI is not configured. Set COMFY_HOST.'
})
}
const instrumental = body.instrumental === true || body.instrumental === 'true'
const tags = sanitizeMusicTags(body.tags)
const lyrics = sanitizeMusicLyrics(body.lyrics, instrumental)
if (!tags) {
throw createError({ statusCode: 400, statusMessage: 'Genre and style tags are required' })
}
if (!instrumental && !lyrics) {
throw createError({ statusCode: 400, statusMessage: 'Lyrics are required unless Instrumental is on' })
}
const ownerKey = libraryOwnerKey(event)
const library = publicLibrary(event)
const folderId = library.folders.some(folder => folder.id === String(body.folderId || ''))
? String(body.folderId)
: library.folders[0]?.id
if (!folderId) {
throw createError({ statusCode: 400, statusMessage: 'Create a library folder before generating' })
}
assertFolderExists(event, folderId)
const destFolder = library.folders.find(folder => folder.id === folderId)
const folderLocked = Boolean(destFolder?.protected && !destFolder.unlocked)
const duration = clampMusicDuration(body.duration)
const steps = clampMusicSteps(body.steps)
const cfg = clampMusicCfg(body.cfg)
const lyricsStrength = clampLyricsStrength(body.lyricsStrength)
const seed = parseMusicSeed(body.seed)
const engine = parseMusicEngine(body.engine)
const clipName = allocateOutputName(ownerKey, folderId, String(body.name || '').trim(), {
engine: engine === 'ace-step-1.5' ? 'ace15' : 'ace'
})
const job = await startMusicJob({
ownerKey,
folderId,
name: clipName,
tags,
lyrics,
duration,
steps,
seed,
cfg,
lyricsStrength,
instrumental,
folderLocked,
engine,
samplerName: String(body.sampler_name || 'euler'),
scheduler: String(body.scheduler || 'simple')
})
return {
jobId: job.id,
queued: job.status === 'queued',
seed,
duration,
steps,
cfg,
folderId,
folderLocked,
engine,
name: clipName
}
})
+8
View File
@@ -0,0 +1,8 @@
export default defineEventHandler(async (event) => {
const { owner } = assertLibraryOwner(event)
const id = String(getRouterParam(event, 'id') || '')
const track = getTrack(owner, id)
assertFolderAccess(event, track.folderId)
await deleteTrack(owner, id)
return { ok: true }
})
+18
View File
@@ -0,0 +1,18 @@
export default defineEventHandler(async (event) => {
const { owner } = assertLibraryOwner(event)
const id = String(getRouterParam(event, 'id') || '')
const body = await readBody<{ folderId?: string; name?: string }>(event).catch(() => ({} as { folderId?: string; name?: string }))
const folderId = String(body?.folderId || '')
const hasName = typeof body?.name === 'string'
if (!folderId && !hasName) {
throw createError({ statusCode: 400, statusMessage: 'Choose a library folder or a new name' })
}
const track = getTrack(owner, id)
assertFolderAccess(event, track.folderId)
if (folderId) {
assertFolderExists(event, folderId)
await moveTrack(owner, id, folderId)
}
if (hasName) await renameTrack(owner, id, body.name || '')
return publicLibrary(event)
})
@@ -0,0 +1,19 @@
import { existsSync } from 'node:fs'
import { sendPathWithRange } from '~/server/utils/httpRange'
import { musicContentType } from '~/utils/music'
export default defineEventHandler((event) => {
const { owner } = assertLibraryOwner(event)
const id = String(getRouterParam(event, 'id') || '')
const track = getTrack(owner, id)
assertFolderAccess(event, track.folderId)
const path = trackAudioPath(owner, track.id, track.ext)
if (!existsSync(path)) {
throw createError({ statusCode: 404, statusMessage: 'Audio file is missing' })
}
const ext = track.ext || 'flac'
return sendPathWithRange(event, path, musicContentType(ext), {
'Content-Disposition': `inline; filename="${safeDownloadName(track.name)}.${ext}"`,
'Cache-Control': 'private, max-age=0, must-revalidate'
})
})
+12 -2
View File
@@ -11,10 +11,20 @@ export default defineEventHandler(async (event) => {
const params = new URLSearchParams({ filename, subfolder, type })
const res = await comfyFetch(`/view?${params.toString()}`)
if (!res.ok) {
throw createError({ statusCode: res.status, statusMessage: 'Failed to fetch video from ComfyUI' })
throw createError({ statusCode: res.status, statusMessage: 'Failed to fetch media from ComfyUI' })
}
const buf = Buffer.from(await res.arrayBuffer())
return sendBufferWithRange(event, buf, res.headers.get('content-type') || 'video/mp4', {
const lower = filename.toLowerCase()
const fallbackType = lower.endsWith('.wav')
? 'audio/wav'
: lower.endsWith('.flac')
? 'audio/flac'
: lower.endsWith('.mp3')
? 'audio/mpeg'
: lower.endsWith('.ogg') || lower.endsWith('.opus')
? 'audio/ogg'
: 'video/mp4'
return sendBufferWithRange(event, buf, res.headers.get('content-type') || fallbackType, {
'Content-Disposition': `inline; filename="${filename}"`,
'Cache-Control': 'private, max-age=0, must-revalidate'
})