Add ACE-Step music generation to the studio and library.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -52,12 +52,16 @@ export async function ensureGenerationNotifyPermission() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function notifyGenerationComplete(kind: 'video' | 'image' = 'video') {
|
||||
export async function notifyGenerationComplete(kind: 'video' | 'image' | 'music' = 'video') {
|
||||
if (!generationNotifySupported() || !generationNotifyPref()) return
|
||||
if (Notification.permission !== 'granted') return
|
||||
if (typeof document !== 'undefined' && !document.hidden && document.hasFocus()) return
|
||||
const title = appName()
|
||||
const body = kind === 'image' ? 'Image generation is complete.' : 'Generation is complete.'
|
||||
const body = kind === 'image'
|
||||
? 'Image generation is complete.'
|
||||
: kind === 'music'
|
||||
? 'Music generation is complete.'
|
||||
: 'Generation is complete.'
|
||||
const options: NotificationOptions = {
|
||||
body,
|
||||
icon: appIcon(),
|
||||
|
||||
+50
-9
@@ -1,7 +1,7 @@
|
||||
import type { StillGenerationSettings } from '~/utils/libraryGeneration'
|
||||
import type { LoraStackItem } from '~/utils/loras'
|
||||
|
||||
export type LibraryFilter = 'all' | 'images' | 'videos'
|
||||
export type LibraryFilter = 'all' | 'images' | 'videos' | 'music'
|
||||
export type LibrarySort = 'date-desc' | 'date-asc' | 'name-asc' | 'name-desc' | 'type'
|
||||
|
||||
export type LibraryFolder = {
|
||||
@@ -13,6 +13,7 @@ export type LibraryFolder = {
|
||||
unlocked: boolean
|
||||
clipCount?: number
|
||||
imageCount?: number
|
||||
trackCount?: number
|
||||
}
|
||||
|
||||
export type LibraryStill = {
|
||||
@@ -66,15 +67,37 @@ export type LibraryClip = {
|
||||
loraStack?: LoraStackItem[]
|
||||
}
|
||||
|
||||
export type 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
|
||||
hasAudio?: boolean
|
||||
}
|
||||
|
||||
export type ClipGroup = { key: string; head: LibraryClip; parts: LibraryClip[] }
|
||||
export type StillGroup = { key: string; head: LibraryStill; parts: LibraryStill[] }
|
||||
export type LibraryEntry = {
|
||||
kind: 'clip' | 'still'
|
||||
kind: 'clip' | 'still' | 'track'
|
||||
key: string
|
||||
createdAt: number
|
||||
name: string
|
||||
group?: ClipGroup
|
||||
stillGroup?: StillGroup
|
||||
track?: LibraryTrack
|
||||
}
|
||||
|
||||
export function familyBaseName(name: string) {
|
||||
@@ -248,6 +271,7 @@ export function stillCollectionTitle(group: StillGroup) {
|
||||
|
||||
export function entryTitle(entry: LibraryEntry) {
|
||||
if (entry.kind === 'clip' && entry.group) return collectionTitle(entry.group)
|
||||
if (entry.kind === 'track' && entry.track) return entry.track.name
|
||||
if (entry.stillGroup) return stillCollectionTitle(entry.stillGroup)
|
||||
return entry.name
|
||||
}
|
||||
@@ -256,6 +280,9 @@ export function entrySearchText(entry: LibraryEntry) {
|
||||
if (entry.kind === 'clip' && entry.group) {
|
||||
return entry.group.parts.map(part => `${part.name || ''} ${part.prompt || ''}`).join(' ')
|
||||
}
|
||||
if (entry.kind === 'track' && entry.track) {
|
||||
return `${entry.track.name || ''} ${entry.track.tags || ''} ${entry.track.lyrics || ''}`
|
||||
}
|
||||
if (entry.stillGroup) {
|
||||
return entry.stillGroup.parts.map(part => `${part.name || ''} ${part.prompt || ''} ${part.filename || ''}`).join(' ')
|
||||
}
|
||||
@@ -297,6 +324,7 @@ function compareName(a: string, b: string) {
|
||||
export function buildLibraryEntries(opts: {
|
||||
clips: LibraryClip[]
|
||||
stills: LibraryStill[]
|
||||
tracks?: LibraryTrack[]
|
||||
folderId: string
|
||||
filter: LibraryFilter
|
||||
search: string
|
||||
@@ -305,7 +333,8 @@ export function buildLibraryEntries(opts: {
|
||||
const inFolder = (folderId: string) => !opts.folderId || opts.folderId === 'all' || folderId === opts.folderId
|
||||
const clips = opts.clips.filter(clip => inFolder(clip.folderId) && clip.hasVideo !== false)
|
||||
const stills = opts.stills.filter(still => inFolder(still.folderId) && still.role === 'output')
|
||||
const videos: LibraryEntry[] = opts.filter === 'images'
|
||||
const tracks = (opts.tracks || []).filter(track => inFolder(track.folderId) && track.hasAudio !== false)
|
||||
const videos: LibraryEntry[] = opts.filter === 'images' || opts.filter === 'music'
|
||||
? []
|
||||
: groupClips(clips).map(group => ({
|
||||
kind: 'clip' as const,
|
||||
@@ -314,7 +343,7 @@ export function buildLibraryEntries(opts: {
|
||||
name: collectionTitle(group),
|
||||
group
|
||||
}))
|
||||
const images: LibraryEntry[] = opts.filter === 'videos'
|
||||
const images: LibraryEntry[] = opts.filter === 'videos' || opts.filter === 'music'
|
||||
? []
|
||||
: groupStills(stills).map(group => ({
|
||||
kind: 'still' as const,
|
||||
@@ -323,7 +352,16 @@ export function buildLibraryEntries(opts: {
|
||||
name: stillCollectionTitle(group),
|
||||
stillGroup: group
|
||||
}))
|
||||
let entries = [...videos, ...images]
|
||||
const music: LibraryEntry[] = opts.filter === 'images' || opts.filter === 'videos'
|
||||
? []
|
||||
: tracks.map(track => ({
|
||||
kind: 'track' as const,
|
||||
key: `track:${track.id}`,
|
||||
createdAt: track.createdAt,
|
||||
name: track.name,
|
||||
track
|
||||
}))
|
||||
let entries = [...videos, ...images, ...music]
|
||||
const needle = opts.search.trim().toLowerCase()
|
||||
if (needle) {
|
||||
entries = entries.filter(entry => entrySearchText(entry).toLowerCase().includes(needle))
|
||||
@@ -334,7 +372,8 @@ export function buildLibraryEntries(opts: {
|
||||
if (sort === 'name-desc') return compareName(b.name, a.name) || b.createdAt - a.createdAt
|
||||
if (sort === 'date-asc') return a.createdAt - b.createdAt
|
||||
if (sort === 'type') {
|
||||
if (a.kind !== b.kind) return a.kind === 'still' ? -1 : 1
|
||||
const order = { still: 0, clip: 1, track: 2 } as const
|
||||
if (a.kind !== b.kind) return order[a.kind] - order[b.kind]
|
||||
return b.createdAt - a.createdAt
|
||||
}
|
||||
return b.createdAt - a.createdAt
|
||||
@@ -342,13 +381,15 @@ export function buildLibraryEntries(opts: {
|
||||
return entries
|
||||
}
|
||||
|
||||
export function folderOutputCount(folder: LibraryFolder, clips: LibraryClip[], stills: LibraryStill[], filter: LibraryFilter) {
|
||||
if (!folder.unlocked) return (folder.clipCount || 0) + (folder.imageCount || 0)
|
||||
export function folderOutputCount(folder: LibraryFolder, clips: LibraryClip[], stills: LibraryStill[], filter: LibraryFilter, tracks: LibraryTrack[] = []) {
|
||||
if (!folder.unlocked) return (folder.clipCount || 0) + (folder.imageCount || 0) + (folder.trackCount || 0)
|
||||
const videos = clips.filter(clip => clip.folderId === folder.id).length
|
||||
const images = stills.filter(still => still.folderId === folder.id && still.role === 'output').length
|
||||
const music = tracks.filter(track => track.folderId === folder.id).length
|
||||
if (filter === 'videos') return videos
|
||||
if (filter === 'images') return images
|
||||
return videos + images
|
||||
if (filter === 'music') return music
|
||||
return videos + images + music
|
||||
}
|
||||
|
||||
export function formatWhen(createdAt: number) {
|
||||
|
||||
@@ -280,3 +280,34 @@ export function clipSettingsRows(clip: {
|
||||
if (loras) rows.push({ label: 'LoRA', value: loras })
|
||||
return rows
|
||||
}
|
||||
|
||||
export function trackSettingsRows(track: {
|
||||
tags?: string
|
||||
lyrics?: string
|
||||
duration?: number
|
||||
steps?: number
|
||||
seed?: number
|
||||
cfg?: number
|
||||
instrumental?: boolean
|
||||
engine?: string
|
||||
samplerName?: string
|
||||
scheduler?: string
|
||||
lyricsStrength?: number
|
||||
}) {
|
||||
const rows: SettingsRow[] = []
|
||||
if (track.engine) rows.push({ label: 'Engine', value: track.engine === 'ace-step-1.5' ? 'ACE-Step 1.5' : 'ACE-Step' })
|
||||
if (track.tags?.trim()) rows.push({ label: 'Tags', value: track.tags.trim() })
|
||||
if (track.instrumental) rows.push({ label: 'Instrumental', value: 'On' })
|
||||
else if (track.lyrics?.trim()) rows.push({ label: 'Lyrics', value: track.lyrics.trim() })
|
||||
if (typeof track.duration === 'number' && track.duration > 0) {
|
||||
const rounded = Math.round(track.duration * 10) / 10
|
||||
rows.push({ label: 'Duration', value: Number.isInteger(rounded) ? `${rounded}s` : `${rounded.toFixed(1)}s` })
|
||||
}
|
||||
if (track.steps != null) rows.push({ label: 'Steps', value: String(track.steps) })
|
||||
if (track.cfg != null) rows.push({ label: 'CFG', value: formatNum(track.cfg) })
|
||||
if (track.samplerName) rows.push({ label: 'Sampler', value: track.samplerName })
|
||||
if (track.scheduler) rows.push({ label: 'Scheduler', value: track.scheduler })
|
||||
if (track.lyricsStrength != null) rows.push({ label: 'Lyric strength', value: formatNum(track.lyricsStrength) })
|
||||
if (track.seed != null) rows.push({ label: 'Seed', value: String(track.seed) })
|
||||
return rows
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
export const MUSIC_DURATION_MIN = 30
|
||||
export const MUSIC_DURATION_MAX = 180
|
||||
export const MUSIC_DURATION_DEFAULT = 60
|
||||
export const MUSIC_STEPS_MIN = 8
|
||||
export const MUSIC_STEPS_MAX = 100
|
||||
export const MUSIC_STEPS_DEFAULT = 50
|
||||
export const MUSIC_CFG_DEFAULT = 5
|
||||
export const MUSIC_LYRICS_STRENGTH_DEFAULT = 0.9
|
||||
export const MUSIC_TAGS_MAX = 2000
|
||||
export const MUSIC_LYRICS_MAX = 8000
|
||||
|
||||
export const MUSIC_STRUCTURE_CHIPS = [
|
||||
'[Verse 1]',
|
||||
'[Verse 2]',
|
||||
'[Pre-Chorus]',
|
||||
'[Chorus]',
|
||||
'[Bridge]',
|
||||
'[Guitar Solo]',
|
||||
'[Instrumental]',
|
||||
'[Outro]'
|
||||
] as const
|
||||
|
||||
export const DEFAULT_MUSIC_TAGS = 'modern synthpop, 120 bpm, female vocals, punchy bass, analog synths'
|
||||
|
||||
export const DEFAULT_MUSIC_LYRICS = `[Verse 1]
|
||||
|
||||
[Chorus]
|
||||
|
||||
[Guitar Solo]
|
||||
|
||||
[Outro]
|
||||
`
|
||||
|
||||
export type MusicEngine = 'ace-step' | 'ace-step-1.5'
|
||||
|
||||
export function parseMusicEngine(raw: unknown): MusicEngine {
|
||||
const value = String(raw || '').trim().toLowerCase()
|
||||
if (value === 'ace-step-1.5' || value === 'ace15' || value === 'v1.5') return 'ace-step-1.5'
|
||||
return 'ace-step'
|
||||
}
|
||||
|
||||
export function clampMusicDuration(raw: unknown, fallback = MUSIC_DURATION_DEFAULT) {
|
||||
const value = Number(raw)
|
||||
if (!Number.isFinite(value)) return fallback
|
||||
return Math.min(MUSIC_DURATION_MAX, Math.max(MUSIC_DURATION_MIN, Math.round(value)))
|
||||
}
|
||||
|
||||
export function clampMusicSteps(raw: unknown, fallback = MUSIC_STEPS_DEFAULT) {
|
||||
const value = Number(raw)
|
||||
if (!Number.isFinite(value)) return fallback
|
||||
return Math.min(MUSIC_STEPS_MAX, Math.max(MUSIC_STEPS_MIN, Math.round(value)))
|
||||
}
|
||||
|
||||
export function clampMusicCfg(raw: unknown, fallback = MUSIC_CFG_DEFAULT) {
|
||||
const value = Number(raw)
|
||||
if (!Number.isFinite(value)) return fallback
|
||||
return Math.min(15, Math.max(1, Math.round(value * 10) / 10))
|
||||
}
|
||||
|
||||
export function clampLyricsStrength(raw: unknown, fallback = MUSIC_LYRICS_STRENGTH_DEFAULT) {
|
||||
const value = Number(raw)
|
||||
if (!Number.isFinite(value)) return fallback
|
||||
return Math.min(2, Math.max(0, Math.round(value * 100) / 100))
|
||||
}
|
||||
|
||||
export function sanitizeMusicTags(raw: unknown) {
|
||||
return String(raw || '').replace(/\r\n/g, '\n').trim().slice(0, MUSIC_TAGS_MAX)
|
||||
}
|
||||
|
||||
export function sanitizeMusicLyrics(raw: unknown, instrumental = false) {
|
||||
if (instrumental) return ''
|
||||
return String(raw || '').replace(/\r\n/g, '\n').trim().slice(0, MUSIC_LYRICS_MAX)
|
||||
}
|
||||
|
||||
export function parseMusicSeed(raw: unknown) {
|
||||
const text = String(raw ?? '').trim().toLowerCase()
|
||||
if (!text || text === 'random') return Math.floor(Math.random() * 2_147_483_647)
|
||||
const value = Number(text)
|
||||
if (!Number.isFinite(value)) return Math.floor(Math.random() * 2_147_483_647)
|
||||
return Math.max(0, Math.min(2_147_483_647, Math.floor(value)))
|
||||
}
|
||||
|
||||
export function musicContentType(ext: string) {
|
||||
const kind = String(ext || '').replace(/^\./, '').toLowerCase()
|
||||
if (kind === 'wav') return 'audio/wav'
|
||||
if (kind === 'flac') return 'audio/flac'
|
||||
if (kind === 'mp3') return 'audio/mpeg'
|
||||
if (kind === 'ogg' || kind === 'opus') return 'audio/ogg'
|
||||
return 'application/octet-stream'
|
||||
}
|
||||
|
||||
export function musicExtension(filename: string) {
|
||||
const match = String(filename || '').toLowerCase().match(/\.(wav|flac|mp3|ogg|opus)$/)
|
||||
return match ? match[1] : 'flac'
|
||||
}
|
||||
@@ -77,6 +77,9 @@ export function engineFilenameKey(engine: string) {
|
||||
if (value === 'klein') return 'klein'
|
||||
if (value === 'ltx') return 'ltx'
|
||||
if (value === 'minimax') return 'minimax'
|
||||
if (value === 'ace' || value === 'ace-step') return 'ace'
|
||||
if (value === 'ace15' || value === 'ace-step-1.5') return 'ace15'
|
||||
if (value === 'yue') return 'yue'
|
||||
return sanitizeFilenameKey(value, 16) || 'gen'
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user