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
+8 -1
View File
@@ -55,6 +55,13 @@
role="menuitem" role="menuitem"
@click="menuOpen = false" @click="menuOpen = false"
>Director</NuxtLink> >Director</NuxtLink>
<NuxtLink
to="/music"
class="block rounded-xl px-3 py-2 text-sm hover:bg-white/5 hover:text-white"
:class="current === 'music' ? 'text-amber-100' : 'text-zinc-200'"
role="menuitem"
@click="menuOpen = false"
>Music</NuxtLink>
<button <button
type="button" type="button"
class="block w-full rounded-xl px-3 py-2 text-left text-sm hover:bg-white/5 disabled:cursor-not-allowed disabled:opacity-50" class="block w-full rounded-xl px-3 py-2 text-left text-sm hover:bg-white/5 disabled:cursor-not-allowed disabled:opacity-50"
@@ -116,7 +123,7 @@
<script setup lang="ts"> <script setup lang="ts">
withDefaults(defineProps<{ withDefaults(defineProps<{
subtitle: string subtitle: string
current?: 'studio' | 'library' | 'director' | 'queue' | 'settings' current?: 'studio' | 'library' | 'director' | 'queue' | 'settings' | 'music'
wide?: boolean wide?: boolean
queueCount?: number queueCount?: number
}>(), { }>(), {
+219
View File
@@ -0,0 +1,219 @@
<template>
<div class="space-y-3">
<div class="overflow-hidden rounded-2xl border border-white/10 bg-zinc-950/80 p-3">
<canvas
ref="canvasEl"
class="h-16 w-full cursor-pointer"
height="64"
@click="onWaveClick"
/>
<input
type="range"
class="aigen-slider mt-2 w-full"
min="0"
:max="Math.max(duration, 0.01)"
step="0.01"
:value="current"
:disabled="!ready"
:aria-label="label || 'Playback'"
@input="onScrub"
>
</div>
<div class="flex flex-wrap items-center gap-2">
<button
type="button"
class="rounded-full border border-amber-300/40 bg-amber-400/10 px-3 py-1.5 text-xs font-semibold text-amber-100 disabled:opacity-50"
:disabled="!ready"
@click="toggle"
>
{{ playing ? 'Pause' : 'Play' }}
</button>
<p class="min-w-[6.5rem] text-xs tabular-nums text-zinc-400">{{ clock }}</p>
<label class="ml-auto flex items-center gap-2 text-xs text-zinc-400">
Vol
<input
v-model.number="volume"
type="range"
class="aigen-slider w-24"
min="0"
max="1"
step="0.01"
aria-label="Volume"
>
</label>
<a
v-if="src"
:href="src"
:download="downloadName"
class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 hover:text-white"
>Download</a>
</div>
<audio
ref="audioEl"
:src="src || undefined"
preload="metadata"
class="hidden"
@loadedmetadata="onMeta"
@timeupdate="onTime"
@ended="playing = false"
@error="onError"
/>
<p v-if="error" class="text-xs text-red-300">{{ error }}</p>
</div>
</template>
<script setup lang="ts">
const props = withDefaults(defineProps<{
src: string
downloadName?: string
label?: string
}>(), {
downloadName: 'track.flac',
label: ''
})
const audioEl = ref<HTMLAudioElement | null>(null)
const canvasEl = ref<HTMLCanvasElement | null>(null)
const current = ref(0)
const duration = ref(0)
const playing = ref(false)
const ready = ref(false)
const error = ref('')
const volume = ref(0.9)
const peaks = ref<number[]>([])
const clock = computed(() => `${fmt(current.value)} / ${fmt(duration.value)}`)
function fmt(seconds: number) {
if (!Number.isFinite(seconds) || seconds < 0) return '0:00'
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
return `${m}:${String(s).padStart(2, '0')}`
}
function toggle() {
const audio = audioEl.value
if (!audio) return
if (playing.value) {
audio.pause()
playing.value = false
return
}
void audio.play().then(() => { playing.value = true }).catch(() => {
error.value = 'Could not play this file'
})
}
function onMeta() {
const audio = audioEl.value
if (!audio) return
duration.value = audio.duration || 0
ready.value = true
error.value = ''
void decodeWave()
}
function onTime() {
const audio = audioEl.value
if (!audio) return
current.value = audio.currentTime || 0
draw()
}
function onScrub(event: Event) {
const audio = audioEl.value
if (!audio) return
const next = Number((event.target as HTMLInputElement).value)
audio.currentTime = next
current.value = next
draw()
}
function onWaveClick(event: MouseEvent) {
const canvas = canvasEl.value
const audio = audioEl.value
if (!canvas || !audio || !duration.value) return
const rect = canvas.getBoundingClientRect()
const ratio = Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width))
audio.currentTime = ratio * duration.value
current.value = audio.currentTime
draw()
}
function onError() {
ready.value = false
error.value = 'Could not load this audio file'
}
function draw() {
const canvas = canvasEl.value
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const width = canvas.clientWidth || 320
const height = canvas.height
if (canvas.width !== width) canvas.width = width
ctx.clearRect(0, 0, width, height)
const samples = peaks.value.length ? peaks.value : Array.from({ length: 80 }, () => 0.12)
const barW = Math.max(1, width / samples.length)
const progress = duration.value > 0 ? current.value / duration.value : 0
samples.forEach((amp, index) => {
const x = index * barW
const h = Math.max(2, amp * (height - 8))
const y = (height - h) / 2
ctx.fillStyle = (index / samples.length) <= progress ? '#fbbf24' : 'rgba(255,255,255,0.18)'
ctx.fillRect(x, y, Math.max(1, barW - 1), h)
})
}
async function decodeWave() {
if (!props.src || typeof window === 'undefined') return
try {
const res = await fetch(props.src)
if (!res.ok) return
const buf = await res.arrayBuffer()
const ctx = new AudioContext()
const decoded = await ctx.decodeAudioData(buf.slice(0))
await ctx.close()
const data = decoded.getChannelData(0)
const buckets = 120
const size = Math.floor(data.length / buckets) || 1
const next: number[] = []
for (let i = 0; i < buckets; i++) {
let sum = 0
const start = i * size
for (let j = 0; j < size; j++) sum += Math.abs(data[start + j] || 0)
next.push(Math.min(1, (sum / size) * 3.2))
}
peaks.value = next
draw()
} catch {
draw()
}
}
watch(() => props.src, () => {
current.value = 0
duration.value = 0
playing.value = false
ready.value = false
error.value = ''
peaks.value = []
draw()
})
watch(volume, (value) => {
if (audioEl.value) audioEl.value.volume = value
})
onMounted(() => {
if (audioEl.value) audioEl.value.volume = volume.value
draw()
window.addEventListener('resize', draw)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', draw)
audioEl.value?.pause()
})
</script>
+1
View File
@@ -58,6 +58,7 @@ export default defineNuxtConfig({
backupIngestToken: process.env.BACKUP_INGEST_TOKEN || 'carr-aigen-xaigen-backup', backupIngestToken: process.env.BACKUP_INGEST_TOKEN || 'carr-aigen-xaigen-backup',
purgeComfyOutputs: process.env.PURGE_COMFY_OUTPUTS !== 'false', purgeComfyOutputs: process.env.PURGE_COMFY_OUTPUTS !== 'false',
comfyFilenamePrefix: process.env.COMFY_FILENAME_PREFIX || 'video/MiniMax_H3', comfyFilenamePrefix: process.env.COMFY_FILENAME_PREFIX || 'video/MiniMax_H3',
comfyAudioPrefix: process.env.COMFY_AUDIO_PREFIX || 'audio/AIGen',
kreaConceptLora: process.env.NUXT_KREA_CONCEPT_LORA || '', kreaConceptLora: process.env.NUXT_KREA_CONCEPT_LORA || '',
public: { public: {
authEnabled: process.env.NUXT_PUBLIC_AUTH_ENABLED === 'true', authEnabled: process.env.NUXT_PUBLIC_AUTH_ENABLED === 'true',
+12 -1
View File
@@ -10,7 +10,7 @@
<main class="mx-auto grid min-w-0 max-w-7xl gap-6 px-4 py-6 sm:px-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:items-stretch"> <main class="mx-auto grid min-w-0 max-w-7xl gap-6 px-4 py-6 sm:px-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:items-stretch">
<section id="studio-input" class="glass min-w-0 max-w-full space-y-5 rounded-3xl p-5 sm:p-6"> <section id="studio-input" class="glass min-w-0 max-w-full space-y-5 rounded-3xl p-5 sm:p-6">
<div> <div>
<div class="mb-4 grid grid-cols-2 gap-3"> <div class="mb-4 grid grid-cols-3 gap-3">
<button <button
type="button" type="button"
class="rounded-3xl border px-4 py-4 text-left transition" class="rounded-3xl border px-4 py-4 text-left transition"
@@ -39,6 +39,17 @@
<span class="mt-3 block font-display text-lg font-bold text-zinc-100">Image</span> <span class="mt-3 block font-display text-lg font-bold text-zinc-100">Image</span>
<span class="mt-0.5 block text-xs text-zinc-500">Flux · Krea</span> <span class="mt-0.5 block text-xs text-zinc-500">Flux · Krea</span>
</button> </button>
<NuxtLink
to="/music"
class="rounded-3xl border border-white/10 px-4 py-4 text-left text-zinc-400 transition hover:border-white/20 hover:text-zinc-200"
>
<svg class="h-10 w-10" viewBox="0 0 40 40" fill="none" aria-hidden="true">
<circle cx="20" cy="20" r="12" stroke="currentColor" stroke-width="2" />
<path d="M18 14v12l10-4V12l-10 2Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round" />
</svg>
<span class="mt-3 block font-display text-lg font-bold text-zinc-100">Music</span>
<span class="mt-0.5 block text-xs text-zinc-500">ACE-Step · vocals</span>
</NuxtLink>
</div> </div>
<h2 class="font-display text-2xl font-bold">Input</h2> <h2 class="font-display text-2xl font-bold">Input</h2>
<p class="text-sm text-zinc-400">{{ studioMode === 'editv2' <p class="text-sm text-zinc-400">{{ studioMode === 'editv2'
+85 -17
View File
@@ -161,6 +161,18 @@
> >
<div class="relative aspect-[4/5] bg-zinc-900"> <div class="relative aspect-[4/5] bg-zinc-900">
<span v-if="thumbHidden(entry)" class="sensitive-glow block h-full w-full" /> <span v-if="thumbHidden(entry)" class="sensitive-glow block h-full w-full" />
<div
v-else-if="entry.kind === 'track'"
class="flex h-full w-full items-end gap-0.5 bg-zinc-900 px-4 py-8"
aria-hidden="true"
>
<span
v-for="n in 16"
:key="n"
class="flex-1 rounded-full bg-amber-400/70"
:style="{ height: `${20 + ((n * 17) % 60)}%` }"
/>
</div>
<img <img
v-else-if="thumbSrc(entry)" v-else-if="thumbSrc(entry)"
:src="thumbSrc(entry)" :src="thumbSrc(entry)"
@@ -171,7 +183,7 @@
class="h-full w-full object-cover" class="h-full w-full object-cover"
> >
<span v-else class="block h-full w-full bg-zinc-800" /> <span v-else class="block h-full w-full bg-zinc-800" />
<span class="absolute left-2 top-2 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-zinc-200">{{ entry.kind === 'clip' ? 'Video' : 'Image' }}</span> <span class="absolute left-2 top-2 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-zinc-200">{{ entry.kind === 'clip' ? 'Video' : entry.kind === 'track' ? 'Music' : 'Image' }}</span>
<span <span
v-if="partCount(entry) > 1" v-if="partCount(entry) > 1"
class="absolute right-2 top-2 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] text-amber-100" class="absolute right-2 top-2 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] text-amber-100"
@@ -235,6 +247,9 @@
controls controls
playsinline playsinline
/> />
<div v-else-if="focused.kind === 'track' && previewAudio" class="p-4">
<AudioPlayer :src="previewAudio" :download-name="`${focused.name}.${focused.track?.ext || 'flac'}`" />
</div>
<button <button
v-else-if="focused.kind === 'still' && previewStillSrc" v-else-if="focused.kind === 'still' && previewStillSrc"
type="button" type="button"
@@ -263,6 +278,7 @@
> >
</button> </button>
<FamilyNav <FamilyNav
v-if="focused.kind !== 'track'"
:count="focusedNavParts.length" :count="focusedNavParts.length"
:index="focusedNavIndex" :index="focusedNavIndex"
:prev-label="focused?.kind === 'clip' ? 'Previous clip' : 'Previous image'" :prev-label="focused?.kind === 'clip' ? 'Previous clip' : 'Previous image'"
@@ -270,7 +286,7 @@
@step="stepFocusedFamily" @step="stepFocusedFamily"
/> />
<button <button
v-if="showPrivacyToggles" v-if="showPrivacyToggles && focused.kind !== 'track'"
type="button" type="button"
class="absolute right-2 top-2 z-30 flex h-8 w-8 items-center justify-center rounded-full border border-white/20 bg-black/60 text-zinc-100 hover:border-amber-300/50 hover:text-amber-100" class="absolute right-2 top-2 z-30 flex h-8 w-8 items-center justify-center rounded-full border border-white/20 bg-black/60 text-zinc-100 hover:border-amber-300/50 hover:text-amber-100"
:title="detailsHidden ? 'Show preview' : 'Hide preview'" :title="detailsHidden ? 'Show preview' : 'Hide preview'"
@@ -300,8 +316,8 @@
</button> </button>
</div> </div>
<div class="mt-3 flex flex-wrap gap-2"> <div class="mt-3 flex flex-wrap gap-2">
<button type="button" class="rounded-full border border-amber-300/40 px-3 py-1.5 text-xs text-amber-100" @click="openInStudio(focused)">Open in Studio</button> <button type="button" class="rounded-full border border-amber-300/40 px-3 py-1.5 text-xs text-amber-100" @click="openInStudio(focused)">{{ focused.kind === 'track' ? 'Open in Music' : 'Open in Studio' }}</button>
<button type="button" class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 hover:text-white" @click="useInStudio(focused)">Use as input</button> <button v-if="focused.kind !== 'track'" type="button" class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 hover:text-white" @click="useInStudio(focused)">Use as input</button>
<button <button
v-if="focused.kind === 'still' && focusedStillRole !== 'input'" v-if="focused.kind === 'still' && focusedStillRole !== 'input'"
type="button" type="button"
@@ -323,7 +339,7 @@
class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 hover:text-white" class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 hover:text-white"
>Download</a> >Download</a>
<button <button
v-if="!detailsHidden && (previewVideo || previewStillSrc || thumbSrc(focused))" v-if="!detailsHidden && focused.kind !== 'track' && (previewVideo || previewStillSrc || thumbSrc(focused))"
type="button" type="button"
class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 hover:text-white" class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 hover:text-white"
@click="lightboxOpen = true" @click="lightboxOpen = true"
@@ -364,7 +380,7 @@
<Teleport to="body"> <Teleport to="body">
<div <div
v-if="lightboxOpen && focused && !detailsHidden" v-if="lightboxOpen && focused && !detailsHidden && focused.kind !== 'track'"
class="fixed inset-0 z-[80] flex items-center justify-center bg-black/85 p-3 sm:p-6" class="fixed inset-0 z-[80] flex items-center justify-center bg-black/85 p-3 sm:p-6"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
@@ -446,10 +462,11 @@ import {
type LibraryFilter, type LibraryFilter,
type LibraryFolder, type LibraryFolder,
type LibrarySort, type LibrarySort,
type LibraryStill type LibraryStill,
type LibraryTrack
} from '~/utils/libraryBrowse' } from '~/utils/libraryBrowse'
import { isXaigenStudio } from '~/utils/videoModels' import { isXaigenStudio } from '~/utils/videoModels'
import { clipSettingsRows, stillSettingsRows } from '~/utils/libraryGeneration' import { clipSettingsRows, stillSettingsRows, trackSettingsRows } from '~/utils/libraryGeneration'
const CARD_MIN = 156 const CARD_MIN = 156
const GAP = 10 const GAP = 10
@@ -459,7 +476,8 @@ const DRAG_MIME = 'application/x-aigen-library'
const filterOptions = [ const filterOptions = [
{ id: 'all' as const, label: 'All' }, { id: 'all' as const, label: 'All' },
{ id: 'images' as const, label: 'Images' }, { id: 'images' as const, label: 'Images' },
{ id: 'videos' as const, label: 'Videos' } { id: 'videos' as const, label: 'Videos' },
{ id: 'music' as const, label: 'Music' }
] ]
const runtimeConfig = useRuntimeConfig() const runtimeConfig = useRuntimeConfig()
@@ -473,6 +491,7 @@ const showPrivacyToggles = computed(() => isXaigenStudio({
const folders = ref<LibraryFolder[]>([]) const folders = ref<LibraryFolder[]>([])
const clips = ref<LibraryClip[]>([]) const clips = ref<LibraryClip[]>([])
const stills = ref<LibraryStill[]>([]) const stills = ref<LibraryStill[]>([])
const tracks = ref<LibraryTrack[]>([])
const folderId = ref('all') const folderId = ref('all')
const filter = ref<LibraryFilter>('all') const filter = ref<LibraryFilter>('all')
const sort = ref<LibrarySort>('date-desc') const sort = ref<LibrarySort>('date-desc')
@@ -511,6 +530,7 @@ const browseFolder = computed(() => folders.value.find(folder => folder.id === f
const entries = computed(() => buildLibraryEntries({ const entries = computed(() => buildLibraryEntries({
clips: clips.value, clips: clips.value,
stills: stills.value, stills: stills.value,
tracks: tracks.value,
folderId: folderId.value, folderId: folderId.value,
filter: filter.value, filter: filter.value,
search: search.value, search: search.value,
@@ -521,6 +541,7 @@ const allCount = computed(() => {
return buildLibraryEntries({ return buildLibraryEntries({
clips: clips.value, clips: clips.value,
stills: stills.value, stills: stills.value,
tracks: tracks.value,
folderId: 'all', folderId: 'all',
filter: filter.value, filter: filter.value,
search: '', search: '',
@@ -528,17 +549,21 @@ const allCount = computed(() => {
}).length }).length
}) })
const folderChipCounts = computed(() => { const folderChipCounts = computed(() => {
const map: Record<string, { videos: number; images: number }> = {} const map: Record<string, { videos: number; images: number; music: number }> = {}
for (const clip of clips.value) { for (const clip of clips.value) {
if (clip.hasVideo === false) continue if (clip.hasVideo === false) continue
const row = map[clip.folderId] || (map[clip.folderId] = { videos: 0, images: 0 }) const row = map[clip.folderId] || (map[clip.folderId] = { videos: 0, images: 0, music: 0 })
row.videos += 1 row.videos += 1
} }
for (const still of stills.value) { for (const still of stills.value) {
if (still.role !== 'output') continue if (still.role !== 'output') continue
const row = map[still.folderId] || (map[still.folderId] = { videos: 0, images: 0 }) const row = map[still.folderId] || (map[still.folderId] = { videos: 0, images: 0, music: 0 })
row.images += 1 row.images += 1
} }
for (const track of tracks.value) {
const row = map[track.folderId] || (map[track.folderId] = { videos: 0, images: 0, music: 0 })
row.music += 1
}
return map return map
}) })
const moveFolders = computed(() => folders.value.filter(folder => folder.id !== folderId.value)) const moveFolders = computed(() => folders.value.filter(folder => folder.id !== folderId.value))
@@ -552,6 +577,7 @@ const emptyLabel = computed(() => {
if (search.value.trim()) return 'Nothing matches that search.' if (search.value.trim()) return 'Nothing matches that search.'
if (filter.value === 'images') return 'No generated stills here yet.' if (filter.value === 'images') return 'No generated stills here yet.'
if (filter.value === 'videos') return 'No generated clips here yet.' if (filter.value === 'videos') return 'No generated clips here yet.'
if (filter.value === 'music') return 'No generated tracks here yet.'
return 'Nothing in this folder yet.' return 'Nothing in this folder yet.'
}) })
const cols = computed(() => Math.max(2, Math.floor((viewportW.value + GAP) / (CARD_MIN + GAP)))) const cols = computed(() => Math.max(2, Math.floor((viewportW.value + GAP) / (CARD_MIN + GAP))))
@@ -580,6 +606,10 @@ const previewStillSrc = computed(() => {
const id = focusedPartId.value || focused.value.stillGroup.head.id const id = focusedPartId.value || focused.value.stillGroup.head.id
return `/api/library/stills/${id}` return `/api/library/stills/${id}`
}) })
const previewAudio = computed(() => {
if (focused.value?.kind !== 'track' || !focused.value.track) return ''
return `/api/library/tracks/${focused.value.track.id}/audio`
})
const focusedParts = computed(() => { const focusedParts = computed(() => {
if (focused.value?.kind === 'clip') return focused.value.group?.parts || [] if (focused.value?.kind === 'clip') return focused.value.group?.parts || []
return focused.value?.stillGroup?.parts || [] return focused.value?.stillGroup?.parts || []
@@ -606,6 +636,9 @@ const focusedSettingsRows = computed(() => {
const item = focused.value.group.parts.find(part => part.id === focusedPartId.value) || focused.value.group.head const item = focused.value.group.parts.find(part => part.id === focusedPartId.value) || focused.value.group.head
return clipSettingsRows(item) return clipSettingsRows(item)
} }
if (focused.value?.kind === 'track' && focused.value.track) {
return trackSettingsRows(focused.value.track)
}
const still = focused.value?.stillGroup?.parts.find(part => part.id === focusedPartId.value) || focused.value?.stillGroup?.head const still = focused.value?.stillGroup?.parts.find(part => part.id === focusedPartId.value) || focused.value?.stillGroup?.head
return still ? stillSettingsRows(still) : [] return still ? stillSettingsRows(still) : []
}) })
@@ -616,6 +649,9 @@ const detailMeta = computed(() => {
const item = entry.group.parts.find(part => part.id === focusedPartId.value) || entry.group.head const item = entry.group.parts.find(part => part.id === focusedPartId.value) || entry.group.head
return [item.width && item.height ? `${item.width}×${item.height}` : '', formatClipDuration(item.duration), formatWhen(item.createdAt)].filter(Boolean).join(' · ') return [item.width && item.height ? `${item.width}×${item.height}` : '', formatClipDuration(item.duration), formatWhen(item.createdAt)].filter(Boolean).join(' · ')
} }
if (entry.kind === 'track' && entry.track) {
return [formatClipDuration(entry.track.duration), entry.track.ext?.toUpperCase(), formatWhen(entry.track.createdAt)].filter(Boolean).join(' · ')
}
const still = entry.stillGroup?.parts.find(part => part.id === focusedPartId.value) || entry.stillGroup?.head const still = entry.stillGroup?.parts.find(part => part.id === focusedPartId.value) || entry.stillGroup?.head
if (!still) return '' if (!still) return ''
return [still.width && still.height ? `${still.width}×${still.height}` : 'image', formatWhen(still.createdAt)].filter(Boolean).join(' · ') return [still.width && still.height ? `${still.width}×${still.height}` : 'image', formatWhen(still.createdAt)].filter(Boolean).join(' · ')
@@ -625,6 +661,9 @@ const downloadHref = computed(() => {
const id = focusedPartId.value || focused.value.group.head.id const id = focusedPartId.value || focused.value.group.head.id
return `/api/library/clips/${id}/video` return `/api/library/clips/${id}/video`
} }
if (focused.value?.kind === 'track' && focused.value.track) {
return `/api/library/tracks/${focused.value.track.id}/audio`
}
if (focused.value?.stillGroup) { if (focused.value?.stillGroup) {
const id = focusedPartId.value || focused.value.stillGroup.head.id const id = focusedPartId.value || focused.value.stillGroup.head.id
return `/api/library/stills/${id}` return `/api/library/stills/${id}`
@@ -648,8 +687,9 @@ function applyLibrary(data: unknown) {
folders?: LibraryFolder[] folders?: LibraryFolder[]
clips?: LibraryClip[] clips?: LibraryClip[]
stills?: LibraryStill[] stills?: LibraryStill[]
tracks?: LibraryTrack[]
} }
if (payload.id && !payload.folders && !payload.clips && !payload.stills) { if (payload.id && !payload.folders && !payload.clips && !payload.stills && !payload.tracks) {
if (typeof payload.hideInput === 'boolean') { if (typeof payload.hideInput === 'boolean') {
const still = stills.value.find(item => item.id === payload.id) const still = stills.value.find(item => item.id === payload.id)
if (still) still.hideInput = payload.hideInput if (still) still.hideInput = payload.hideInput
@@ -663,6 +703,7 @@ function applyLibrary(data: unknown) {
folders.value = payload.folders || [] folders.value = payload.folders || []
clips.value = payload.clips || [] clips.value = payload.clips || []
stills.value = payload.stills || [] stills.value = payload.stills || []
tracks.value = payload.tracks || []
if (folderId.value !== 'all' && !folders.value.some(folder => folder.id === folderId.value)) { if (folderId.value !== 'all' && !folders.value.some(folder => folder.id === folderId.value)) {
folderId.value = folders.value[0]?.id || 'all' folderId.value = folders.value[0]?.id || 'all'
} }
@@ -673,12 +714,13 @@ async function loadLibrary() {
} }
function folderChipCount(folder: LibraryFolder) { function folderChipCount(folder: LibraryFolder) {
if (!folder.unlocked) return (folder.clipCount || 0) + (folder.imageCount || 0) if (!folder.unlocked) return (folder.clipCount || 0) + (folder.imageCount || 0) + (folder.trackCount || 0)
const row = folderChipCounts.value[folder.id] const row = folderChipCounts.value[folder.id]
if (!row) return 0 if (!row) return 0
if (filter.value === 'videos') return row.videos if (filter.value === 'videos') return row.videos
if (filter.value === 'images') return row.images if (filter.value === 'images') return row.images
return row.videos + row.images if (filter.value === 'music') return row.music
return row.videos + row.images + row.music
} }
function folderChipClass(id: string) { function folderChipClass(id: string) {
@@ -696,6 +738,7 @@ function cardClass(entry: LibraryEntry) {
function itemHidden(entry: LibraryEntry) { function itemHidden(entry: LibraryEntry) {
if (entry.kind === 'clip') return Boolean(entry.group?.head.hideThumbnail) if (entry.kind === 'clip') return Boolean(entry.group?.head.hideThumbnail)
if (entry.kind === 'track') return false
return Boolean(entry.stillGroup?.head.hideInput) return Boolean(entry.stillGroup?.head.hideInput)
} }
@@ -751,11 +794,13 @@ async function toggleFocusedHidden() {
function thumbSrc(entry: LibraryEntry) { function thumbSrc(entry: LibraryEntry) {
if (entry.kind === 'clip' && entry.group) return `/api/library/clips/${entry.group.head.id}/thumb` if (entry.kind === 'clip' && entry.group) return `/api/library/clips/${entry.group.head.id}/thumb`
if (entry.kind === 'track') return ''
if (entry.stillGroup) return `/api/library/stills/${entry.stillGroup.head.id}?thumb=1` if (entry.stillGroup) return `/api/library/stills/${entry.stillGroup.head.id}?thumb=1`
return '' return ''
} }
function partCount(entry: LibraryEntry) { function partCount(entry: LibraryEntry) {
if (entry.kind === 'track') return 1
return entry.kind === 'clip' ? (entry.group?.parts.length || 1) : (entry.stillGroup?.parts.length || 1) return entry.kind === 'clip' ? (entry.group?.parts.length || 1) : (entry.stillGroup?.parts.length || 1)
} }
@@ -764,6 +809,9 @@ function cardMeta(entry: LibraryEntry) {
const branches = familyBranchHint(entry.group.parts) const branches = familyBranchHint(entry.group.parts)
return [formatClipDuration(entry.group.head.duration), formatWhen(entry.createdAt), branches].filter(Boolean).join(' · ') return [formatClipDuration(entry.group.head.duration), formatWhen(entry.createdAt), branches].filter(Boolean).join(' · ')
} }
if (entry.kind === 'track' && entry.track) {
return [formatClipDuration(entry.track.duration), formatWhen(entry.createdAt)].filter(Boolean).join(' · ')
}
return formatWhen(entry.createdAt) return formatWhen(entry.createdAt)
} }
@@ -847,7 +895,11 @@ function onCardClick(entry: LibraryEntry, event: MouseEvent) {
} }
const sameCard = focusedKey.value === entry.key const sameCard = focusedKey.value === entry.key
focusedKey.value = entry.key focusedKey.value = entry.key
focusedPartId.value = entry.kind === 'clip' ? (entry.group?.head.id || '') : (entry.stillGroup?.head.id || '') focusedPartId.value = entry.kind === 'clip'
? (entry.group?.head.id || '')
: entry.kind === 'track'
? (entry.track?.id || '')
: (entry.stillGroup?.head.id || '')
if (!sameCard) detailsHidden.value = itemHidden(entry) && !revealAll.value if (!sameCard) detailsHidden.value = itemHidden(entry) && !revealAll.value
cancelRename() cancelRename()
} }
@@ -886,7 +938,11 @@ function selectedEntries(keys = selectedKeys.value) {
async function moveKeys(keys: string[], destId: string) { async function moveKeys(keys: string[], destId: string) {
const items = selectedEntries(keys).filter((entry) => { const items = selectedEntries(keys).filter((entry) => {
const current = entry.kind === 'clip' ? entry.group?.head.folderId : entry.stillGroup?.head.folderId const current = entry.kind === 'clip'
? entry.group?.head.folderId
: entry.kind === 'track'
? entry.track?.folderId
: entry.stillGroup?.head.folderId
return current && current !== destId return current && current !== destId
}) })
if (!items.length) return if (!items.length) return
@@ -895,6 +951,9 @@ async function moveKeys(keys: string[], destId: string) {
if (entry.kind === 'clip') { if (entry.kind === 'clip') {
return $fetch(`/api/library/clips/${entry.group!.head.id}`, { method: 'PATCH', body: { folderId: destId, family: true } }) return $fetch(`/api/library/clips/${entry.group!.head.id}`, { method: 'PATCH', body: { folderId: destId, family: true } })
} }
if (entry.kind === 'track') {
return $fetch(`/api/library/tracks/${entry.track!.id}`, { method: 'PATCH', body: { folderId: destId } })
}
return $fetch(`/api/library/stills/${entry.stillGroup!.head.id}`, { method: 'PATCH', body: { folderId: destId, family: true } }) return $fetch(`/api/library/stills/${entry.stillGroup!.head.id}`, { method: 'PATCH', body: { folderId: destId, family: true } })
})) }))
await loadLibrary() await loadLibrary()
@@ -923,6 +982,10 @@ function openInStudio(entry: LibraryEntry) {
navigateTo({ path: '/', query: { clip: focusedPartId.value || entry.group.head.id } }) navigateTo({ path: '/', query: { clip: focusedPartId.value || entry.group.head.id } })
return return
} }
if (entry.kind === 'track' && entry.track) {
navigateTo({ path: '/music', query: { track: entry.track.id } })
return
}
if (entry.stillGroup) { if (entry.stillGroup) {
navigateTo({ path: '/', query: { still: focusedPartId.value || entry.stillGroup.head.id } }) navigateTo({ path: '/', query: { still: focusedPartId.value || entry.stillGroup.head.id } })
} }
@@ -996,6 +1059,8 @@ async function saveRename() {
try { try {
if (entry.kind === 'clip' && entry.group) { if (entry.kind === 'clip' && entry.group) {
applyLibrary(await $fetch(`/api/library/clips/${entry.group.head.id}`, { method: 'PATCH', body: { name, family: true } })) applyLibrary(await $fetch(`/api/library/clips/${entry.group.head.id}`, { method: 'PATCH', body: { name, family: true } }))
} else if (entry.kind === 'track' && entry.track) {
applyLibrary(await $fetch(`/api/library/tracks/${entry.track.id}`, { method: 'PATCH', body: { name } }))
} else if (entry.stillGroup) { } else if (entry.stillGroup) {
applyLibrary(await $fetch(`/api/library/stills/${entry.stillGroup.head.id}`, { method: 'PATCH', body: { name, family: true } })) applyLibrary(await $fetch(`/api/library/stills/${entry.stillGroup.head.id}`, { method: 'PATCH', body: { name, family: true } }))
} }
@@ -1038,6 +1103,9 @@ async function confirmDelete() {
if (entry.kind === 'clip') { if (entry.kind === 'clip') {
return $fetch(`/api/library/clips/${entry.group!.head.id}`, { method: 'DELETE', query: { family: '1' } }) return $fetch(`/api/library/clips/${entry.group!.head.id}`, { method: 'DELETE', query: { family: '1' } })
} }
if (entry.kind === 'track') {
return $fetch(`/api/library/tracks/${entry.track!.id}`, { method: 'DELETE' })
}
return $fetch(`/api/library/stills/${entry.stillGroup!.head.id}`, { method: 'DELETE', query: { family: '1' } }) return $fetch(`/api/library/stills/${entry.stillGroup!.head.id}`, { method: 'DELETE', query: { family: '1' } })
})) }))
if (pending.keys.includes(focusedKey.value)) { if (pending.keys.includes(focusedKey.value)) {
+344
View File
@@ -0,0 +1,344 @@
<template>
<div class="min-h-dvh min-w-0">
<AppHeader
subtitle="ACE-Step · vocals"
current="music"
/>
<main class="mx-auto grid min-w-0 max-w-7xl gap-6 px-4 py-6 sm:px-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:items-stretch">
<section class="glass min-w-0 max-w-full space-y-5 rounded-3xl p-5 sm:p-6">
<div>
<h2 class="font-display text-2xl font-bold">Music Generator</h2>
<p class="mt-1 text-sm text-zinc-400">
Tags describe style and voice. Lyrics use structure chips. Instrumental skips the vocal line.
</p>
</div>
<p v-if="error" class="rounded-2xl border border-red-400/30 bg-red-400/10 px-4 py-3 text-sm text-red-200">{{ error }}</p>
<label class="block text-sm">
<span class="mb-1 block font-medium text-zinc-300">Genre &amp; style tags</span>
<input
v-model="tags"
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
:placeholder="tagsPlaceholder"
maxlength="2000"
>
</label>
<div>
<div class="mb-1 flex flex-wrap items-center justify-between gap-2">
<span class="text-sm font-medium text-zinc-300">Lyrics</span>
<label class="flex cursor-pointer items-center gap-2 text-xs text-zinc-400">
<input v-model="instrumental" type="checkbox" class="accent-amber-400">
Instrumental
</label>
</div>
<div class="mb-2 flex flex-wrap gap-1.5">
<button
v-for="chip in chips"
:key="chip"
type="button"
class="rounded-full border border-white/10 px-2.5 py-1 text-[11px] text-zinc-300 hover:border-amber-300/50 hover:text-white disabled:opacity-40"
:disabled="instrumental"
@click="insertChip(chip)"
>{{ chip }}</button>
</div>
<textarea
ref="lyricsEl"
v-model="lyrics"
rows="12"
class="max-h-[min(28rem,50vh)] w-full overflow-y-auto rounded-2xl border border-white/10 bg-zinc-950/70 px-4 py-3 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 [overflow-wrap:anywhere] focus:ring-2 disabled:opacity-50"
:disabled="instrumental"
:placeholder="lyricsPlaceholder"
/>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<label class="block text-sm">
<span class="mb-1 block font-medium text-zinc-300">Duration · {{ duration }}s</span>
<input
v-model.number="duration"
type="range"
class="aigen-slider w-full"
:min="durationMin"
:max="durationMax"
step="5"
>
<span class="mt-1 block text-[11px] text-zinc-500">{{ durationMin }}–{{ durationMax }} seconds</span>
</label>
<label class="block text-sm">
<span class="mb-1 block font-medium text-zinc-300">Steps · {{ steps }}</span>
<input
v-model.number="steps"
type="range"
class="aigen-slider w-full"
:min="stepsMin"
:max="stepsMax"
step="1"
>
<span class="mt-1 block text-[11px] text-zinc-500">30–50 is the usual range</span>
</label>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<label class="block text-sm">
<span class="mb-1 block font-medium text-zinc-300">Seed</span>
<input
v-model="seed"
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
placeholder="random"
>
</label>
<label class="block text-sm">
<span class="mb-1 block font-medium text-zinc-300">Folder</span>
<select v-model="folderId" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm">
<option v-for="folder in folders" :key="folder.id" :value="folder.id">{{ folder.name }}</option>
</select>
</label>
</div>
<label class="block text-sm">
<span class="mb-1 block font-medium text-zinc-300">Name</span>
<input
v-model="name"
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
maxlength="80"
placeholder="Optional track name"
>
</label>
<button
type="button"
class="inline-flex w-full items-center justify-center gap-2 rounded-2xl bg-amber-400 px-4 py-3 text-sm font-semibold text-zinc-950 hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="busy || blocked"
@click="generate"
>
<span
v-if="busy"
class="h-4 w-4 animate-spin rounded-full border-2 border-zinc-950/20 border-t-zinc-950"
/>
{{ busy ? (status || 'Generating…') : 'Generate' }}
</button>
<p v-if="blockReason" class="text-xs text-zinc-400">{{ blockReason }}</p>
</section>
<section class="glass min-w-0 max-w-full space-y-4 rounded-3xl p-5 sm:p-6">
<div>
<h2 class="font-display text-2xl font-bold">Output</h2>
<p class="mt-1 text-sm text-zinc-400">{{ status || 'Waiting for a generate.' }}</p>
</div>
<div v-if="busy" class="h-2 overflow-hidden rounded-full bg-zinc-800">
<div class="progress-glow h-full rounded-full bg-amber-400 transition-[width]" :style="{ width: `${Math.max(progress, 4)}%` }" />
</div>
<AudioPlayer
v-if="audioUrl"
:src="audioUrl"
:download-name="downloadName"
/>
<div v-else class="flex min-h-44 items-center justify-center rounded-2xl border border-dashed border-white/10 text-sm text-zinc-500">
Generated FLAC / WAV plays here
</div>
<p v-if="trackId" class="text-xs text-zinc-500">
Saved to library.
<NuxtLink to="/library" class="text-amber-200 hover:text-amber-100">Open Library</NuxtLink>
</p>
</section>
</main>
</div>
</template>
<script setup lang="ts">
import {
DEFAULT_MUSIC_LYRICS,
DEFAULT_MUSIC_TAGS,
MUSIC_DURATION_DEFAULT,
MUSIC_DURATION_MAX,
MUSIC_DURATION_MIN,
MUSIC_STEPS_DEFAULT,
MUSIC_STEPS_MAX,
MUSIC_STEPS_MIN,
MUSIC_STRUCTURE_CHIPS
} from '~/utils/music'
import { ensureGenerationNotifyPermission, notifyGenerationComplete } from '~/utils/generationNotify'
import type { LibraryFolder } from '~/utils/libraryBrowse'
const tagsPlaceholder = DEFAULT_MUSIC_TAGS
const lyricsPlaceholder = DEFAULT_MUSIC_LYRICS
const chips = MUSIC_STRUCTURE_CHIPS
const durationMin = MUSIC_DURATION_MIN
const durationMax = MUSIC_DURATION_MAX
const stepsMin = MUSIC_STEPS_MIN
const stepsMax = MUSIC_STEPS_MAX
const tags = ref(DEFAULT_MUSIC_TAGS)
const lyrics = ref(DEFAULT_MUSIC_LYRICS)
const instrumental = ref(false)
const duration = ref(MUSIC_DURATION_DEFAULT)
const steps = ref(MUSIC_STEPS_DEFAULT)
const seed = ref('')
const name = ref('')
const folderId = ref('')
const folders = ref<LibraryFolder[]>([])
const lyricsEl = ref<HTMLTextAreaElement | null>(null)
const busy = ref(false)
const error = ref('')
const status = ref('')
const progress = ref(0)
const audioUrl = ref('')
const trackId = ref('')
const jobId = ref('')
const downloadName = ref('track.flac')
let source: EventSource | null = null
let listenGen = 0
let settled = false
const blocked = computed(() => !tags.value.trim() || (!instrumental.value && !lyrics.value.trim()) || !folderId.value)
const blockReason = computed(() => {
if (!folderId.value) return 'Choose a library folder before generating.'
if (!tags.value.trim()) return 'Add genre and style tags.'
if (!instrumental.value && !lyrics.value.trim()) return 'Write lyrics, or turn on Instrumental.'
return ''
})
function insertChip(chip: string) {
const el = lyricsEl.value
const token = `${chip}\n`
if (!el) {
lyrics.value = `${lyrics.value}${lyrics.value.endsWith('\n') || !lyrics.value ? '' : '\n'}${token}`
return
}
const start = el.selectionStart ?? lyrics.value.length
const end = el.selectionEnd ?? start
lyrics.value = `${lyrics.value.slice(0, start)}${token}${lyrics.value.slice(end)}`
nextTick(() => {
const pos = start + token.length
el.focus()
el.setSelectionRange(pos, pos)
})
}
async function loadLibrary() {
const payload = await $fetch<{ folders?: LibraryFolder[] }>('/api/library?light=1').catch(() => ({ folders: [] }))
folders.value = payload.folders || []
if (!folderId.value || !folders.value.some(folder => folder.id === folderId.value)) {
folderId.value = folders.value.find(folder => folder.unlocked)?.id || folders.value[0]?.id || ''
}
}
function stopListen() {
listenGen += 1
source?.close()
source = null
}
function applyEvent(payload: Record<string, any>) {
if (payload.message) status.value = payload.message
if (typeof payload.progress === 'number') progress.value = payload.progress
if (payload.type === 'complete' || payload.status === 'complete') {
settled = true
busy.value = false
status.value = payload.message || 'Track ready'
progress.value = 100
if (payload.trackId) {
trackId.value = payload.trackId
audioUrl.value = `/api/library/tracks/${payload.trackId}/audio`
downloadName.value = `${(name.value || 'track').replace(/[^\w.-]+/g, '-')}.flac`
} else if (payload.filename) {
const params = new URLSearchParams({
filename: payload.filename,
subfolder: payload.subfolder || 'audio',
type: payload.mediaType || 'output'
})
audioUrl.value = `/api/media?${params.toString()}`
}
stopListen()
void notifyGenerationComplete('music')
}
if (payload.type === 'error' || payload.status === 'error') {
settled = true
busy.value = false
error.value = payload.error || payload.message || 'Generation failed'
status.value = error.value
stopListen()
}
}
function listen(id: string) {
stopListen()
const gen = listenGen
settled = false
const stream = new EventSource(`/api/generate/${id}/stream`)
source = stream
stream.onmessage = (event) => {
if (gen !== listenGen) return
applyEvent(JSON.parse(event.data))
}
stream.onerror = () => {
if (gen !== listenGen) return
stream.close()
setTimeout(() => {
if (gen !== listenGen || settled) return
listen(id)
}, 2000)
}
}
async function generate() {
if (blocked.value) {
error.value = blockReason.value
return
}
error.value = ''
await ensureGenerationNotifyPermission()
busy.value = true
settled = false
progress.value = 2
audioUrl.value = ''
trackId.value = ''
status.value = 'Queueing ACE-Step…'
try {
const started = await $fetch<{ jobId: string; name?: string }>(
'/api/generate/music',
{
method: 'POST',
body: {
tags: tags.value,
lyrics: lyrics.value,
duration: duration.value,
steps: steps.value,
seed: seed.value || 'random',
instrumental: instrumental.value,
folderId: folderId.value,
name: name.value.trim()
}
}
)
jobId.value = started.jobId
if (started.name) downloadName.value = `${started.name}.flac`
listen(started.jobId)
} catch (err: any) {
busy.value = false
error.value = err?.data?.statusMessage || err?.statusMessage || err?.message || 'Could not queue music generation'
status.value = error.value
}
}
watch(instrumental, (on) => {
if (on && !lyrics.value.trim()) lyrics.value = DEFAULT_MUSIC_LYRICS
})
onMounted(async () => {
await loadLibrary()
const query = useRoute().query
if (typeof query.track === 'string' && query.track) {
audioUrl.value = `/api/library/tracks/${query.track}/audio`
trackId.value = query.track
status.value = 'Loaded from library'
}
})
onBeforeUnmount(() => {
stopListen()
})
</script>
+322
View File
@@ -0,0 +1,322 @@
# Download ACE-Step / YuE weights into the local ComfyUI models tree via Hugging Face CLI.
# Example:
# powershell -ExecutionPolicy Bypass -File scripts\download_music_models.ps1
# powershell -File scripts\download_music_models.ps1 -ComfyRoot C:\ComfyUI -SkipYue
[CmdletBinding()]
param(
[string]$ComfyRoot = '',
[switch]$SkipAceStep,
[switch]$SkipAceStep15,
[switch]$SkipYue,
[switch]$Force
)
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'Continue'
function Write-Step([string]$Message) {
Write-Host ""
Write-Host "==> $Message" -ForegroundColor Cyan
}
function Write-Ok([string]$Message) {
Write-Host " $Message" -ForegroundColor Green
}
function Write-Skip([string]$Message) {
Write-Host " skip $Message" -ForegroundColor DarkYellow
}
function Write-Warn([string]$Message) {
Write-Host " warn $Message" -ForegroundColor Yellow
}
function Resolve-ComfyRoot([string]$Hint) {
$candidates = @(
$Hint,
$env:COMFY_ROOT,
$env:COMFYUI_ROOT,
'C:\ComfyUI',
'C:\ComfyUI_windows_portable\ComfyUI',
'D:\ComfyUI',
'C:\ComfyUI-MiniMax\ComfyUI',
(Join-Path $env:USERPROFILE 'ComfyUI'),
(Join-Path $env:LOCALAPPDATA 'Comfy-Desktop\ComfyUI-Installs\ComfyUI (1)\ComfyUI'),
(Join-Path $env:LOCALAPPDATA 'Comfy-Desktop\ComfyUI-Installs\ComfyUI\ComfyUI')
) | Where-Object { $_ } | ForEach-Object { $_.TrimEnd('\', '/') }
foreach ($path in $candidates) {
if (Test-Path (Join-Path $path 'main.py')) { return (Resolve-Path $path).Path }
if (Test-Path (Join-Path $path 'ComfyUI\main.py')) { return (Resolve-Path (Join-Path $path 'ComfyUI')).Path }
}
Write-Host "Could not find ComfyUI (looked for main.py)." -ForegroundColor Red
$typed = Read-Host "Enter ComfyUI root path (or blank to abort)"
if (-not $typed) { throw "COMFY_ROOT is required." }
$typed = $typed.Trim().Trim('"')
if (-not (Test-Path (Join-Path $typed 'main.py'))) {
throw "Not a ComfyUI root (missing main.py): $typed"
}
return (Resolve-Path $typed).Path
}
function Get-DirBytes([string]$Path) {
if (-not (Test-Path $Path)) { return [long]0 }
$item = Get-Item $Path
if (-not $item.PSIsContainer) { return [long]$item.Length }
[long]((Get-ChildItem $Path -Recurse -File -ErrorAction SilentlyContinue | Measure-Object Length -Sum).Sum)
}
function Format-Bytes([long]$Bytes) {
if ($Bytes -ge 1GB) { return ('{0:N2} GB' -f ($Bytes / 1GB)) }
if ($Bytes -ge 1MB) { return ('{0:N1} MB' -f ($Bytes / 1MB)) }
if ($Bytes -ge 1KB) { return ('{0:N0} KB' -f ($Bytes / 1KB)) }
return "$Bytes B"
}
function Test-VerifiedTarget([string]$Path, [long]$MinBytes = 1MB) {
if (-not (Test-Path $Path)) { return $false }
$item = Get-Item $Path
if (-not $item.PSIsContainer) { return $item.Length -ge $MinBytes }
$files = Get-ChildItem $Path -Recurse -File -ErrorAction SilentlyContinue
if (-not $files) { return $false }
$total = [long]($files | Measure-Object Length -Sum).Sum
return $total -ge $MinBytes
}
function Ensure-Dir([string]$Path) {
if (-not (Test-Path $Path)) {
New-Item -ItemType Directory -Force -Path $Path | Out-Null
}
}
function Find-HfCli {
foreach ($name in @('hf', 'huggingface-cli')) {
$cmd = Get-Command $name -ErrorAction SilentlyContinue
if ($cmd) { return $cmd.Source }
}
return $null
}
function Ensure-HfCli {
$cli = Find-HfCli
if ($cli) {
Write-Ok "Hugging Face CLI: $cli"
return $cli
}
Write-Warn "Hugging Face CLI not found (hf / huggingface-cli)."
$answer = Read-Host "Install huggingface_hub now (Y/n)"
if ($answer -and $answer -notmatch '^(y|yes)$') {
throw "Hugging Face CLI is required. Install with: pip install -U huggingface_hub"
}
$pip = Get-Command pip -ErrorAction SilentlyContinue
$py = Get-Command python -ErrorAction SilentlyContinue
if ($pip) {
Write-Step "Installing huggingface_hub via pip"
& $pip.Source install -U huggingface_hub
} elseif ($py) {
Write-Step "Installing huggingface_hub via python -m pip"
& $py.Source -m pip install -U huggingface_hub
} else {
throw "pip/python not on PATH. Install Python, then: pip install -U huggingface_hub"
}
$cli = Find-HfCli
if (-not $cli) { throw "huggingface_hub installed but hf / huggingface-cli still not on PATH. Open a new terminal and retry." }
Write-Ok "Hugging Face CLI: $cli"
return $cli
}
function Invoke-HfDownload {
param(
[Parameter(Mandatory)] [string]$Cli,
[Parameter(Mandatory)] [string]$Repo,
[Parameter(Mandatory)] [string]$LocalDir,
[string[]]$Include,
[string]$File
)
Ensure-Dir $LocalDir
$exe = Split-Path $Cli -Leaf
$args = @()
if ($exe -eq 'huggingface-cli.exe' -or $exe -eq 'huggingface-cli') {
$args += @('download', $Repo)
} else {
$args += @('download', $Repo)
}
if ($File) { $args += $File }
$args += @('--local-dir', $LocalDir)
foreach ($pattern in $Include) {
$args += @('--include', $pattern)
}
Write-Host " hf $($args -join ' ')" -ForegroundColor DarkGray
& $Cli @args
if ($LASTEXITCODE -ne 0) {
throw "hf download failed for $Repo (exit $LASTEXITCODE)"
}
}
function Place-File([string]$Source, [string]$Dest) {
Ensure-Dir (Split-Path $Dest -Parent)
if ((Test-Path $Source) -and ((Resolve-Path $Source).Path -ne (Join-Path (Split-Path $Dest -Parent) (Split-Path $Dest -Leaf)))) {
Copy-Item -Force $Source $Dest
}
}
$root = Resolve-ComfyRoot $ComfyRoot
$cli = Ensure-HfCli
$checkpoints = Join-Path $root 'models\checkpoints'
$diffusion = Join-Path $root 'models\diffusion_models'
$textEncoders = Join-Path $root 'models\text_encoders'
$vae = Join-Path $root 'models\vae'
$ttsAce = Join-Path $root 'models\TTS\ACE-Step-v1-3.5B'
$customAce = Join-Path $root 'custom_nodes\ComfyUI-ACE-Step\models'
$yueRoot = Join-Path $root 'models\YuE'
$yueCustom = Join-Path $root 'custom_nodes\ComfyUI-YuE\models'
Write-Step "ComfyUI root"
Write-Host " $root"
Write-Host " checkpoints $checkpoints"
Write-Host " ACE-Step TTS $ttsAce"
Write-Host " ACE-Step custom $customAce"
Write-Host " YuE $yueRoot"
$before = Get-DirBytes (Join-Path $root 'models')
$downloaded = [System.Collections.Generic.List[string]]::new()
$skipped = [System.Collections.Generic.List[string]]::new()
function Pull-IfMissing {
param(
[string]$Label,
[string]$Target,
[long]$MinBytes,
[scriptblock]$Download
)
if (-not $Force -and (Test-VerifiedTarget $Target $MinBytes)) {
Write-Skip "$Label already present ($((Format-Bytes (Get-DirBytes $Target)))) — $Target"
$skipped.Add($Label) | Out-Null
return
}
Write-Step $Label
& $Download
if (-not (Test-VerifiedTarget $Target $MinBytes)) {
throw "Download finished but $Target is missing or too small."
}
Write-Ok "$Label ready ($((Format-Bytes (Get-DirBytes $Target))))"
$downloaded.Add($Label) | Out-Null
}
if (-not $SkipAceStep) {
$aio = Join-Path $checkpoints 'ace_step_v1_3.5b.safetensors'
Pull-IfMissing -Label 'ACE-Step v1 3.5B (native AIO checkpoint)' -Target $aio -MinBytes 1GB -Download {
Ensure-Dir $checkpoints
$tmp = Join-Path $env:TEMP 'aigen-ace-step-aio'
Ensure-Dir $tmp
Invoke-HfDownload -Cli $cli -Repo 'Comfy-Org/ACE-Step_ComfyUI_repackaged' -LocalDir $tmp -File 'all_in_one/ace_step_v1_3.5b.safetensors'
$found = Get-ChildItem $tmp -Recurse -Filter 'ace_step_v1_3.5b.safetensors' | Select-Object -First 1
if (-not $found) { throw 'AIO checkpoint not in Hugging Face download.' }
Place-File $found.FullName $aio
}
Pull-IfMissing -Label 'ACE-Step v1 3.5B (split transformer / vocoder / UMT5)' -Target $ttsAce -MinBytes 2GB -Download {
Invoke-HfDownload -Cli $cli -Repo 'ACE-Step/ACE-Step-v1-3.5B' -LocalDir $ttsAce -Include @('*.safetensors', '*.json')
}
if (Test-Path (Split-Path $customAce -Parent)) {
Pull-IfMissing -Label 'ACE-Step custom-node models copy' -Target $customAce -MinBytes 2GB -Download {
Ensure-Dir $customAce
Copy-Item -Recurse -Force (Join-Path $ttsAce '*') $customAce
}
} else {
Write-Skip "ComfyUI-ACE-Step custom node not installed — split weights stay in models\TTS"
}
}
if (-not $SkipAceStep15) {
$aio15 = Join-Path $checkpoints 'ace_step_1.5_turbo_aio.safetensors'
Pull-IfMissing -Label 'ACE-Step 1.5 Turbo AIO checkpoint' -Target $aio15 -MinBytes 500MB -Download {
Ensure-Dir $checkpoints
$tmp = Join-Path $env:TEMP 'aigen-ace-step-15-aio'
Ensure-Dir $tmp
Invoke-HfDownload -Cli $cli -Repo 'Comfy-Org/ace_step_1.5_ComfyUI_files' -LocalDir $tmp -Include @('*aio*.safetensors', '*turbo_aio*')
$found = Get-ChildItem $tmp -Recurse -Filter '*.safetensors' | Where-Object { $_.Name -match 'aio' } | Select-Object -First 1
if (-not $found) {
Invoke-HfDownload -Cli $cli -Repo 'Comfy-Org/ace_step_1.5_ComfyUI_files' -LocalDir $tmp
$found = Get-ChildItem $tmp -Recurse -Filter '*.safetensors' | Where-Object { $_.Name -match 'aio|1\.5' } | Sort-Object Length -Descending | Select-Object -First 1
}
if (-not $found) { throw 'ACE-Step 1.5 AIO safetensors not found in repo. Download split files instead, or update the repo layout.' }
Place-File $found.FullName $aio15
}
$diff15 = Join-Path $diffusion 'acestep_v1.5_turbo.safetensors'
$enc06 = Join-Path $textEncoders 'qwen_0.6b_ace15.safetensors'
$enc17 = Join-Path $textEncoders 'qwen_1.7b_ace15.safetensors'
$vae15 = Join-Path $vae 'ace_1.5_vae.safetensors'
$splitOk = (Test-VerifiedTarget $diff15 200MB) -and (Test-VerifiedTarget $enc06 50MB) -and (Test-VerifiedTarget $vae15 20MB)
if ($Force -or -not $splitOk) {
Write-Step 'ACE-Step 1.5 split files (diffusion / text encoder / VAE)'
Ensure-Dir $diffusion
Ensure-Dir $textEncoders
Ensure-Dir $vae
$tmp = Join-Path $env:TEMP 'aigen-ace-step-15-split'
Ensure-Dir $tmp
Invoke-HfDownload -Cli $cli -Repo 'Comfy-Org/ace_step_1.5_ComfyUI_files' -LocalDir $tmp -Include @('*.safetensors', '*.json')
foreach ($pair in @(
@{ Match = 'acestep_v1.5_turbo'; Dest = $diff15 },
@{ Match = 'qwen_0.6b_ace15'; Dest = $enc06 },
@{ Match = 'qwen_1.7b_ace15'; Dest = $enc17 },
@{ Match = 'ace_1.5_vae'; Dest = $vae15 }
)) {
$hit = Get-ChildItem $tmp -Recurse -Filter '*.safetensors' | Where-Object { $_.Name -like "*$($pair.Match)*" } | Select-Object -First 1
if ($hit) { Place-File $hit.FullName $pair.Dest; Write-Ok $hit.Name }
else { Write-Warn "Did not find $($pair.Match)*.safetensors in the 1.5 repo" }
}
$downloaded.Add('ACE-Step 1.5 split files') | Out-Null
} else {
Write-Skip "ACE-Step 1.5 split files already present"
$skipped.Add('ACE-Step 1.5 split files') | Out-Null
}
}
if (-not $SkipYue) {
$s1 = Join-Path $yueRoot 'YuE-s1-7B-anneal-en-cot'
$s2 = Join-Path $yueRoot 'YuE-s2-1B-general'
$codec = Join-Path $yueRoot 'xcodec_mini_infer'
Pull-IfMissing -Label 'YuE stage-1 (s1 7B anneal EN CoT)' -Target $s1 -MinBytes 1GB -Download {
Invoke-HfDownload -Cli $cli -Repo 'm-a-p/YuE-s1-7B-anneal-en-cot' -LocalDir $s1 -Include @('*.safetensors', '*.bin', '*.json', '*.model', '*.txt')
}
Pull-IfMissing -Label 'YuE stage-2 (s2 1B general)' -Target $s2 -MinBytes 200MB -Download {
Invoke-HfDownload -Cli $cli -Repo 'm-a-p/YuE-s2-1B-general' -LocalDir $s2 -Include @('*.safetensors', '*.bin', '*.json', '*.model', '*.txt')
}
Pull-IfMissing -Label 'YuE vocoder (xcodec_mini_infer)' -Target $codec -MinBytes 20MB -Download {
Invoke-HfDownload -Cli $cli -Repo 'm-a-p/xcodec_mini_infer' -LocalDir $codec -Include @('*.safetensors', '*.pth', '*.pt', '*.json', '*.yaml', '*.txt')
}
if (Test-Path (Split-Path $yueCustom -Parent)) {
Pull-IfMissing -Label 'YuE custom-node models copy' -Target $yueCustom -MinBytes 1GB -Download {
Ensure-Dir $yueCustom
Copy-Item -Recurse -Force (Join-Path $yueRoot '*') $yueCustom
}
} else {
Write-Skip "ComfyUI-YuE custom node not installed — YuE weights stay in models\YuE"
}
}
$after = Get-DirBytes (Join-Path $root 'models')
$delta = [Math]::Max(0, $after - $before)
Write-Host ""
Write-Host "------------------------------------------------------------" -ForegroundColor DarkGray
Write-Host "Downloaded: $($downloaded.Count)" -ForegroundColor Green
foreach ($item in $downloaded) { Write-Host " + $item" -ForegroundColor Green }
Write-Host "Skipped (already verified): $($skipped.Count)" -ForegroundColor DarkYellow
foreach ($item in $skipped) { Write-Host " = $item" -ForegroundColor DarkYellow }
Write-Host "Models tree size: $(Format-Bytes $after) (this run +$(Format-Bytes $delta))" -ForegroundColor Cyan
Write-Host "Restart ComfyUI if it was running so new checkpoints appear." -ForegroundColor DarkGray
Write-Host "------------------------------------------------------------" -ForegroundColor DarkGray
+6 -3
View File
@@ -32,11 +32,13 @@ export default defineEventHandler(async (event) => {
const live = listJobs() const live = listJobs()
.filter(job => job.library?.ownerKey === owner && jobLooksLive(job, comfyBusy)) .filter(job => job.library?.ownerKey === owner && jobLooksLive(job, comfyBusy))
.sort((a, b) => b.startedAt - a.startedAt) .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 editLive = live.find(job => job.kind === 'edit')
const musicLive = live.find(job => job.kind === 'music')
let video = videoLive ? livePayload(videoLive) : null let video = videoLive ? livePayload(videoLive) : null
const edit = editLive ? livePayload(editLive) : null const edit = editLive ? livePayload(editLive) : null
const music = musicLive ? livePayload(musicLive) : null
if (!video) { if (!video) {
const pending = listPendingJobs() const pending = listPendingJobs()
@@ -70,8 +72,9 @@ export default defineEventHandler(async (event) => {
} }
return { return {
jobId: video?.jobId || edit?.jobId || null, jobId: video?.jobId || edit?.jobId || music?.jobId || null,
video, 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 params = new URLSearchParams({ filename, subfolder, type })
const res = await comfyFetch(`/view?${params.toString()}`) const res = await comfyFetch(`/view?${params.toString()}`)
if (!res.ok) { 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()) 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}"`, 'Content-Disposition': `inline; filename="${filename}"`,
'Cache-Control': 'private, max-age=0, must-revalidate' 'Cache-Control': 'private, max-age=0, must-revalidate'
}) })
+120
View File
@@ -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"
}
}
}
+52
View File
@@ -243,6 +243,58 @@ function findVideo(value: unknown, depth = 0): { filename: string; subfolder: st
return null 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) { export function extractVideo(history: Record<string, unknown> | null, promptId: string) {
if (!history) return null if (!history) return null
const wrapped = history[promptId] as { outputs?: Record<string, unknown> } | undefined const wrapped = history[promptId] as { outputs?: Record<string, unknown> } | undefined
+17 -5
View File
@@ -12,6 +12,7 @@ export interface JobEvent {
mediaType?: string mediaType?: string
clipId?: string clipId?: string
stillId?: string stillId?: string
trackId?: string
hideThumbnail?: boolean hideThumbnail?: boolean
error?: string error?: string
elapsedMs?: number elapsedMs?: number
@@ -31,7 +32,7 @@ export interface JobEvent {
export interface Job { export interface Job {
id: string id: string
kind?: 'video' | 'edit' kind?: 'video' | 'edit' | 'music'
promptId?: string promptId?: string
clientId: string clientId: string
status: JobStatus status: JobStatus
@@ -41,8 +42,10 @@ export interface Job {
maxStep: number maxStep: number
startedAt: number startedAt: number
video?: { filename: string; subfolder: string; type: string } video?: { filename: string; subfolder: string; type: string }
audio?: { filename: string; subfolder: string; type: string }
clipId?: string clipId?: string
stillId?: string stillId?: string
trackId?: string
hideThumbnail?: boolean hideThumbnail?: boolean
imageComfyHost?: string imageComfyHost?: string
library?: { library?: {
@@ -102,6 +105,12 @@ export interface Job {
loraStack?: import('~/utils/loras').LoraStackItem[] loraStack?: import('~/utils/loras').LoraStackItem[]
shotLoras?: string[] shotLoras?: string[]
shotLoraStacks?: import('~/utils/loras').LoraStackItem[][] shotLoraStacks?: import('~/utils/loras').LoraStackItem[][]
tags?: string
lyrics?: string
instrumental?: boolean
audioExt?: string
engine?: string
lyricsStrength?: number
} }
error?: string error?: string
socketReady?: boolean socketReady?: boolean
@@ -115,9 +124,10 @@ export interface Job {
const jobs = new Map<string, Job>() const jobs = new Map<string, Job>()
const MAX_JOBS = 40 const MAX_JOBS = 40
export function createJob(): Job { export function createJob(kind: Job['kind'] = 'video'): Job {
const job: Job = { const job: Job = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
kind,
clientId: crypto.randomUUID(), clientId: crypto.randomUUID(),
status: 'queued', status: 'queued',
message: 'Queued', message: 'Queued',
@@ -175,6 +185,7 @@ export function listJobs() {
} }
export function jobSnapshot(job: Job) { export function jobSnapshot(job: Job) {
const media = job.kind === 'music' ? job.audio : job.video
return { return {
jobId: job.id, jobId: job.id,
kind: job.kind || 'video', kind: job.kind || 'video',
@@ -186,11 +197,12 @@ export function jobSnapshot(job: Job) {
maxStep: job.maxStep, maxStep: job.maxStep,
promptId: job.promptId, promptId: job.promptId,
elapsedMs: Date.now() - job.startedAt, elapsedMs: Date.now() - job.startedAt,
filename: job.library?.folderLocked ? undefined : job.video?.filename, filename: job.library?.folderLocked ? undefined : media?.filename,
subfolder: job.library?.folderLocked ? undefined : job.video?.subfolder, subfolder: job.library?.folderLocked ? undefined : media?.subfolder,
mediaType: job.library?.folderLocked ? undefined : job.video?.type, mediaType: job.library?.folderLocked ? undefined : media?.type,
clipId: job.clipId, clipId: job.clipId,
stillId: job.stillId, stillId: job.stillId,
trackId: job.trackId,
hideThumbnail: job.hideThumbnail, hideThumbnail: job.hideThumbnail,
error: job.error, error: job.error,
folderLocked: job.library?.folderLocked, folderLocked: job.library?.folderLocked,
+207 -4
View File
@@ -66,6 +66,27 @@ export interface LibraryClip {
promptPost?: string 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 type StillRole = 'input' | 'output'
export interface LibraryStill { export interface LibraryStill {
@@ -97,6 +118,7 @@ export interface PublicFolder {
unlocked: boolean unlocked: boolean
clipCount: number clipCount: number
imageCount: number imageCount: number
trackCount: number
} }
export interface QueuedExtension { export interface QueuedExtension {
@@ -146,6 +168,7 @@ interface Catalog {
folders: LibraryFolder[] folders: LibraryFolder[]
clips: LibraryClip[] clips: LibraryClip[]
stills: LibraryStill[] stills: LibraryStill[]
tracks: LibraryTrack[]
drafts: RetryDraft[] drafts: RetryDraft[]
loraAliases: Record<string, string> loraAliases: Record<string, string>
loraTriggers: Record<string, string> loraTriggers: Record<string, string>
@@ -188,6 +211,7 @@ function emptyCatalog(): Catalog {
}], }],
clips: [], clips: [],
stills: [], stills: [],
tracks: [],
drafts: [], drafts: [],
loraAliases: {}, loraAliases: {},
loraTriggers: {}, 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 { function normalizeCatalog(parsed: Partial<Catalog> & { passwordHash?: string | null }): Catalog {
const folders = Array.isArray(parsed.folders) && parsed.folders.length const folders = Array.isArray(parsed.folders) && parsed.folders.length
? parsed.folders.map(normalizeFolder) ? parsed.folders.map(normalizeFolder)
@@ -252,6 +300,7 @@ function normalizeCatalog(parsed: Partial<Catalog> & { passwordHash?: string | n
folders, folders,
clips: Array.isArray(parsed.clips) ? parsed.clips : [], clips: Array.isArray(parsed.clips) ? parsed.clips : [],
stills: Array.isArray(parsed.stills) ? parsed.stills.map(normalizeStill) : [], 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 : [], drafts: Array.isArray(parsed.drafts) ? parsed.drafts : [],
loraAliases: normalizeLoraAliases(parsed.loraAliases), loraAliases: normalizeLoraAliases(parsed.loraAliases),
loraTriggers: normalizeLoraTriggers(parsed.loraTriggers), loraTriggers: normalizeLoraTriggers(parsed.loraTriggers),
@@ -284,6 +333,7 @@ function migrateLegacy(owner: string) {
function ensureOwner(owner: string) { function ensureOwner(owner: string) {
mkdirSync(join(ownerRoot(owner), 'files'), { recursive: true }) mkdirSync(join(ownerRoot(owner), 'files'), { recursive: true })
mkdirSync(join(ownerRoot(owner), 'stills'), { recursive: true }) mkdirSync(join(ownerRoot(owner), 'stills'), { recursive: true })
mkdirSync(join(ownerRoot(owner), 'tracks'), { recursive: true })
migrateLegacy(owner) migrateLegacy(owner)
} }
@@ -392,7 +442,11 @@ function existingOutputNames(catalog: Catalog, folderId: string) {
.filter(item => item.folderId === folderId) .filter(item => item.folderId === folderId)
.map(item => String(item.name || '').trim()) .map(item => String(item.name || '').trim())
.filter(Boolean) .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) { export function getLoraAliases(owner: string) {
@@ -481,7 +535,7 @@ export function folderUnlocked(event: H3Event, folder: LibraryFolder) {
return folderUnlocks(event)[folder.id] === passwordVersion(folder.passwordHash) 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 { return {
id: folder.id, id: folder.id,
name: folder.name, name: folder.name,
@@ -490,7 +544,8 @@ export function publicFolder(event: H3Event, folder: LibraryFolder, clipCount =
protected: Boolean(folder.passwordHash), protected: Boolean(folder.passwordHash),
unlocked: folderUnlocked(event, folder), unlocked: folderUnlocked(event, folder),
clipCount, 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 clipIds = new Set(catalog.clips.map(clip => clip.id.toLowerCase()))
const clipCounts = new Map<string, number>() const clipCounts = new Map<string, number>()
const imageCounts = new Map<string, number>() const imageCounts = new Map<string, number>()
const trackCounts = new Map<string, number>()
for (const clip of catalog.clips) { for (const clip of catalog.clips) {
clipCounts.set(clip.folderId, (clipCounts.get(clip.folderId) || 0) + 1) 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 if (isPipelineStill(catalog, still, clipIds) || stillRole(still) !== 'output') continue
imageCounts.set(still.folderId, (imageCounts.get(still.folderId) || 0) + 1) 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( const folders = catalog.folders.map(folder => publicFolder(
event, event,
folder, folder,
clipCounts.get(folder.id) || 0, 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)) const unlockedIds = new Set(folders.filter(folder => folder.unlocked).map(folder => folder.id))
return { return {
@@ -526,6 +586,12 @@ export function publicLibrary(event: H3Event, opts?: { light?: boolean }) {
stills: catalog.stills stills: catalog.stills
.filter(still => unlockedIds.has(still.folderId) && !isPipelineStill(catalog, still, clipIds)) .filter(still => unlockedIds.has(still.folderId) && !isPipelineStill(catalog, still, clipIds))
.map(still => ({ ...still, role: stillRole(still) })), .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, drafts: catalog.drafts,
loraAliases: catalog.loraAliases || {}, loraAliases: catalog.loraAliases || {},
loraTriggers: catalog.loraTriggers || {}, loraTriggers: catalog.loraTriggers || {},
@@ -599,15 +665,20 @@ export function deleteFolder(owner: string, id: string) {
if (!folder) throw createError({ statusCode: 404, statusMessage: 'Folder not found' }) if (!folder) throw createError({ statusCode: 404, statusMessage: 'Folder not found' })
const clips = catalog.clips.filter(clip => clip.folderId === id) const clips = catalog.clips.filter(clip => clip.folderId === id)
const stills = catalog.stills.filter(still => still.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.folders = catalog.folders.filter(item => item.id !== id)
catalog.clips = catalog.clips.filter(clip => clip.folderId !== id) catalog.clips = catalog.clips.filter(clip => clip.folderId !== id)
catalog.stills = catalog.stills.filter(still => still.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) { for (const clip of clips) {
rmSync(clipDir(owner, clip.id), { recursive: true, force: true }) rmSync(clipDir(owner, clip.id), { recursive: true, force: true })
} }
for (const still of stills) { for (const still of stills) {
removeStillFiles(owner, still.id) removeStillFiles(owner, still.id)
} }
for (const track of tracks) {
rmSync(trackDir(owner, track.id), { recursive: true, force: true })
}
return { fallbackFolderId: catalog.folders[0].id } return { fallbackFolderId: catalog.folders[0].id }
}) })
} }
@@ -695,6 +766,25 @@ export function clipVideoPath(owner: string, id: string) {
return join(clipDir(owner, id), 'video.mp4') 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) { export function clipLastFramePath(owner: string, id: string) {
return join(clipDir(owner, id), 'last-frame.png') return join(clipDir(owner, id), 'last-frame.png')
} }
@@ -1653,6 +1743,103 @@ export function getClip(owner: string, id: string) {
return clip 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) { function stampFamilyId(family: LibraryClip[], fallback?: string) {
const familyId = family.find(item => item.familyId)?.familyId || fallback || crypto.randomUUID() const familyId = family.find(item => item.familyId)?.familyId || fallback || crypto.randomUUID()
for (const item of family) item.familyId = familyId 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})`) 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) { export async function importMissingComfyVideos(owner: string, folderId?: string) {
const catalog = readCatalog(owner) const catalog = readCatalog(owner)
const folder = catalog.folders.find(item => item.id === folderId) || catalog.folders[0] const folder = catalog.folders.find(item => item.id === folderId) || catalog.folders[0]
+352
View File
@@ -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
}
+68
View File
@@ -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
View File
@@ -2,6 +2,7 @@
import workflowV1 from '../assets/workflow_minimax_video.json' import workflowV1 from '../assets/workflow_minimax_video.json'
import workflowV2 from '../assets/workflow_minimax_video_v2.json' import workflowV2 from '../assets/workflow_minimax_video_v2.json'
import workflowLtx from '../assets/workflow_ltx_video.json' import workflowLtx from '../assets/workflow_ltx_video.json'
import { MUSIC_NODE_LABELS } from '~/server/utils/musicWorkflow'
import { buildIdentityPrompt } from '~/utils/identityPrompt' import { buildIdentityPrompt } from '~/utils/identityPrompt'
import { applyUserLoraToGraph, resolveGraphLoraNames } from '~/server/utils/loras' import { applyUserLoraToGraph, resolveGraphLoraNames } from '~/server/utils/loras'
import type { LoraStackItem } from '~/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(workflowV1 as WorkflowGraph)),
...Object.fromEntries(labelsFrom(workflowV2 as WorkflowGraph)), ...Object.fromEntries(labelsFrom(workflowV2 as WorkflowGraph)),
...Object.fromEntries(labelsFrom(workflowLtx as WorkflowGraph)), ...Object.fromEntries(labelsFrom(workflowLtx as WorkflowGraph)),
...MUSIC_NODE_LABELS,
...LABEL_OVERRIDES ...LABEL_OVERRIDES
} }
+6 -2
View File
@@ -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 (!generationNotifySupported() || !generationNotifyPref()) return
if (Notification.permission !== 'granted') return if (Notification.permission !== 'granted') return
if (typeof document !== 'undefined' && !document.hidden && document.hasFocus()) return if (typeof document !== 'undefined' && !document.hidden && document.hasFocus()) return
const title = appName() 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 = { const options: NotificationOptions = {
body, body,
icon: appIcon(), icon: appIcon(),
+50 -9
View File
@@ -1,7 +1,7 @@
import type { StillGenerationSettings } from '~/utils/libraryGeneration' import type { StillGenerationSettings } from '~/utils/libraryGeneration'
import type { LoraStackItem } from '~/utils/loras' 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 LibrarySort = 'date-desc' | 'date-asc' | 'name-asc' | 'name-desc' | 'type'
export type LibraryFolder = { export type LibraryFolder = {
@@ -13,6 +13,7 @@ export type LibraryFolder = {
unlocked: boolean unlocked: boolean
clipCount?: number clipCount?: number
imageCount?: number imageCount?: number
trackCount?: number
} }
export type LibraryStill = { export type LibraryStill = {
@@ -66,15 +67,37 @@ export type LibraryClip = {
loraStack?: LoraStackItem[] 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 ClipGroup = { key: string; head: LibraryClip; parts: LibraryClip[] }
export type StillGroup = { key: string; head: LibraryStill; parts: LibraryStill[] } export type StillGroup = { key: string; head: LibraryStill; parts: LibraryStill[] }
export type LibraryEntry = { export type LibraryEntry = {
kind: 'clip' | 'still' kind: 'clip' | 'still' | 'track'
key: string key: string
createdAt: number createdAt: number
name: string name: string
group?: ClipGroup group?: ClipGroup
stillGroup?: StillGroup stillGroup?: StillGroup
track?: LibraryTrack
} }
export function familyBaseName(name: string) { export function familyBaseName(name: string) {
@@ -248,6 +271,7 @@ export function stillCollectionTitle(group: StillGroup) {
export function entryTitle(entry: LibraryEntry) { export function entryTitle(entry: LibraryEntry) {
if (entry.kind === 'clip' && entry.group) return collectionTitle(entry.group) 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) if (entry.stillGroup) return stillCollectionTitle(entry.stillGroup)
return entry.name return entry.name
} }
@@ -256,6 +280,9 @@ export function entrySearchText(entry: LibraryEntry) {
if (entry.kind === 'clip' && entry.group) { if (entry.kind === 'clip' && entry.group) {
return entry.group.parts.map(part => `${part.name || ''} ${part.prompt || ''}`).join(' ') 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) { if (entry.stillGroup) {
return entry.stillGroup.parts.map(part => `${part.name || ''} ${part.prompt || ''} ${part.filename || ''}`).join(' ') 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: { export function buildLibraryEntries(opts: {
clips: LibraryClip[] clips: LibraryClip[]
stills: LibraryStill[] stills: LibraryStill[]
tracks?: LibraryTrack[]
folderId: string folderId: string
filter: LibraryFilter filter: LibraryFilter
search: string search: string
@@ -305,7 +333,8 @@ export function buildLibraryEntries(opts: {
const inFolder = (folderId: string) => !opts.folderId || opts.folderId === 'all' || folderId === opts.folderId 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 clips = opts.clips.filter(clip => inFolder(clip.folderId) && clip.hasVideo !== false)
const stills = opts.stills.filter(still => inFolder(still.folderId) && still.role === 'output') 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 => ({ : groupClips(clips).map(group => ({
kind: 'clip' as const, kind: 'clip' as const,
@@ -314,7 +343,7 @@ export function buildLibraryEntries(opts: {
name: collectionTitle(group), name: collectionTitle(group),
group group
})) }))
const images: LibraryEntry[] = opts.filter === 'videos' const images: LibraryEntry[] = opts.filter === 'videos' || opts.filter === 'music'
? [] ? []
: groupStills(stills).map(group => ({ : groupStills(stills).map(group => ({
kind: 'still' as const, kind: 'still' as const,
@@ -323,7 +352,16 @@ export function buildLibraryEntries(opts: {
name: stillCollectionTitle(group), name: stillCollectionTitle(group),
stillGroup: 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() const needle = opts.search.trim().toLowerCase()
if (needle) { if (needle) {
entries = entries.filter(entry => entrySearchText(entry).toLowerCase().includes(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 === 'name-desc') return compareName(b.name, a.name) || b.createdAt - a.createdAt
if (sort === 'date-asc') return a.createdAt - b.createdAt if (sort === 'date-asc') return a.createdAt - b.createdAt
if (sort === 'type') { 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
} }
return b.createdAt - a.createdAt return b.createdAt - a.createdAt
@@ -342,13 +381,15 @@ export function buildLibraryEntries(opts: {
return entries return entries
} }
export function folderOutputCount(folder: LibraryFolder, clips: LibraryClip[], stills: LibraryStill[], filter: LibraryFilter) { export function folderOutputCount(folder: LibraryFolder, clips: LibraryClip[], stills: LibraryStill[], filter: LibraryFilter, tracks: LibraryTrack[] = []) {
if (!folder.unlocked) return (folder.clipCount || 0) + (folder.imageCount || 0) if (!folder.unlocked) return (folder.clipCount || 0) + (folder.imageCount || 0) + (folder.trackCount || 0)
const videos = clips.filter(clip => clip.folderId === folder.id).length const videos = clips.filter(clip => clip.folderId === folder.id).length
const images = stills.filter(still => still.folderId === folder.id && still.role === 'output').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 === 'videos') return videos
if (filter === 'images') return images if (filter === 'images') return images
return videos + images if (filter === 'music') return music
return videos + images + music
} }
export function formatWhen(createdAt: number) { export function formatWhen(createdAt: number) {
+31
View File
@@ -280,3 +280,34 @@ export function clipSettingsRows(clip: {
if (loras) rows.push({ label: 'LoRA', value: loras }) if (loras) rows.push({ label: 'LoRA', value: loras })
return rows 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
}
+95
View File
@@ -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'
}
+3
View File
@@ -77,6 +77,9 @@ export function engineFilenameKey(engine: string) {
if (value === 'klein') return 'klein' if (value === 'klein') return 'klein'
if (value === 'ltx') return 'ltx' if (value === 'ltx') return 'ltx'
if (value === 'minimax') return 'minimax' 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' return sanitizeFilenameKey(value, 16) || 'gen'
} }