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
+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]