Add ACE-Step music generation to the studio and library.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+207
-4
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user