Add ACE-Step music generation to the studio and library.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -55,6 +55,13 @@
|
||||
role="menuitem"
|
||||
@click="menuOpen = false"
|
||||
>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
|
||||
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"
|
||||
@@ -116,7 +123,7 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{
|
||||
subtitle: string
|
||||
current?: 'studio' | 'library' | 'director' | 'queue' | 'settings'
|
||||
current?: 'studio' | 'library' | 'director' | 'queue' | 'settings' | 'music'
|
||||
wide?: boolean
|
||||
queueCount?: number
|
||||
}>(), {
|
||||
|
||||
@@ -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>
|
||||
@@ -58,6 +58,7 @@ export default defineNuxtConfig({
|
||||
backupIngestToken: process.env.BACKUP_INGEST_TOKEN || 'carr-aigen-xaigen-backup',
|
||||
purgeComfyOutputs: process.env.PURGE_COMFY_OUTPUTS !== 'false',
|
||||
comfyFilenamePrefix: process.env.COMFY_FILENAME_PREFIX || 'video/MiniMax_H3',
|
||||
comfyAudioPrefix: process.env.COMFY_AUDIO_PREFIX || 'audio/AIGen',
|
||||
kreaConceptLora: process.env.NUXT_KREA_CONCEPT_LORA || '',
|
||||
public: {
|
||||
authEnabled: process.env.NUXT_PUBLIC_AUTH_ENABLED === 'true',
|
||||
|
||||
+12
-1
@@ -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">
|
||||
<section id="studio-input" class="glass min-w-0 max-w-full space-y-5 rounded-3xl p-5 sm:p-6">
|
||||
<div>
|
||||
<div class="mb-4 grid grid-cols-2 gap-3">
|
||||
<div class="mb-4 grid grid-cols-3 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
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-0.5 block text-xs text-zinc-500">Flux · Krea</span>
|
||||
</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>
|
||||
<h2 class="font-display text-2xl font-bold">Input</h2>
|
||||
<p class="text-sm text-zinc-400">{{ studioMode === 'editv2'
|
||||
|
||||
+85
-17
@@ -161,6 +161,18 @@
|
||||
>
|
||||
<div class="relative aspect-[4/5] bg-zinc-900">
|
||||
<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
|
||||
v-else-if="thumbSrc(entry)"
|
||||
:src="thumbSrc(entry)"
|
||||
@@ -171,7 +183,7 @@
|
||||
class="h-full w-full object-cover"
|
||||
>
|
||||
<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
|
||||
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"
|
||||
@@ -235,6 +247,9 @@
|
||||
controls
|
||||
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
|
||||
v-else-if="focused.kind === 'still' && previewStillSrc"
|
||||
type="button"
|
||||
@@ -263,6 +278,7 @@
|
||||
>
|
||||
</button>
|
||||
<FamilyNav
|
||||
v-if="focused.kind !== 'track'"
|
||||
:count="focusedNavParts.length"
|
||||
:index="focusedNavIndex"
|
||||
:prev-label="focused?.kind === 'clip' ? 'Previous clip' : 'Previous image'"
|
||||
@@ -270,7 +286,7 @@
|
||||
@step="stepFocusedFamily"
|
||||
/>
|
||||
<button
|
||||
v-if="showPrivacyToggles"
|
||||
v-if="showPrivacyToggles && focused.kind !== 'track'"
|
||||
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"
|
||||
:title="detailsHidden ? 'Show preview' : 'Hide preview'"
|
||||
@@ -300,8 +316,8 @@
|
||||
</button>
|
||||
</div>
|
||||
<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-white/10 px-3 py-1.5 text-xs text-zinc-300 hover:text-white" @click="useInStudio(focused)">Use as input</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 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
|
||||
v-if="focused.kind === 'still' && focusedStillRole !== 'input'"
|
||||
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"
|
||||
>Download</a>
|
||||
<button
|
||||
v-if="!detailsHidden && (previewVideo || previewStillSrc || thumbSrc(focused))"
|
||||
v-if="!detailsHidden && focused.kind !== 'track' && (previewVideo || previewStillSrc || thumbSrc(focused))"
|
||||
type="button"
|
||||
class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 hover:text-white"
|
||||
@click="lightboxOpen = true"
|
||||
@@ -364,7 +380,7 @@
|
||||
|
||||
<Teleport to="body">
|
||||
<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"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@@ -446,10 +462,11 @@ import {
|
||||
type LibraryFilter,
|
||||
type LibraryFolder,
|
||||
type LibrarySort,
|
||||
type LibraryStill
|
||||
type LibraryStill,
|
||||
type LibraryTrack
|
||||
} from '~/utils/libraryBrowse'
|
||||
import { isXaigenStudio } from '~/utils/videoModels'
|
||||
import { clipSettingsRows, stillSettingsRows } from '~/utils/libraryGeneration'
|
||||
import { clipSettingsRows, stillSettingsRows, trackSettingsRows } from '~/utils/libraryGeneration'
|
||||
|
||||
const CARD_MIN = 156
|
||||
const GAP = 10
|
||||
@@ -459,7 +476,8 @@ const DRAG_MIME = 'application/x-aigen-library'
|
||||
const filterOptions = [
|
||||
{ id: 'all' as const, label: 'All' },
|
||||
{ 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()
|
||||
@@ -473,6 +491,7 @@ const showPrivacyToggles = computed(() => isXaigenStudio({
|
||||
const folders = ref<LibraryFolder[]>([])
|
||||
const clips = ref<LibraryClip[]>([])
|
||||
const stills = ref<LibraryStill[]>([])
|
||||
const tracks = ref<LibraryTrack[]>([])
|
||||
const folderId = ref('all')
|
||||
const filter = ref<LibraryFilter>('all')
|
||||
const sort = ref<LibrarySort>('date-desc')
|
||||
@@ -511,6 +530,7 @@ const browseFolder = computed(() => folders.value.find(folder => folder.id === f
|
||||
const entries = computed(() => buildLibraryEntries({
|
||||
clips: clips.value,
|
||||
stills: stills.value,
|
||||
tracks: tracks.value,
|
||||
folderId: folderId.value,
|
||||
filter: filter.value,
|
||||
search: search.value,
|
||||
@@ -521,6 +541,7 @@ const allCount = computed(() => {
|
||||
return buildLibraryEntries({
|
||||
clips: clips.value,
|
||||
stills: stills.value,
|
||||
tracks: tracks.value,
|
||||
folderId: 'all',
|
||||
filter: filter.value,
|
||||
search: '',
|
||||
@@ -528,17 +549,21 @@ const allCount = computed(() => {
|
||||
}).length
|
||||
})
|
||||
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) {
|
||||
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
|
||||
}
|
||||
for (const still of stills.value) {
|
||||
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
|
||||
}
|
||||
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
|
||||
})
|
||||
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 (filter.value === 'images') return 'No generated stills 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.'
|
||||
})
|
||||
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
|
||||
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(() => {
|
||||
if (focused.value?.kind === 'clip') return focused.value.group?.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
|
||||
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
|
||||
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
|
||||
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
|
||||
if (!still) return ''
|
||||
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
|
||||
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) {
|
||||
const id = focusedPartId.value || focused.value.stillGroup.head.id
|
||||
return `/api/library/stills/${id}`
|
||||
@@ -648,8 +687,9 @@ function applyLibrary(data: unknown) {
|
||||
folders?: LibraryFolder[]
|
||||
clips?: LibraryClip[]
|
||||
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') {
|
||||
const still = stills.value.find(item => item.id === payload.id)
|
||||
if (still) still.hideInput = payload.hideInput
|
||||
@@ -663,6 +703,7 @@ function applyLibrary(data: unknown) {
|
||||
folders.value = payload.folders || []
|
||||
clips.value = payload.clips || []
|
||||
stills.value = payload.stills || []
|
||||
tracks.value = payload.tracks || []
|
||||
if (folderId.value !== 'all' && !folders.value.some(folder => folder.id === folderId.value)) {
|
||||
folderId.value = folders.value[0]?.id || 'all'
|
||||
}
|
||||
@@ -673,12 +714,13 @@ async function loadLibrary() {
|
||||
}
|
||||
|
||||
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]
|
||||
if (!row) return 0
|
||||
if (filter.value === 'videos') return row.videos
|
||||
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) {
|
||||
@@ -696,6 +738,7 @@ function cardClass(entry: LibraryEntry) {
|
||||
|
||||
function itemHidden(entry: LibraryEntry) {
|
||||
if (entry.kind === 'clip') return Boolean(entry.group?.head.hideThumbnail)
|
||||
if (entry.kind === 'track') return false
|
||||
return Boolean(entry.stillGroup?.head.hideInput)
|
||||
}
|
||||
|
||||
@@ -751,11 +794,13 @@ async function toggleFocusedHidden() {
|
||||
|
||||
function thumbSrc(entry: LibraryEntry) {
|
||||
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`
|
||||
return ''
|
||||
}
|
||||
|
||||
function partCount(entry: LibraryEntry) {
|
||||
if (entry.kind === 'track') return 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)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -847,7 +895,11 @@ function onCardClick(entry: LibraryEntry, event: MouseEvent) {
|
||||
}
|
||||
const sameCard = 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
|
||||
cancelRename()
|
||||
}
|
||||
@@ -886,7 +938,11 @@ function selectedEntries(keys = selectedKeys.value) {
|
||||
|
||||
async function moveKeys(keys: string[], destId: string) {
|
||||
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
|
||||
})
|
||||
if (!items.length) return
|
||||
@@ -895,6 +951,9 @@ async function moveKeys(keys: string[], destId: string) {
|
||||
if (entry.kind === 'clip') {
|
||||
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 } })
|
||||
}))
|
||||
await loadLibrary()
|
||||
@@ -923,6 +982,10 @@ function openInStudio(entry: LibraryEntry) {
|
||||
navigateTo({ path: '/', query: { clip: focusedPartId.value || entry.group.head.id } })
|
||||
return
|
||||
}
|
||||
if (entry.kind === 'track' && entry.track) {
|
||||
navigateTo({ path: '/music', query: { track: entry.track.id } })
|
||||
return
|
||||
}
|
||||
if (entry.stillGroup) {
|
||||
navigateTo({ path: '/', query: { still: focusedPartId.value || entry.stillGroup.head.id } })
|
||||
}
|
||||
@@ -996,6 +1059,8 @@ async function saveRename() {
|
||||
try {
|
||||
if (entry.kind === 'clip' && entry.group) {
|
||||
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) {
|
||||
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') {
|
||||
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' } })
|
||||
}))
|
||||
if (pending.keys.includes(focusedKey.value)) {
|
||||
|
||||
+344
@@ -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 & 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>
|
||||
@@ -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
|
||||
@@ -32,11 +32,13 @@ export default defineEventHandler(async (event) => {
|
||||
const live = listJobs()
|
||||
.filter(job => job.library?.ownerKey === owner && jobLooksLive(job, comfyBusy))
|
||||
.sort((a, b) => b.startedAt - a.startedAt)
|
||||
const videoLive = live.find(job => job.kind !== 'edit')
|
||||
const videoLive = live.find(job => job.kind !== 'edit' && job.kind !== 'music')
|
||||
const editLive = live.find(job => job.kind === 'edit')
|
||||
const musicLive = live.find(job => job.kind === 'music')
|
||||
|
||||
let video = videoLive ? livePayload(videoLive) : null
|
||||
const edit = editLive ? livePayload(editLive) : null
|
||||
const music = musicLive ? livePayload(musicLive) : null
|
||||
|
||||
if (!video) {
|
||||
const pending = listPendingJobs()
|
||||
@@ -70,8 +72,9 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
return {
|
||||
jobId: video?.jobId || edit?.jobId || null,
|
||||
jobId: video?.jobId || edit?.jobId || music?.jobId || null,
|
||||
video,
|
||||
edit
|
||||
edit,
|
||||
music
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { startMusicJob } from '~/server/utils/musicChain'
|
||||
import { comfyConfigured } from '~/server/utils/comfy'
|
||||
import {
|
||||
clampLyricsStrength,
|
||||
clampMusicCfg,
|
||||
clampMusicDuration,
|
||||
clampMusicSteps,
|
||||
parseMusicEngine,
|
||||
parseMusicSeed,
|
||||
sanitizeMusicLyrics,
|
||||
sanitizeMusicTags
|
||||
} from '~/utils/music'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<{
|
||||
tags?: unknown
|
||||
lyrics?: unknown
|
||||
duration?: unknown
|
||||
steps?: unknown
|
||||
seed?: unknown
|
||||
cfg?: unknown
|
||||
lyricsStrength?: unknown
|
||||
instrumental?: unknown
|
||||
folderId?: unknown
|
||||
name?: unknown
|
||||
engine?: unknown
|
||||
sampler_name?: unknown
|
||||
scheduler?: unknown
|
||||
}>(event).catch(() => ({} as Record<string, unknown>))
|
||||
|
||||
if (!comfyConfigured()) {
|
||||
throw createError({
|
||||
statusCode: 503,
|
||||
statusMessage: 'Beast ComfyUI is not configured. Set COMFY_HOST.'
|
||||
})
|
||||
}
|
||||
|
||||
const instrumental = body.instrumental === true || body.instrumental === 'true'
|
||||
const tags = sanitizeMusicTags(body.tags)
|
||||
const lyrics = sanitizeMusicLyrics(body.lyrics, instrumental)
|
||||
if (!tags) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Genre and style tags are required' })
|
||||
}
|
||||
if (!instrumental && !lyrics) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Lyrics are required unless Instrumental is on' })
|
||||
}
|
||||
|
||||
const ownerKey = libraryOwnerKey(event)
|
||||
const library = publicLibrary(event)
|
||||
const folderId = library.folders.some(folder => folder.id === String(body.folderId || ''))
|
||||
? String(body.folderId)
|
||||
: library.folders[0]?.id
|
||||
if (!folderId) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Create a library folder before generating' })
|
||||
}
|
||||
assertFolderExists(event, folderId)
|
||||
const destFolder = library.folders.find(folder => folder.id === folderId)
|
||||
const folderLocked = Boolean(destFolder?.protected && !destFolder.unlocked)
|
||||
|
||||
const duration = clampMusicDuration(body.duration)
|
||||
const steps = clampMusicSteps(body.steps)
|
||||
const cfg = clampMusicCfg(body.cfg)
|
||||
const lyricsStrength = clampLyricsStrength(body.lyricsStrength)
|
||||
const seed = parseMusicSeed(body.seed)
|
||||
const engine = parseMusicEngine(body.engine)
|
||||
const clipName = allocateOutputName(ownerKey, folderId, String(body.name || '').trim(), {
|
||||
engine: engine === 'ace-step-1.5' ? 'ace15' : 'ace'
|
||||
})
|
||||
|
||||
const job = await startMusicJob({
|
||||
ownerKey,
|
||||
folderId,
|
||||
name: clipName,
|
||||
tags,
|
||||
lyrics,
|
||||
duration,
|
||||
steps,
|
||||
seed,
|
||||
cfg,
|
||||
lyricsStrength,
|
||||
instrumental,
|
||||
folderLocked,
|
||||
engine,
|
||||
samplerName: String(body.sampler_name || 'euler'),
|
||||
scheduler: String(body.scheduler || 'simple')
|
||||
})
|
||||
|
||||
return {
|
||||
jobId: job.id,
|
||||
queued: job.status === 'queued',
|
||||
seed,
|
||||
duration,
|
||||
steps,
|
||||
cfg,
|
||||
folderId,
|
||||
folderLocked,
|
||||
engine,
|
||||
name: clipName
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { owner } = assertLibraryOwner(event)
|
||||
const id = String(getRouterParam(event, 'id') || '')
|
||||
const track = getTrack(owner, id)
|
||||
assertFolderAccess(event, track.folderId)
|
||||
await deleteTrack(owner, id)
|
||||
return { ok: true }
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { owner } = assertLibraryOwner(event)
|
||||
const id = String(getRouterParam(event, 'id') || '')
|
||||
const body = await readBody<{ folderId?: string; name?: string }>(event).catch(() => ({} as { folderId?: string; name?: string }))
|
||||
const folderId = String(body?.folderId || '')
|
||||
const hasName = typeof body?.name === 'string'
|
||||
if (!folderId && !hasName) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Choose a library folder or a new name' })
|
||||
}
|
||||
const track = getTrack(owner, id)
|
||||
assertFolderAccess(event, track.folderId)
|
||||
if (folderId) {
|
||||
assertFolderExists(event, folderId)
|
||||
await moveTrack(owner, id, folderId)
|
||||
}
|
||||
if (hasName) await renameTrack(owner, id, body.name || '')
|
||||
return publicLibrary(event)
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { sendPathWithRange } from '~/server/utils/httpRange'
|
||||
import { musicContentType } from '~/utils/music'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const { owner } = assertLibraryOwner(event)
|
||||
const id = String(getRouterParam(event, 'id') || '')
|
||||
const track = getTrack(owner, id)
|
||||
assertFolderAccess(event, track.folderId)
|
||||
const path = trackAudioPath(owner, track.id, track.ext)
|
||||
if (!existsSync(path)) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Audio file is missing' })
|
||||
}
|
||||
const ext = track.ext || 'flac'
|
||||
return sendPathWithRange(event, path, musicContentType(ext), {
|
||||
'Content-Disposition': `inline; filename="${safeDownloadName(track.name)}.${ext}"`,
|
||||
'Cache-Control': 'private, max-age=0, must-revalidate'
|
||||
})
|
||||
})
|
||||
+12
-2
@@ -11,10 +11,20 @@ export default defineEventHandler(async (event) => {
|
||||
const params = new URLSearchParams({ filename, subfolder, type })
|
||||
const res = await comfyFetch(`/view?${params.toString()}`)
|
||||
if (!res.ok) {
|
||||
throw createError({ statusCode: res.status, statusMessage: 'Failed to fetch video from ComfyUI' })
|
||||
throw createError({ statusCode: res.status, statusMessage: 'Failed to fetch media from ComfyUI' })
|
||||
}
|
||||
const buf = Buffer.from(await res.arrayBuffer())
|
||||
return sendBufferWithRange(event, buf, res.headers.get('content-type') || 'video/mp4', {
|
||||
const lower = filename.toLowerCase()
|
||||
const fallbackType = lower.endsWith('.wav')
|
||||
? 'audio/wav'
|
||||
: lower.endsWith('.flac')
|
||||
? 'audio/flac'
|
||||
: lower.endsWith('.mp3')
|
||||
? 'audio/mpeg'
|
||||
: lower.endsWith('.ogg') || lower.endsWith('.opus')
|
||||
? 'audio/ogg'
|
||||
: 'video/mp4'
|
||||
return sendBufferWithRange(event, buf, res.headers.get('content-type') || fallbackType, {
|
||||
'Content-Disposition': `inline; filename="${filename}"`,
|
||||
'Cache-Control': 'private, max-age=0, must-revalidate'
|
||||
})
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
{
|
||||
"14": {
|
||||
"inputs": {
|
||||
"ckpt_name": "ace_step_v1_3.5b.safetensors"
|
||||
},
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"_meta": {
|
||||
"title": "Load Checkpoint"
|
||||
}
|
||||
},
|
||||
"40": {
|
||||
"inputs": {
|
||||
"tags": "modern synthpop, 120 bpm, female vocals, punchy bass, analog synths",
|
||||
"lyrics": "[Verse 1]\n\n[Chorus]\n\n[Guitar Solo]\n\n[Outro]\n",
|
||||
"lyrics_strength": 0.9,
|
||||
"clip": [
|
||||
"14",
|
||||
1
|
||||
]
|
||||
},
|
||||
"class_type": "TextEncodeAceStepAudio",
|
||||
"_meta": {
|
||||
"title": "Tags & Lyrics"
|
||||
}
|
||||
},
|
||||
"47": {
|
||||
"inputs": {
|
||||
"conditioning": [
|
||||
"40",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "ConditioningZeroOut",
|
||||
"_meta": {
|
||||
"title": "Negative (zeroed)"
|
||||
}
|
||||
},
|
||||
"17": {
|
||||
"inputs": {
|
||||
"seconds": 60,
|
||||
"batch_size": 1
|
||||
},
|
||||
"class_type": "EmptyAceStepLatentAudio",
|
||||
"_meta": {
|
||||
"title": "Audio Length"
|
||||
}
|
||||
},
|
||||
"45": {
|
||||
"inputs": {
|
||||
"shift": 5,
|
||||
"model": [
|
||||
"14",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "ModelSamplingSD3",
|
||||
"_meta": {
|
||||
"title": "Model Sampling SD3"
|
||||
}
|
||||
},
|
||||
"3": {
|
||||
"inputs": {
|
||||
"seed": 0,
|
||||
"steps": 50,
|
||||
"cfg": 5,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "simple",
|
||||
"denoise": 1,
|
||||
"model": [
|
||||
"45",
|
||||
0
|
||||
],
|
||||
"positive": [
|
||||
"40",
|
||||
0
|
||||
],
|
||||
"negative": [
|
||||
"47",
|
||||
0
|
||||
],
|
||||
"latent_image": [
|
||||
"17",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "KSampler",
|
||||
"_meta": {
|
||||
"title": "Audio Sampler"
|
||||
}
|
||||
},
|
||||
"16": {
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"3",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"14",
|
||||
2
|
||||
]
|
||||
},
|
||||
"class_type": "VAEDecodeAudio",
|
||||
"_meta": {
|
||||
"title": "VAE Decode Audio"
|
||||
}
|
||||
},
|
||||
"19": {
|
||||
"inputs": {
|
||||
"filename_prefix": "audio/AIGen",
|
||||
"audio": [
|
||||
"16",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "SaveAudio",
|
||||
"_meta": {
|
||||
"title": "Save Audio"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -243,6 +243,58 @@ function findVideo(value: unknown, depth = 0): { filename: string; subfolder: st
|
||||
return null
|
||||
}
|
||||
|
||||
function isAudioFile(item: { filename?: string; format?: string } | null | undefined) {
|
||||
if (!item) return false
|
||||
const name = String(item.filename || '').toLowerCase()
|
||||
const format = String(item.format || '').toLowerCase()
|
||||
return name.endsWith('.wav') || name.endsWith('.flac') || name.endsWith('.mp3') || name.endsWith('.ogg') || name.endsWith('.opus')
|
||||
|| format.includes('audio') || format.includes('wav') || format.includes('flac')
|
||||
}
|
||||
|
||||
function normalizeAudio(item: { filename?: string; subfolder?: string; type?: string }) {
|
||||
return {
|
||||
filename: String(item.filename),
|
||||
subfolder: String(item.subfolder || ''),
|
||||
type: String(item.type || 'output')
|
||||
}
|
||||
}
|
||||
|
||||
function findAudio(value: unknown, depth = 0): { filename: string; subfolder: string; type: string } | null {
|
||||
if (!value || typeof value !== 'object' || depth > 8) return null
|
||||
if (Array.isArray(value)) {
|
||||
const files = value.filter((item): item is { filename?: string; format?: string; subfolder?: string; type?: string } => Boolean(item && typeof item === 'object'))
|
||||
const match = files.find(item => {
|
||||
const name = String(item.filename || '').toLowerCase()
|
||||
return name.endsWith('.flac') || name.endsWith('.wav')
|
||||
}) || files.find(isAudioFile)
|
||||
if (match?.filename) return normalizeAudio(match)
|
||||
for (const item of value) {
|
||||
const nested = findAudio(item, depth + 1)
|
||||
if (nested) return nested
|
||||
}
|
||||
return null
|
||||
}
|
||||
const record = value as { filename?: string; format?: string; subfolder?: string; type?: string }
|
||||
if (record.filename && isAudioFile(record)) return normalizeAudio(record)
|
||||
for (const nested of Object.values(value as Record<string, unknown>)) {
|
||||
const found = findAudio(nested, depth + 1)
|
||||
if (found) return found
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function extractAudio(history: Record<string, unknown> | null, promptId: string) {
|
||||
if (!history) return null
|
||||
const wrapped = history[promptId] as { outputs?: Record<string, unknown> } | undefined
|
||||
if (wrapped) {
|
||||
return findAudio(wrapped.outputs || {}) || findAudio(wrapped)
|
||||
}
|
||||
if ((history as { outputs?: unknown }).outputs) {
|
||||
return findAudio((history as { outputs?: unknown }).outputs) || findAudio(history)
|
||||
}
|
||||
return findAudio(history)
|
||||
}
|
||||
|
||||
export function extractVideo(history: Record<string, unknown> | null, promptId: string) {
|
||||
if (!history) return null
|
||||
const wrapped = history[promptId] as { outputs?: Record<string, unknown> } | undefined
|
||||
|
||||
+17
-5
@@ -12,6 +12,7 @@ export interface JobEvent {
|
||||
mediaType?: string
|
||||
clipId?: string
|
||||
stillId?: string
|
||||
trackId?: string
|
||||
hideThumbnail?: boolean
|
||||
error?: string
|
||||
elapsedMs?: number
|
||||
@@ -31,7 +32,7 @@ export interface JobEvent {
|
||||
|
||||
export interface Job {
|
||||
id: string
|
||||
kind?: 'video' | 'edit'
|
||||
kind?: 'video' | 'edit' | 'music'
|
||||
promptId?: string
|
||||
clientId: string
|
||||
status: JobStatus
|
||||
@@ -41,8 +42,10 @@ export interface Job {
|
||||
maxStep: number
|
||||
startedAt: number
|
||||
video?: { filename: string; subfolder: string; type: string }
|
||||
audio?: { filename: string; subfolder: string; type: string }
|
||||
clipId?: string
|
||||
stillId?: string
|
||||
trackId?: string
|
||||
hideThumbnail?: boolean
|
||||
imageComfyHost?: string
|
||||
library?: {
|
||||
@@ -102,6 +105,12 @@ export interface Job {
|
||||
loraStack?: import('~/utils/loras').LoraStackItem[]
|
||||
shotLoras?: string[]
|
||||
shotLoraStacks?: import('~/utils/loras').LoraStackItem[][]
|
||||
tags?: string
|
||||
lyrics?: string
|
||||
instrumental?: boolean
|
||||
audioExt?: string
|
||||
engine?: string
|
||||
lyricsStrength?: number
|
||||
}
|
||||
error?: string
|
||||
socketReady?: boolean
|
||||
@@ -115,9 +124,10 @@ export interface Job {
|
||||
const jobs = new Map<string, Job>()
|
||||
const MAX_JOBS = 40
|
||||
|
||||
export function createJob(): Job {
|
||||
export function createJob(kind: Job['kind'] = 'video'): Job {
|
||||
const job: Job = {
|
||||
id: crypto.randomUUID(),
|
||||
kind,
|
||||
clientId: crypto.randomUUID(),
|
||||
status: 'queued',
|
||||
message: 'Queued',
|
||||
@@ -175,6 +185,7 @@ export function listJobs() {
|
||||
}
|
||||
|
||||
export function jobSnapshot(job: Job) {
|
||||
const media = job.kind === 'music' ? job.audio : job.video
|
||||
return {
|
||||
jobId: job.id,
|
||||
kind: job.kind || 'video',
|
||||
@@ -186,11 +197,12 @@ export function jobSnapshot(job: Job) {
|
||||
maxStep: job.maxStep,
|
||||
promptId: job.promptId,
|
||||
elapsedMs: Date.now() - job.startedAt,
|
||||
filename: job.library?.folderLocked ? undefined : job.video?.filename,
|
||||
subfolder: job.library?.folderLocked ? undefined : job.video?.subfolder,
|
||||
mediaType: job.library?.folderLocked ? undefined : job.video?.type,
|
||||
filename: job.library?.folderLocked ? undefined : media?.filename,
|
||||
subfolder: job.library?.folderLocked ? undefined : media?.subfolder,
|
||||
mediaType: job.library?.folderLocked ? undefined : media?.type,
|
||||
clipId: job.clipId,
|
||||
stillId: job.stillId,
|
||||
trackId: job.trackId,
|
||||
hideThumbnail: job.hideThumbnail,
|
||||
error: job.error,
|
||||
folderLocked: job.library?.folderLocked,
|
||||
|
||||
+207
-4
@@ -66,6 +66,27 @@ export interface LibraryClip {
|
||||
promptPost?: string
|
||||
}
|
||||
|
||||
export interface LibraryTrack {
|
||||
id: string
|
||||
folderId: string
|
||||
name: string
|
||||
tags: string
|
||||
lyrics: string
|
||||
duration: number
|
||||
steps: number
|
||||
seed: number
|
||||
cfg: number
|
||||
instrumental: boolean
|
||||
createdAt: number
|
||||
filename: string
|
||||
ext: string
|
||||
engine?: string
|
||||
samplerName?: string
|
||||
scheduler?: string
|
||||
lyricsStrength?: number
|
||||
comfyFilename?: string
|
||||
}
|
||||
|
||||
export type StillRole = 'input' | 'output'
|
||||
|
||||
export interface LibraryStill {
|
||||
@@ -97,6 +118,7 @@ export interface PublicFolder {
|
||||
unlocked: boolean
|
||||
clipCount: number
|
||||
imageCount: number
|
||||
trackCount: number
|
||||
}
|
||||
|
||||
export interface QueuedExtension {
|
||||
@@ -146,6 +168,7 @@ interface Catalog {
|
||||
folders: LibraryFolder[]
|
||||
clips: LibraryClip[]
|
||||
stills: LibraryStill[]
|
||||
tracks: LibraryTrack[]
|
||||
drafts: RetryDraft[]
|
||||
loraAliases: Record<string, string>
|
||||
loraTriggers: Record<string, string>
|
||||
@@ -188,6 +211,7 @@ function emptyCatalog(): Catalog {
|
||||
}],
|
||||
clips: [],
|
||||
stills: [],
|
||||
tracks: [],
|
||||
drafts: [],
|
||||
loraAliases: {},
|
||||
loraTriggers: {},
|
||||
@@ -239,6 +263,30 @@ function normalizeStill(still: LibraryStill): LibraryStill {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTrack(track: LibraryTrack): LibraryTrack {
|
||||
const ext = String(track.ext || track.filename || 'flac').replace(/^\./, '').toLowerCase() || 'flac'
|
||||
return {
|
||||
id: track.id,
|
||||
folderId: track.folderId,
|
||||
name: String(track.name || '').trim() || 'Untitled track',
|
||||
tags: String(track.tags || ''),
|
||||
lyrics: String(track.lyrics || ''),
|
||||
duration: Number(track.duration) || 0,
|
||||
steps: Number(track.steps) || 0,
|
||||
seed: Number(track.seed) || 0,
|
||||
cfg: Number(track.cfg) || 0,
|
||||
instrumental: track.instrumental === true,
|
||||
createdAt: track.createdAt || Date.now(),
|
||||
filename: String(track.filename || `audio.${ext}`),
|
||||
ext,
|
||||
engine: track.engine,
|
||||
samplerName: track.samplerName,
|
||||
scheduler: track.scheduler,
|
||||
lyricsStrength: track.lyricsStrength,
|
||||
comfyFilename: track.comfyFilename
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCatalog(parsed: Partial<Catalog> & { passwordHash?: string | null }): Catalog {
|
||||
const folders = Array.isArray(parsed.folders) && parsed.folders.length
|
||||
? parsed.folders.map(normalizeFolder)
|
||||
@@ -252,6 +300,7 @@ function normalizeCatalog(parsed: Partial<Catalog> & { passwordHash?: string | n
|
||||
folders,
|
||||
clips: Array.isArray(parsed.clips) ? parsed.clips : [],
|
||||
stills: Array.isArray(parsed.stills) ? parsed.stills.map(normalizeStill) : [],
|
||||
tracks: Array.isArray(parsed.tracks) ? parsed.tracks.map(normalizeTrack) : [],
|
||||
drafts: Array.isArray(parsed.drafts) ? parsed.drafts : [],
|
||||
loraAliases: normalizeLoraAliases(parsed.loraAliases),
|
||||
loraTriggers: normalizeLoraTriggers(parsed.loraTriggers),
|
||||
@@ -284,6 +333,7 @@ function migrateLegacy(owner: string) {
|
||||
function ensureOwner(owner: string) {
|
||||
mkdirSync(join(ownerRoot(owner), 'files'), { recursive: true })
|
||||
mkdirSync(join(ownerRoot(owner), 'stills'), { recursive: true })
|
||||
mkdirSync(join(ownerRoot(owner), 'tracks'), { recursive: true })
|
||||
migrateLegacy(owner)
|
||||
}
|
||||
|
||||
@@ -392,7 +442,11 @@ function existingOutputNames(catalog: Catalog, folderId: string) {
|
||||
.filter(item => item.folderId === folderId)
|
||||
.map(item => String(item.name || '').trim())
|
||||
.filter(Boolean)
|
||||
return [...stills, ...clips]
|
||||
const tracks = catalog.tracks
|
||||
.filter(item => item.folderId === folderId)
|
||||
.map(item => String(item.name || '').trim())
|
||||
.filter(Boolean)
|
||||
return [...stills, ...clips, ...tracks]
|
||||
}
|
||||
|
||||
export function getLoraAliases(owner: string) {
|
||||
@@ -481,7 +535,7 @@ export function folderUnlocked(event: H3Event, folder: LibraryFolder) {
|
||||
return folderUnlocks(event)[folder.id] === passwordVersion(folder.passwordHash)
|
||||
}
|
||||
|
||||
export function publicFolder(event: H3Event, folder: LibraryFolder, clipCount = 0, imageCount = 0): PublicFolder {
|
||||
export function publicFolder(event: H3Event, folder: LibraryFolder, clipCount = 0, imageCount = 0, trackCount = 0): PublicFolder {
|
||||
return {
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
@@ -490,7 +544,8 @@ export function publicFolder(event: H3Event, folder: LibraryFolder, clipCount =
|
||||
protected: Boolean(folder.passwordHash),
|
||||
unlocked: folderUnlocked(event, folder),
|
||||
clipCount,
|
||||
imageCount
|
||||
imageCount,
|
||||
trackCount
|
||||
}
|
||||
}
|
||||
|
||||
@@ -501,6 +556,7 @@ export function publicLibrary(event: H3Event, opts?: { light?: boolean }) {
|
||||
const clipIds = new Set(catalog.clips.map(clip => clip.id.toLowerCase()))
|
||||
const clipCounts = new Map<string, number>()
|
||||
const imageCounts = new Map<string, number>()
|
||||
const trackCounts = new Map<string, number>()
|
||||
for (const clip of catalog.clips) {
|
||||
clipCounts.set(clip.folderId, (clipCounts.get(clip.folderId) || 0) + 1)
|
||||
}
|
||||
@@ -508,11 +564,15 @@ export function publicLibrary(event: H3Event, opts?: { light?: boolean }) {
|
||||
if (isPipelineStill(catalog, still, clipIds) || stillRole(still) !== 'output') continue
|
||||
imageCounts.set(still.folderId, (imageCounts.get(still.folderId) || 0) + 1)
|
||||
}
|
||||
for (const track of catalog.tracks || []) {
|
||||
trackCounts.set(track.folderId, (trackCounts.get(track.folderId) || 0) + 1)
|
||||
}
|
||||
const folders = catalog.folders.map(folder => publicFolder(
|
||||
event,
|
||||
folder,
|
||||
clipCounts.get(folder.id) || 0,
|
||||
imageCounts.get(folder.id) || 0
|
||||
imageCounts.get(folder.id) || 0,
|
||||
trackCounts.get(folder.id) || 0
|
||||
))
|
||||
const unlockedIds = new Set(folders.filter(folder => folder.unlocked).map(folder => folder.id))
|
||||
return {
|
||||
@@ -526,6 +586,12 @@ export function publicLibrary(event: H3Event, opts?: { light?: boolean }) {
|
||||
stills: catalog.stills
|
||||
.filter(still => unlockedIds.has(still.folderId) && !isPipelineStill(catalog, still, clipIds))
|
||||
.map(still => ({ ...still, role: stillRole(still) })),
|
||||
tracks: (catalog.tracks || [])
|
||||
.filter(track => unlockedIds.has(track.folderId))
|
||||
.map(track => ({
|
||||
...track,
|
||||
hasAudio: light ? true : existsSync(trackAudioPath(owner, track.id, track.ext))
|
||||
})),
|
||||
drafts: catalog.drafts,
|
||||
loraAliases: catalog.loraAliases || {},
|
||||
loraTriggers: catalog.loraTriggers || {},
|
||||
@@ -599,15 +665,20 @@ export function deleteFolder(owner: string, id: string) {
|
||||
if (!folder) throw createError({ statusCode: 404, statusMessage: 'Folder not found' })
|
||||
const clips = catalog.clips.filter(clip => clip.folderId === id)
|
||||
const stills = catalog.stills.filter(still => still.folderId === id)
|
||||
const tracks = (catalog.tracks || []).filter(track => track.folderId === id)
|
||||
catalog.folders = catalog.folders.filter(item => item.id !== id)
|
||||
catalog.clips = catalog.clips.filter(clip => clip.folderId !== id)
|
||||
catalog.stills = catalog.stills.filter(still => still.folderId !== id)
|
||||
catalog.tracks = (catalog.tracks || []).filter(track => track.folderId !== id)
|
||||
for (const clip of clips) {
|
||||
rmSync(clipDir(owner, clip.id), { recursive: true, force: true })
|
||||
}
|
||||
for (const still of stills) {
|
||||
removeStillFiles(owner, still.id)
|
||||
}
|
||||
for (const track of tracks) {
|
||||
rmSync(trackDir(owner, track.id), { recursive: true, force: true })
|
||||
}
|
||||
return { fallbackFolderId: catalog.folders[0].id }
|
||||
})
|
||||
}
|
||||
@@ -695,6 +766,25 @@ export function clipVideoPath(owner: string, id: string) {
|
||||
return join(clipDir(owner, id), 'video.mp4')
|
||||
}
|
||||
|
||||
export function trackDir(owner: string, id: string) {
|
||||
const current = join(ownerRoot(owner), 'tracks', id)
|
||||
if (existsSync(current)) return current
|
||||
const legacy = join(libraryRoot(), 'tracks', id)
|
||||
if (existsSync(legacy)) return legacy
|
||||
return current
|
||||
}
|
||||
|
||||
export function trackAudioPath(owner: string, id: string, ext = 'flac') {
|
||||
const dir = trackDir(owner, id)
|
||||
const wanted = join(dir, `audio.${String(ext || 'flac').replace(/^\./, '')}`)
|
||||
if (existsSync(wanted)) return wanted
|
||||
for (const fallback of ['flac', 'wav', 'mp3', 'ogg', 'opus']) {
|
||||
const path = join(dir, `audio.${fallback}`)
|
||||
if (existsSync(path)) return path
|
||||
}
|
||||
return wanted
|
||||
}
|
||||
|
||||
export function clipLastFramePath(owner: string, id: string) {
|
||||
return join(clipDir(owner, id), 'last-frame.png')
|
||||
}
|
||||
@@ -1653,6 +1743,103 @@ export function getClip(owner: string, id: string) {
|
||||
return clip
|
||||
}
|
||||
|
||||
export function getTrack(owner: string, id: string) {
|
||||
const track = readCatalog(owner).tracks.find(item => item.id === id)
|
||||
if (!track) throw createError({ statusCode: 404, statusMessage: 'Track not found' })
|
||||
return track
|
||||
}
|
||||
|
||||
export async function saveTrack(params: {
|
||||
ownerKey: string
|
||||
folderId: string
|
||||
name?: string
|
||||
tags: string
|
||||
lyrics: string
|
||||
duration: number
|
||||
steps: number
|
||||
seed: number
|
||||
cfg: number
|
||||
instrumental: boolean
|
||||
audio: Buffer
|
||||
ext?: string
|
||||
engine?: string
|
||||
samplerName?: string
|
||||
scheduler?: string
|
||||
lyricsStrength?: number
|
||||
comfyFilename?: string
|
||||
}) {
|
||||
const catalog = readCatalog(params.ownerKey)
|
||||
const folder = catalog.folders.find(item => item.id === params.folderId) || catalog.folders[0]
|
||||
if (!folder) throw createError({ statusCode: 400, statusMessage: 'No library folder available' })
|
||||
const ext = String(params.ext || 'flac').replace(/^\./, '').toLowerCase() || 'flac'
|
||||
const track: LibraryTrack = {
|
||||
id: crypto.randomUUID(),
|
||||
folderId: folder.id,
|
||||
name: clipTitle({ name: params.name || '', prompt: params.tags }),
|
||||
tags: params.tags,
|
||||
lyrics: params.lyrics,
|
||||
duration: params.duration,
|
||||
steps: params.steps,
|
||||
seed: params.seed,
|
||||
cfg: params.cfg,
|
||||
instrumental: params.instrumental === true,
|
||||
createdAt: Date.now(),
|
||||
filename: `audio.${ext}`,
|
||||
ext,
|
||||
engine: params.engine,
|
||||
samplerName: params.samplerName,
|
||||
scheduler: params.scheduler,
|
||||
lyricsStrength: params.lyricsStrength,
|
||||
comfyFilename: params.comfyFilename
|
||||
}
|
||||
mkdirSync(trackDir(params.ownerKey, track.id), { recursive: true })
|
||||
await writeFile(trackAudioPath(params.ownerKey, track.id, ext), params.audio)
|
||||
if (!(typeof params.duration === 'number' && params.duration > 0)) {
|
||||
try {
|
||||
track.duration = await probeDuration(trackAudioPath(params.ownerKey, track.id, ext))
|
||||
} catch {
|
||||
/* duration stays as posted */
|
||||
}
|
||||
}
|
||||
await mutate(params.ownerKey, (next) => {
|
||||
if (!Array.isArray(next.tracks)) next.tracks = []
|
||||
next.tracks.unshift(track)
|
||||
})
|
||||
return track
|
||||
}
|
||||
|
||||
export function deleteTrack(owner: string, id: string) {
|
||||
return mutate(owner, (catalog) => {
|
||||
const track = catalog.tracks.find(item => item.id === id)
|
||||
if (!track) throw createError({ statusCode: 404, statusMessage: 'Track not found' })
|
||||
catalog.tracks = catalog.tracks.filter(item => item.id !== id)
|
||||
rmSync(trackDir(owner, id), { recursive: true, force: true })
|
||||
return track
|
||||
})
|
||||
}
|
||||
|
||||
export function moveTrack(owner: string, id: string, folderId: string) {
|
||||
return mutate(owner, (catalog) => {
|
||||
const track = catalog.tracks.find(item => item.id === id)
|
||||
if (!track) throw createError({ statusCode: 404, statusMessage: 'Track not found' })
|
||||
const folder = catalog.folders.find(item => item.id === folderId)
|
||||
if (!folder) throw createError({ statusCode: 404, statusMessage: 'Folder not found' })
|
||||
track.folderId = folderId
|
||||
return track
|
||||
})
|
||||
}
|
||||
|
||||
export function renameTrack(owner: string, id: string, name: string) {
|
||||
return mutate(owner, (catalog) => {
|
||||
const track = catalog.tracks.find(item => item.id === id)
|
||||
if (!track) throw createError({ statusCode: 404, statusMessage: 'Track not found' })
|
||||
const cleaned = String(name || '').trim().slice(0, 80)
|
||||
if (!cleaned) throw createError({ statusCode: 400, statusMessage: 'A name is required' })
|
||||
track.name = cleaned
|
||||
return track
|
||||
})
|
||||
}
|
||||
|
||||
function stampFamilyId(family: LibraryClip[], fallback?: string) {
|
||||
const familyId = family.find(item => item.familyId)?.familyId || fallback || crypto.randomUUID()
|
||||
for (const item of family) item.familyId = familyId
|
||||
@@ -1791,6 +1978,22 @@ export async function downloadComfyVideo(video: { filename: string; subfolder: s
|
||||
throw new Error(`Failed to fetch completed video from ComfyUI (${lastStatus})`)
|
||||
}
|
||||
|
||||
export async function downloadComfyAudio(audio: { filename: string; subfolder: string; type: string }) {
|
||||
const subfolders = [...new Set([audio.subfolder, 'audio', ''])]
|
||||
let lastStatus = 0
|
||||
for (const subfolder of subfolders) {
|
||||
const params = new URLSearchParams({
|
||||
filename: audio.filename,
|
||||
subfolder,
|
||||
type: audio.type || 'output'
|
||||
})
|
||||
const res = await comfyFetch(`/view?${params.toString()}`)
|
||||
lastStatus = res.status
|
||||
if (res.ok) return Buffer.from(await res.arrayBuffer())
|
||||
}
|
||||
throw new Error(`Failed to fetch completed audio from ComfyUI (${lastStatus})`)
|
||||
}
|
||||
|
||||
export async function importMissingComfyVideos(owner: string, folderId?: string) {
|
||||
const catalog = readCatalog(owner)
|
||||
const folder = catalog.folders.find(item => item.id === folderId) || catalog.folders[0]
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
import { createJob, emitJob, type Job } from '~/server/utils/jobs'
|
||||
import { extractAudio, fetchHistory, queuePrompt } from '~/server/utils/comfy'
|
||||
import { comfyWsUrl } from '~/server/utils/comfy'
|
||||
import { ensureComfyReady } from '~/server/utils/comfyLifecycle'
|
||||
import { downloadComfyAudio, saveTrack } from '~/server/utils/library'
|
||||
import { buildMusicWorkflow, musicFilenamePrefix } from '~/server/utils/musicWorkflow'
|
||||
import { NODE_LABELS } from '~/server/utils/workflow'
|
||||
import { musicExtension } from '~/utils/music'
|
||||
import type { MusicEngine } from '~/utils/music'
|
||||
|
||||
export type MusicJobParams = {
|
||||
ownerKey: string
|
||||
folderId: string
|
||||
name: string
|
||||
tags: string
|
||||
lyrics: string
|
||||
duration: number
|
||||
steps: number
|
||||
seed: number
|
||||
cfg: number
|
||||
lyricsStrength: number
|
||||
instrumental: boolean
|
||||
folderLocked?: boolean
|
||||
engine?: MusicEngine
|
||||
samplerName?: string
|
||||
scheduler?: string
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function classifyError(message: string) {
|
||||
const lower = message.toLowerCase()
|
||||
if (lower.includes('out of memory') || (lower.includes('cuda') && lower.includes('alloc')) || lower.includes('vram')) {
|
||||
return 'ComfyUI VRAM allocation failed. Try fewer steps or a shorter duration.'
|
||||
}
|
||||
if (lower.includes('econnrefused') || lower.includes('unreachable') || lower.includes('fetch failed')) {
|
||||
return 'ComfyUI host connection dropped. Confirm the desktop instance is running.'
|
||||
}
|
||||
if (lower.includes('timeout')) {
|
||||
return 'Network timeout talking to ComfyUI. The job may still be running on the desktop.'
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
function nodeLabel(node: string) {
|
||||
if (!node) return ''
|
||||
return NODE_LABELS[node] || ''
|
||||
}
|
||||
|
||||
async function waitForSocket(job: Job, ms = 4000) {
|
||||
const started = Date.now()
|
||||
while (Date.now() - started < ms) {
|
||||
if (job.socketReady) return
|
||||
await sleep(100)
|
||||
}
|
||||
}
|
||||
|
||||
function watchMusicJob(job: Job): Promise<void> {
|
||||
job.socketReady = false
|
||||
job.saving = false
|
||||
const ws = new WebSocket(comfyWsUrl(job.clientId))
|
||||
let settled = false
|
||||
let finishing = false
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let localProgress = 0
|
||||
const startedAt = Date.now()
|
||||
let lastActivity = Date.now()
|
||||
const IDLE_MS = 30 * 60 * 1000
|
||||
const ABSOLUTE_MS = 3 * 60 * 60 * 1000
|
||||
|
||||
const markActivity = () => { lastActivity = Date.now() }
|
||||
|
||||
const emitLocal = (event: Parameters<typeof emitJob>[1], samplePct?: number) => {
|
||||
markActivity()
|
||||
if (typeof event.progress === 'number') localProgress = event.progress
|
||||
if (event.message && typeof samplePct === 'number') {
|
||||
event.message = `${event.message} (${samplePct}%)`
|
||||
}
|
||||
emitJob(job, event)
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
if (timeout) clearTimeout(timeout)
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
timeout = null
|
||||
pollTimer = null
|
||||
try { ws.close() } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const armIdle = () => {
|
||||
if (timeout) clearTimeout(timeout)
|
||||
if (settled) return
|
||||
const absLeft = ABSOLUTE_MS - (Date.now() - startedAt)
|
||||
if (absLeft <= 0) {
|
||||
void fail('Timed out waiting for ComfyUI (3 hours).')
|
||||
return
|
||||
}
|
||||
const idleLeft = IDLE_MS - (Date.now() - lastActivity)
|
||||
timeout = setTimeout(() => {
|
||||
if (settled || finishing) return
|
||||
if (Date.now() - lastActivity >= IDLE_MS) {
|
||||
void fail('Timed out waiting for ComfyUI progress (30 minutes with no updates).')
|
||||
return
|
||||
}
|
||||
armIdle()
|
||||
}, Math.max(1000, Math.min(idleLeft, absLeft)))
|
||||
}
|
||||
|
||||
const fail = async (error: string) => {
|
||||
if (settled || finishing) return
|
||||
settled = true
|
||||
cleanup()
|
||||
job.status = job.status === 'cancelled' ? 'cancelled' : 'error'
|
||||
job.error = classifyError(error)
|
||||
emitJob(job, { type: 'error', error: job.error, message: job.error })
|
||||
resolve()
|
||||
}
|
||||
|
||||
const succeed = async () => {
|
||||
if (settled || finishing || !job.promptId) return false
|
||||
finishing = true
|
||||
job.saving = true
|
||||
try {
|
||||
const history = await fetchHistory(job.promptId)
|
||||
const audio = extractAudio(history, job.promptId)
|
||||
if (!audio) {
|
||||
finishing = false
|
||||
job.saving = false
|
||||
return false
|
||||
}
|
||||
if (settled) return false
|
||||
settled = true
|
||||
cleanup()
|
||||
try {
|
||||
job.audio = audio
|
||||
emitLocal({ type: 'status', message: 'Saving to library...', progress: 98 })
|
||||
if (job.library) {
|
||||
const buffer = await downloadComfyAudio(audio)
|
||||
const ext = musicExtension(audio.filename)
|
||||
const track = await saveTrack({
|
||||
ownerKey: job.library.ownerKey,
|
||||
folderId: job.library.folderId,
|
||||
name: job.library.name,
|
||||
tags: job.library.tags || job.library.prompt,
|
||||
lyrics: job.library.lyrics || '',
|
||||
duration: job.library.duration || 0,
|
||||
steps: job.library.steps,
|
||||
seed: job.library.seed,
|
||||
cfg: job.library.cfg || 0,
|
||||
instrumental: job.library.instrumental === true,
|
||||
audio: buffer,
|
||||
ext,
|
||||
engine: job.library.engine,
|
||||
samplerName: job.library.samplerName,
|
||||
scheduler: job.library.scheduler,
|
||||
lyricsStrength: job.library.lyricsStrength,
|
||||
comfyFilename: audio.filename
|
||||
})
|
||||
job.trackId = track.id
|
||||
job.library.audioExt = ext
|
||||
}
|
||||
job.status = 'complete'
|
||||
emitJob(job, {
|
||||
type: 'complete',
|
||||
message: job.library?.folderLocked
|
||||
? 'Saved to the locked folder. Unlock it to listen.'
|
||||
: 'Track ready',
|
||||
progress: 100,
|
||||
filename: job.library?.folderLocked ? undefined : audio.filename,
|
||||
subfolder: job.library?.folderLocked ? undefined : audio.subfolder,
|
||||
mediaType: job.library?.folderLocked ? undefined : audio.type,
|
||||
trackId: job.trackId,
|
||||
folderLocked: job.library?.folderLocked
|
||||
})
|
||||
} catch (saveError) {
|
||||
const message = saveError instanceof Error ? saveError.message : String(saveError)
|
||||
job.status = 'error'
|
||||
job.error = `Audio generated but library save failed: ${message}`
|
||||
emitJob(job, { type: 'error', error: job.error, message: job.error })
|
||||
}
|
||||
job.saving = false
|
||||
resolve()
|
||||
return true
|
||||
} catch (error) {
|
||||
finishing = false
|
||||
job.saving = false
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const pollHistory = async () => {
|
||||
if (settled || finishing || !job.promptId) return
|
||||
try {
|
||||
const history = await fetchHistory(job.promptId)
|
||||
if (extractAudio(history, job.promptId)) {
|
||||
await succeed()
|
||||
return
|
||||
}
|
||||
const entry = (history?.[job.promptId] || history) as {
|
||||
status?: { status_str?: string; completed?: boolean }
|
||||
} | undefined
|
||||
const status = entry?.status?.status_str
|
||||
if (status === 'error') await fail('ComfyUI reported an execution error')
|
||||
else if (status === 'interrupted') await fail('Job interrupted.')
|
||||
} catch {
|
||||
// history can miss while the graph is still running
|
||||
}
|
||||
}
|
||||
|
||||
armIdle()
|
||||
pollTimer = setInterval(() => { void pollHistory() }, 4000)
|
||||
|
||||
ws.addEventListener('open', () => {
|
||||
job.socketReady = true
|
||||
emitLocal({ type: 'status', message: 'Connected to ComfyUI', progress: Math.max(localProgress, 4) })
|
||||
})
|
||||
ws.addEventListener('error', () => {
|
||||
job.socketReady = false
|
||||
})
|
||||
ws.addEventListener('message', (event) => {
|
||||
let payload: { type?: string; data?: Record<string, any> }
|
||||
try {
|
||||
payload = JSON.parse(String(event.data))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const type = String(payload.type || '')
|
||||
const data = payload.data || {}
|
||||
if (type === 'status') markActivity()
|
||||
if (type === 'progress') {
|
||||
const value = Number(data.value || 0)
|
||||
const max = Number(data.max || 0)
|
||||
const pct = max > 0 ? Math.min(95, Math.round((value / max) * 90) + 8) : Math.max(localProgress, 12)
|
||||
const node = String(data.node || '')
|
||||
emitLocal({
|
||||
type: 'progress',
|
||||
message: nodeLabel(node) || 'Sampling',
|
||||
progress: pct,
|
||||
step: value,
|
||||
maxStep: max,
|
||||
node
|
||||
}, max > 0 ? Math.round((value / max) * 100) : undefined)
|
||||
}
|
||||
if (type === 'executing') {
|
||||
const node = data.node == null ? '' : String(data.node)
|
||||
if (!node && job.promptId) void succeed()
|
||||
else if (node) {
|
||||
emitLocal({
|
||||
type: 'status',
|
||||
message: nodeLabel(node) || 'Running node',
|
||||
progress: Math.max(localProgress, 10),
|
||||
node
|
||||
})
|
||||
}
|
||||
}
|
||||
if (type === 'executed') {
|
||||
void succeed()
|
||||
}
|
||||
if (type === 'execution_error') {
|
||||
const message = String(data.exception_message || data.message || 'ComfyUI node execution failed')
|
||||
void fail(message)
|
||||
}
|
||||
if (type === 'execution_interrupted') {
|
||||
job.status = 'cancelled'
|
||||
void fail('Job interrupted.')
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function startMusicJob(params: MusicJobParams) {
|
||||
const job = createJob('music')
|
||||
job.library = {
|
||||
ownerKey: params.ownerKey,
|
||||
folderId: params.folderId,
|
||||
hideThumbnail: false,
|
||||
folderLocked: params.folderLocked === true,
|
||||
name: params.name,
|
||||
prompt: params.tags,
|
||||
tags: params.tags,
|
||||
lyrics: params.lyrics,
|
||||
instrumental: params.instrumental,
|
||||
aspect: 'audio',
|
||||
width: 0,
|
||||
height: 0,
|
||||
steps: params.steps,
|
||||
turbo: false,
|
||||
seed: params.seed,
|
||||
cfg: params.cfg,
|
||||
duration: params.duration,
|
||||
sound: true,
|
||||
engine: params.engine || 'ace-step',
|
||||
samplerName: params.samplerName || 'euler',
|
||||
scheduler: params.scheduler || 'simple',
|
||||
lyricsStrength: params.lyricsStrength
|
||||
}
|
||||
job.maxStep = params.steps
|
||||
job.status = 'queued'
|
||||
emitJob(job, { type: 'status', message: 'Checking ComfyUI...', progress: 1 })
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const done = watchMusicJob(job)
|
||||
await ensureComfyReady((status) => {
|
||||
emitJob(job, {
|
||||
type: status.state === 'busy' ? 'busy' : 'status',
|
||||
message: status.message,
|
||||
progress: status.state === 'online' ? 3 : 1,
|
||||
busy: status.state === 'busy',
|
||||
queueRunning: status.queueRunning,
|
||||
queuePending: status.queuePending
|
||||
})
|
||||
})
|
||||
job.status = 'uploading'
|
||||
emitJob(job, { type: 'status', message: 'Queueing ACE-Step...', progress: 6 })
|
||||
await waitForSocket(job, 4000)
|
||||
const graph = buildMusicWorkflow({
|
||||
tags: params.tags,
|
||||
lyrics: params.instrumental ? '' : params.lyrics,
|
||||
duration: params.duration,
|
||||
steps: params.steps,
|
||||
seed: params.seed,
|
||||
cfg: params.cfg,
|
||||
lyricsStrength: params.instrumental ? 0 : params.lyricsStrength,
|
||||
samplerName: params.samplerName,
|
||||
scheduler: params.scheduler,
|
||||
filenamePrefix: musicFilenamePrefix(),
|
||||
engine: params.engine,
|
||||
vocalMultiplier: params.instrumental ? 0.2 : 1
|
||||
})
|
||||
const queued = await queuePrompt(graph, job.clientId)
|
||||
job.promptId = queued.prompt_id
|
||||
job.status = 'running'
|
||||
emitJob(job, { type: 'status', message: 'Job queued on ComfyUI', progress: 8 })
|
||||
await done
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (job.status !== 'cancelled' && job.status !== 'complete') {
|
||||
job.status = 'error'
|
||||
job.error = classifyError(message)
|
||||
emitJob(job, { type: 'error', error: job.error, message: job.error })
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
return job
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import workflowAceStep from '../assets/workflow_ace_step.json'
|
||||
import type { MusicEngine } from '~/utils/music'
|
||||
|
||||
export type MusicWorkflowParams = {
|
||||
tags: string
|
||||
lyrics: string
|
||||
duration: number
|
||||
steps: number
|
||||
seed: number
|
||||
cfg: number
|
||||
lyricsStrength: number
|
||||
samplerName?: string
|
||||
scheduler?: string
|
||||
filenamePrefix?: string
|
||||
checkpoint?: string
|
||||
engine?: MusicEngine
|
||||
vocalMultiplier?: number
|
||||
}
|
||||
|
||||
type WorkflowNode = { class_type: string; inputs: Record<string, unknown>; _meta?: { title?: string } }
|
||||
type WorkflowGraph = Record<string, WorkflowNode>
|
||||
|
||||
const ACE_V1_CKPT = 'ace_step_v1_3.5b.safetensors'
|
||||
const ACE_15_CKPT = 'ace_step_1.5_turbo_aio.safetensors'
|
||||
|
||||
export function musicFilenamePrefix() {
|
||||
return String(useRuntimeConfig().comfyAudioPrefix || process.env.COMFY_AUDIO_PREFIX || 'audio/AIGen')
|
||||
}
|
||||
|
||||
export function buildMusicWorkflow(params: MusicWorkflowParams): WorkflowGraph {
|
||||
const graph = structuredClone(workflowAceStep as WorkflowGraph)
|
||||
const engine = params.engine || 'ace-step'
|
||||
const checkpoint = params.checkpoint
|
||||
|| (engine === 'ace-step-1.5' ? ACE_15_CKPT : ACE_V1_CKPT)
|
||||
const prefix = params.filenamePrefix || musicFilenamePrefix()
|
||||
const sampler = params.samplerName || 'euler'
|
||||
const scheduler = params.scheduler || 'simple'
|
||||
|
||||
for (const node of Object.values(graph)) {
|
||||
if (node.class_type === 'CheckpointLoaderSimple' && 'ckpt_name' in node.inputs) {
|
||||
node.inputs.ckpt_name = checkpoint
|
||||
}
|
||||
if (node.class_type === 'TextEncodeAceStepAudio') {
|
||||
node.inputs.tags = params.tags
|
||||
node.inputs.lyrics = params.lyrics
|
||||
node.inputs.lyrics_strength = params.lyricsStrength
|
||||
}
|
||||
if (node.class_type === 'EmptyAceStepLatentAudio' && 'seconds' in node.inputs) {
|
||||
node.inputs.seconds = params.duration
|
||||
}
|
||||
if (node.class_type === 'KSampler') {
|
||||
node.inputs.seed = params.seed
|
||||
node.inputs.steps = params.steps
|
||||
node.inputs.cfg = params.cfg
|
||||
node.inputs.sampler_name = sampler
|
||||
node.inputs.scheduler = scheduler
|
||||
}
|
||||
if (node.class_type === 'SaveAudio' && 'filename_prefix' in node.inputs) {
|
||||
node.inputs.filename_prefix = prefix
|
||||
}
|
||||
}
|
||||
|
||||
return graph
|
||||
}
|
||||
|
||||
export const MUSIC_NODE_LABELS: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(workflowAceStep as WorkflowGraph).map(([id, node]) => [id, node._meta?.title || `Node ${id}`])
|
||||
)
|
||||
@@ -2,6 +2,7 @@
|
||||
import workflowV1 from '../assets/workflow_minimax_video.json'
|
||||
import workflowV2 from '../assets/workflow_minimax_video_v2.json'
|
||||
import workflowLtx from '../assets/workflow_ltx_video.json'
|
||||
import { MUSIC_NODE_LABELS } from '~/server/utils/musicWorkflow'
|
||||
import { buildIdentityPrompt } from '~/utils/identityPrompt'
|
||||
import { applyUserLoraToGraph, resolveGraphLoraNames } from '~/server/utils/loras'
|
||||
import type { LoraStackItem } from '~/utils/loras'
|
||||
@@ -396,6 +397,7 @@ export const NODE_LABELS: Record<string, string> = {
|
||||
...Object.fromEntries(labelsFrom(workflowV1 as WorkflowGraph)),
|
||||
...Object.fromEntries(labelsFrom(workflowV2 as WorkflowGraph)),
|
||||
...Object.fromEntries(labelsFrom(workflowLtx as WorkflowGraph)),
|
||||
...MUSIC_NODE_LABELS,
|
||||
...LABEL_OVERRIDES
|
||||
}
|
||||
|
||||
|
||||
@@ -52,12 +52,16 @@ export async function ensureGenerationNotifyPermission() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function notifyGenerationComplete(kind: 'video' | 'image' = 'video') {
|
||||
export async function notifyGenerationComplete(kind: 'video' | 'image' | 'music' = 'video') {
|
||||
if (!generationNotifySupported() || !generationNotifyPref()) return
|
||||
if (Notification.permission !== 'granted') return
|
||||
if (typeof document !== 'undefined' && !document.hidden && document.hasFocus()) return
|
||||
const title = appName()
|
||||
const body = kind === 'image' ? 'Image generation is complete.' : 'Generation is complete.'
|
||||
const body = kind === 'image'
|
||||
? 'Image generation is complete.'
|
||||
: kind === 'music'
|
||||
? 'Music generation is complete.'
|
||||
: 'Generation is complete.'
|
||||
const options: NotificationOptions = {
|
||||
body,
|
||||
icon: appIcon(),
|
||||
|
||||
+50
-9
@@ -1,7 +1,7 @@
|
||||
import type { StillGenerationSettings } from '~/utils/libraryGeneration'
|
||||
import type { LoraStackItem } from '~/utils/loras'
|
||||
|
||||
export type LibraryFilter = 'all' | 'images' | 'videos'
|
||||
export type LibraryFilter = 'all' | 'images' | 'videos' | 'music'
|
||||
export type LibrarySort = 'date-desc' | 'date-asc' | 'name-asc' | 'name-desc' | 'type'
|
||||
|
||||
export type LibraryFolder = {
|
||||
@@ -13,6 +13,7 @@ export type LibraryFolder = {
|
||||
unlocked: boolean
|
||||
clipCount?: number
|
||||
imageCount?: number
|
||||
trackCount?: number
|
||||
}
|
||||
|
||||
export type LibraryStill = {
|
||||
@@ -66,15 +67,37 @@ export type LibraryClip = {
|
||||
loraStack?: LoraStackItem[]
|
||||
}
|
||||
|
||||
export type LibraryTrack = {
|
||||
id: string
|
||||
folderId: string
|
||||
name: string
|
||||
tags: string
|
||||
lyrics: string
|
||||
duration: number
|
||||
steps: number
|
||||
seed: number
|
||||
cfg: number
|
||||
instrumental: boolean
|
||||
createdAt: number
|
||||
filename: string
|
||||
ext: string
|
||||
engine?: string
|
||||
samplerName?: string
|
||||
scheduler?: string
|
||||
lyricsStrength?: number
|
||||
hasAudio?: boolean
|
||||
}
|
||||
|
||||
export type ClipGroup = { key: string; head: LibraryClip; parts: LibraryClip[] }
|
||||
export type StillGroup = { key: string; head: LibraryStill; parts: LibraryStill[] }
|
||||
export type LibraryEntry = {
|
||||
kind: 'clip' | 'still'
|
||||
kind: 'clip' | 'still' | 'track'
|
||||
key: string
|
||||
createdAt: number
|
||||
name: string
|
||||
group?: ClipGroup
|
||||
stillGroup?: StillGroup
|
||||
track?: LibraryTrack
|
||||
}
|
||||
|
||||
export function familyBaseName(name: string) {
|
||||
@@ -248,6 +271,7 @@ export function stillCollectionTitle(group: StillGroup) {
|
||||
|
||||
export function entryTitle(entry: LibraryEntry) {
|
||||
if (entry.kind === 'clip' && entry.group) return collectionTitle(entry.group)
|
||||
if (entry.kind === 'track' && entry.track) return entry.track.name
|
||||
if (entry.stillGroup) return stillCollectionTitle(entry.stillGroup)
|
||||
return entry.name
|
||||
}
|
||||
@@ -256,6 +280,9 @@ export function entrySearchText(entry: LibraryEntry) {
|
||||
if (entry.kind === 'clip' && entry.group) {
|
||||
return entry.group.parts.map(part => `${part.name || ''} ${part.prompt || ''}`).join(' ')
|
||||
}
|
||||
if (entry.kind === 'track' && entry.track) {
|
||||
return `${entry.track.name || ''} ${entry.track.tags || ''} ${entry.track.lyrics || ''}`
|
||||
}
|
||||
if (entry.stillGroup) {
|
||||
return entry.stillGroup.parts.map(part => `${part.name || ''} ${part.prompt || ''} ${part.filename || ''}`).join(' ')
|
||||
}
|
||||
@@ -297,6 +324,7 @@ function compareName(a: string, b: string) {
|
||||
export function buildLibraryEntries(opts: {
|
||||
clips: LibraryClip[]
|
||||
stills: LibraryStill[]
|
||||
tracks?: LibraryTrack[]
|
||||
folderId: string
|
||||
filter: LibraryFilter
|
||||
search: string
|
||||
@@ -305,7 +333,8 @@ export function buildLibraryEntries(opts: {
|
||||
const inFolder = (folderId: string) => !opts.folderId || opts.folderId === 'all' || folderId === opts.folderId
|
||||
const clips = opts.clips.filter(clip => inFolder(clip.folderId) && clip.hasVideo !== false)
|
||||
const stills = opts.stills.filter(still => inFolder(still.folderId) && still.role === 'output')
|
||||
const videos: LibraryEntry[] = opts.filter === 'images'
|
||||
const tracks = (opts.tracks || []).filter(track => inFolder(track.folderId) && track.hasAudio !== false)
|
||||
const videos: LibraryEntry[] = opts.filter === 'images' || opts.filter === 'music'
|
||||
? []
|
||||
: groupClips(clips).map(group => ({
|
||||
kind: 'clip' as const,
|
||||
@@ -314,7 +343,7 @@ export function buildLibraryEntries(opts: {
|
||||
name: collectionTitle(group),
|
||||
group
|
||||
}))
|
||||
const images: LibraryEntry[] = opts.filter === 'videos'
|
||||
const images: LibraryEntry[] = opts.filter === 'videos' || opts.filter === 'music'
|
||||
? []
|
||||
: groupStills(stills).map(group => ({
|
||||
kind: 'still' as const,
|
||||
@@ -323,7 +352,16 @@ export function buildLibraryEntries(opts: {
|
||||
name: stillCollectionTitle(group),
|
||||
stillGroup: group
|
||||
}))
|
||||
let entries = [...videos, ...images]
|
||||
const music: LibraryEntry[] = opts.filter === 'images' || opts.filter === 'videos'
|
||||
? []
|
||||
: tracks.map(track => ({
|
||||
kind: 'track' as const,
|
||||
key: `track:${track.id}`,
|
||||
createdAt: track.createdAt,
|
||||
name: track.name,
|
||||
track
|
||||
}))
|
||||
let entries = [...videos, ...images, ...music]
|
||||
const needle = opts.search.trim().toLowerCase()
|
||||
if (needle) {
|
||||
entries = entries.filter(entry => entrySearchText(entry).toLowerCase().includes(needle))
|
||||
@@ -334,7 +372,8 @@ export function buildLibraryEntries(opts: {
|
||||
if (sort === 'name-desc') return compareName(b.name, a.name) || b.createdAt - a.createdAt
|
||||
if (sort === 'date-asc') return a.createdAt - b.createdAt
|
||||
if (sort === 'type') {
|
||||
if (a.kind !== b.kind) return a.kind === 'still' ? -1 : 1
|
||||
const order = { still: 0, clip: 1, track: 2 } as const
|
||||
if (a.kind !== b.kind) return order[a.kind] - order[b.kind]
|
||||
return b.createdAt - a.createdAt
|
||||
}
|
||||
return b.createdAt - a.createdAt
|
||||
@@ -342,13 +381,15 @@ export function buildLibraryEntries(opts: {
|
||||
return entries
|
||||
}
|
||||
|
||||
export function folderOutputCount(folder: LibraryFolder, clips: LibraryClip[], stills: LibraryStill[], filter: LibraryFilter) {
|
||||
if (!folder.unlocked) return (folder.clipCount || 0) + (folder.imageCount || 0)
|
||||
export function folderOutputCount(folder: LibraryFolder, clips: LibraryClip[], stills: LibraryStill[], filter: LibraryFilter, tracks: LibraryTrack[] = []) {
|
||||
if (!folder.unlocked) return (folder.clipCount || 0) + (folder.imageCount || 0) + (folder.trackCount || 0)
|
||||
const videos = clips.filter(clip => clip.folderId === folder.id).length
|
||||
const images = stills.filter(still => still.folderId === folder.id && still.role === 'output').length
|
||||
const music = tracks.filter(track => track.folderId === folder.id).length
|
||||
if (filter === 'videos') return videos
|
||||
if (filter === 'images') return images
|
||||
return videos + images
|
||||
if (filter === 'music') return music
|
||||
return videos + images + music
|
||||
}
|
||||
|
||||
export function formatWhen(createdAt: number) {
|
||||
|
||||
@@ -280,3 +280,34 @@ export function clipSettingsRows(clip: {
|
||||
if (loras) rows.push({ label: 'LoRA', value: loras })
|
||||
return rows
|
||||
}
|
||||
|
||||
export function trackSettingsRows(track: {
|
||||
tags?: string
|
||||
lyrics?: string
|
||||
duration?: number
|
||||
steps?: number
|
||||
seed?: number
|
||||
cfg?: number
|
||||
instrumental?: boolean
|
||||
engine?: string
|
||||
samplerName?: string
|
||||
scheduler?: string
|
||||
lyricsStrength?: number
|
||||
}) {
|
||||
const rows: SettingsRow[] = []
|
||||
if (track.engine) rows.push({ label: 'Engine', value: track.engine === 'ace-step-1.5' ? 'ACE-Step 1.5' : 'ACE-Step' })
|
||||
if (track.tags?.trim()) rows.push({ label: 'Tags', value: track.tags.trim() })
|
||||
if (track.instrumental) rows.push({ label: 'Instrumental', value: 'On' })
|
||||
else if (track.lyrics?.trim()) rows.push({ label: 'Lyrics', value: track.lyrics.trim() })
|
||||
if (typeof track.duration === 'number' && track.duration > 0) {
|
||||
const rounded = Math.round(track.duration * 10) / 10
|
||||
rows.push({ label: 'Duration', value: Number.isInteger(rounded) ? `${rounded}s` : `${rounded.toFixed(1)}s` })
|
||||
}
|
||||
if (track.steps != null) rows.push({ label: 'Steps', value: String(track.steps) })
|
||||
if (track.cfg != null) rows.push({ label: 'CFG', value: formatNum(track.cfg) })
|
||||
if (track.samplerName) rows.push({ label: 'Sampler', value: track.samplerName })
|
||||
if (track.scheduler) rows.push({ label: 'Scheduler', value: track.scheduler })
|
||||
if (track.lyricsStrength != null) rows.push({ label: 'Lyric strength', value: formatNum(track.lyricsStrength) })
|
||||
if (track.seed != null) rows.push({ label: 'Seed', value: String(track.seed) })
|
||||
return rows
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
export const MUSIC_DURATION_MIN = 30
|
||||
export const MUSIC_DURATION_MAX = 180
|
||||
export const MUSIC_DURATION_DEFAULT = 60
|
||||
export const MUSIC_STEPS_MIN = 8
|
||||
export const MUSIC_STEPS_MAX = 100
|
||||
export const MUSIC_STEPS_DEFAULT = 50
|
||||
export const MUSIC_CFG_DEFAULT = 5
|
||||
export const MUSIC_LYRICS_STRENGTH_DEFAULT = 0.9
|
||||
export const MUSIC_TAGS_MAX = 2000
|
||||
export const MUSIC_LYRICS_MAX = 8000
|
||||
|
||||
export const MUSIC_STRUCTURE_CHIPS = [
|
||||
'[Verse 1]',
|
||||
'[Verse 2]',
|
||||
'[Pre-Chorus]',
|
||||
'[Chorus]',
|
||||
'[Bridge]',
|
||||
'[Guitar Solo]',
|
||||
'[Instrumental]',
|
||||
'[Outro]'
|
||||
] as const
|
||||
|
||||
export const DEFAULT_MUSIC_TAGS = 'modern synthpop, 120 bpm, female vocals, punchy bass, analog synths'
|
||||
|
||||
export const DEFAULT_MUSIC_LYRICS = `[Verse 1]
|
||||
|
||||
[Chorus]
|
||||
|
||||
[Guitar Solo]
|
||||
|
||||
[Outro]
|
||||
`
|
||||
|
||||
export type MusicEngine = 'ace-step' | 'ace-step-1.5'
|
||||
|
||||
export function parseMusicEngine(raw: unknown): MusicEngine {
|
||||
const value = String(raw || '').trim().toLowerCase()
|
||||
if (value === 'ace-step-1.5' || value === 'ace15' || value === 'v1.5') return 'ace-step-1.5'
|
||||
return 'ace-step'
|
||||
}
|
||||
|
||||
export function clampMusicDuration(raw: unknown, fallback = MUSIC_DURATION_DEFAULT) {
|
||||
const value = Number(raw)
|
||||
if (!Number.isFinite(value)) return fallback
|
||||
return Math.min(MUSIC_DURATION_MAX, Math.max(MUSIC_DURATION_MIN, Math.round(value)))
|
||||
}
|
||||
|
||||
export function clampMusicSteps(raw: unknown, fallback = MUSIC_STEPS_DEFAULT) {
|
||||
const value = Number(raw)
|
||||
if (!Number.isFinite(value)) return fallback
|
||||
return Math.min(MUSIC_STEPS_MAX, Math.max(MUSIC_STEPS_MIN, Math.round(value)))
|
||||
}
|
||||
|
||||
export function clampMusicCfg(raw: unknown, fallback = MUSIC_CFG_DEFAULT) {
|
||||
const value = Number(raw)
|
||||
if (!Number.isFinite(value)) return fallback
|
||||
return Math.min(15, Math.max(1, Math.round(value * 10) / 10))
|
||||
}
|
||||
|
||||
export function clampLyricsStrength(raw: unknown, fallback = MUSIC_LYRICS_STRENGTH_DEFAULT) {
|
||||
const value = Number(raw)
|
||||
if (!Number.isFinite(value)) return fallback
|
||||
return Math.min(2, Math.max(0, Math.round(value * 100) / 100))
|
||||
}
|
||||
|
||||
export function sanitizeMusicTags(raw: unknown) {
|
||||
return String(raw || '').replace(/\r\n/g, '\n').trim().slice(0, MUSIC_TAGS_MAX)
|
||||
}
|
||||
|
||||
export function sanitizeMusicLyrics(raw: unknown, instrumental = false) {
|
||||
if (instrumental) return ''
|
||||
return String(raw || '').replace(/\r\n/g, '\n').trim().slice(0, MUSIC_LYRICS_MAX)
|
||||
}
|
||||
|
||||
export function parseMusicSeed(raw: unknown) {
|
||||
const text = String(raw ?? '').trim().toLowerCase()
|
||||
if (!text || text === 'random') return Math.floor(Math.random() * 2_147_483_647)
|
||||
const value = Number(text)
|
||||
if (!Number.isFinite(value)) return Math.floor(Math.random() * 2_147_483_647)
|
||||
return Math.max(0, Math.min(2_147_483_647, Math.floor(value)))
|
||||
}
|
||||
|
||||
export function musicContentType(ext: string) {
|
||||
const kind = String(ext || '').replace(/^\./, '').toLowerCase()
|
||||
if (kind === 'wav') return 'audio/wav'
|
||||
if (kind === 'flac') return 'audio/flac'
|
||||
if (kind === 'mp3') return 'audio/mpeg'
|
||||
if (kind === 'ogg' || kind === 'opus') return 'audio/ogg'
|
||||
return 'application/octet-stream'
|
||||
}
|
||||
|
||||
export function musicExtension(filename: string) {
|
||||
const match = String(filename || '').toLowerCase().match(/\.(wav|flac|mp3|ogg|opus)$/)
|
||||
return match ? match[1] : 'flac'
|
||||
}
|
||||
@@ -77,6 +77,9 @@ export function engineFilenameKey(engine: string) {
|
||||
if (value === 'klein') return 'klein'
|
||||
if (value === 'ltx') return 'ltx'
|
||||
if (value === 'minimax') return 'minimax'
|
||||
if (value === 'ace' || value === 'ace-step') return 'ace'
|
||||
if (value === 'ace15' || value === 'ace-step-1.5') return 'ace15'
|
||||
if (value === 'yue') return 'yue'
|
||||
return sanitizeFilenameKey(value, 16) || 'gen'
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user