Add ACE-Step music generation to the studio and library.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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
@@ -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'
|
||||
})
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
{
|
||||
"14": {
|
||||
"inputs": {
|
||||
"ckpt_name": "ace_step_v1_3.5b.safetensors"
|
||||
},
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"_meta": {
|
||||
"title": "Load Checkpoint"
|
||||
}
|
||||
},
|
||||
"40": {
|
||||
"inputs": {
|
||||
"tags": "modern synthpop, 120 bpm, female vocals, punchy bass, analog synths",
|
||||
"lyrics": "[Verse 1]\n\n[Chorus]\n\n[Guitar Solo]\n\n[Outro]\n",
|
||||
"lyrics_strength": 0.9,
|
||||
"clip": [
|
||||
"14",
|
||||
1
|
||||
]
|
||||
},
|
||||
"class_type": "TextEncodeAceStepAudio",
|
||||
"_meta": {
|
||||
"title": "Tags & Lyrics"
|
||||
}
|
||||
},
|
||||
"47": {
|
||||
"inputs": {
|
||||
"conditioning": [
|
||||
"40",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "ConditioningZeroOut",
|
||||
"_meta": {
|
||||
"title": "Negative (zeroed)"
|
||||
}
|
||||
},
|
||||
"17": {
|
||||
"inputs": {
|
||||
"seconds": 60,
|
||||
"batch_size": 1
|
||||
},
|
||||
"class_type": "EmptyAceStepLatentAudio",
|
||||
"_meta": {
|
||||
"title": "Audio Length"
|
||||
}
|
||||
},
|
||||
"45": {
|
||||
"inputs": {
|
||||
"shift": 5,
|
||||
"model": [
|
||||
"14",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "ModelSamplingSD3",
|
||||
"_meta": {
|
||||
"title": "Model Sampling SD3"
|
||||
}
|
||||
},
|
||||
"3": {
|
||||
"inputs": {
|
||||
"seed": 0,
|
||||
"steps": 50,
|
||||
"cfg": 5,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "simple",
|
||||
"denoise": 1,
|
||||
"model": [
|
||||
"45",
|
||||
0
|
||||
],
|
||||
"positive": [
|
||||
"40",
|
||||
0
|
||||
],
|
||||
"negative": [
|
||||
"47",
|
||||
0
|
||||
],
|
||||
"latent_image": [
|
||||
"17",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "KSampler",
|
||||
"_meta": {
|
||||
"title": "Audio Sampler"
|
||||
}
|
||||
},
|
||||
"16": {
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"3",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"14",
|
||||
2
|
||||
]
|
||||
},
|
||||
"class_type": "VAEDecodeAudio",
|
||||
"_meta": {
|
||||
"title": "VAE Decode Audio"
|
||||
}
|
||||
},
|
||||
"19": {
|
||||
"inputs": {
|
||||
"filename_prefix": "audio/AIGen",
|
||||
"audio": [
|
||||
"16",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "SaveAudio",
|
||||
"_meta": {
|
||||
"title": "Save Audio"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user