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"
|
role="menuitem"
|
||||||
@click="menuOpen = false"
|
@click="menuOpen = false"
|
||||||
>Director</NuxtLink>
|
>Director</NuxtLink>
|
||||||
|
<NuxtLink
|
||||||
|
to="/music"
|
||||||
|
class="block rounded-xl px-3 py-2 text-sm hover:bg-white/5 hover:text-white"
|
||||||
|
:class="current === 'music' ? 'text-amber-100' : 'text-zinc-200'"
|
||||||
|
role="menuitem"
|
||||||
|
@click="menuOpen = false"
|
||||||
|
>Music</NuxtLink>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="block w-full rounded-xl px-3 py-2 text-left text-sm hover:bg-white/5 disabled:cursor-not-allowed disabled:opacity-50"
|
class="block w-full rounded-xl px-3 py-2 text-left text-sm hover:bg-white/5 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
@@ -116,7 +123,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
withDefaults(defineProps<{
|
withDefaults(defineProps<{
|
||||||
subtitle: string
|
subtitle: string
|
||||||
current?: 'studio' | 'library' | 'director' | 'queue' | 'settings'
|
current?: 'studio' | 'library' | 'director' | 'queue' | 'settings' | 'music'
|
||||||
wide?: boolean
|
wide?: boolean
|
||||||
queueCount?: number
|
queueCount?: number
|
||||||
}>(), {
|
}>(), {
|
||||||
|
|||||||
@@ -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',
|
backupIngestToken: process.env.BACKUP_INGEST_TOKEN || 'carr-aigen-xaigen-backup',
|
||||||
purgeComfyOutputs: process.env.PURGE_COMFY_OUTPUTS !== 'false',
|
purgeComfyOutputs: process.env.PURGE_COMFY_OUTPUTS !== 'false',
|
||||||
comfyFilenamePrefix: process.env.COMFY_FILENAME_PREFIX || 'video/MiniMax_H3',
|
comfyFilenamePrefix: process.env.COMFY_FILENAME_PREFIX || 'video/MiniMax_H3',
|
||||||
|
comfyAudioPrefix: process.env.COMFY_AUDIO_PREFIX || 'audio/AIGen',
|
||||||
kreaConceptLora: process.env.NUXT_KREA_CONCEPT_LORA || '',
|
kreaConceptLora: process.env.NUXT_KREA_CONCEPT_LORA || '',
|
||||||
public: {
|
public: {
|
||||||
authEnabled: process.env.NUXT_PUBLIC_AUTH_ENABLED === 'true',
|
authEnabled: process.env.NUXT_PUBLIC_AUTH_ENABLED === 'true',
|
||||||
|
|||||||
+12
-1
@@ -10,7 +10,7 @@
|
|||||||
<main class="mx-auto grid min-w-0 max-w-7xl gap-6 px-4 py-6 sm:px-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:items-stretch">
|
<main class="mx-auto grid min-w-0 max-w-7xl gap-6 px-4 py-6 sm:px-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:items-stretch">
|
||||||
<section id="studio-input" class="glass min-w-0 max-w-full space-y-5 rounded-3xl p-5 sm:p-6">
|
<section id="studio-input" class="glass min-w-0 max-w-full space-y-5 rounded-3xl p-5 sm:p-6">
|
||||||
<div>
|
<div>
|
||||||
<div class="mb-4 grid grid-cols-2 gap-3">
|
<div class="mb-4 grid grid-cols-3 gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-3xl border px-4 py-4 text-left transition"
|
class="rounded-3xl border px-4 py-4 text-left transition"
|
||||||
@@ -39,6 +39,17 @@
|
|||||||
<span class="mt-3 block font-display text-lg font-bold text-zinc-100">Image</span>
|
<span class="mt-3 block font-display text-lg font-bold text-zinc-100">Image</span>
|
||||||
<span class="mt-0.5 block text-xs text-zinc-500">Flux · Krea</span>
|
<span class="mt-0.5 block text-xs text-zinc-500">Flux · Krea</span>
|
||||||
</button>
|
</button>
|
||||||
|
<NuxtLink
|
||||||
|
to="/music"
|
||||||
|
class="rounded-3xl border border-white/10 px-4 py-4 text-left text-zinc-400 transition hover:border-white/20 hover:text-zinc-200"
|
||||||
|
>
|
||||||
|
<svg class="h-10 w-10" viewBox="0 0 40 40" fill="none" aria-hidden="true">
|
||||||
|
<circle cx="20" cy="20" r="12" stroke="currentColor" stroke-width="2" />
|
||||||
|
<path d="M18 14v12l10-4V12l-10 2Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
<span class="mt-3 block font-display text-lg font-bold text-zinc-100">Music</span>
|
||||||
|
<span class="mt-0.5 block text-xs text-zinc-500">ACE-Step · vocals</span>
|
||||||
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
<h2 class="font-display text-2xl font-bold">Input</h2>
|
<h2 class="font-display text-2xl font-bold">Input</h2>
|
||||||
<p class="text-sm text-zinc-400">{{ studioMode === 'editv2'
|
<p class="text-sm text-zinc-400">{{ studioMode === 'editv2'
|
||||||
|
|||||||
+85
-17
@@ -161,6 +161,18 @@
|
|||||||
>
|
>
|
||||||
<div class="relative aspect-[4/5] bg-zinc-900">
|
<div class="relative aspect-[4/5] bg-zinc-900">
|
||||||
<span v-if="thumbHidden(entry)" class="sensitive-glow block h-full w-full" />
|
<span v-if="thumbHidden(entry)" class="sensitive-glow block h-full w-full" />
|
||||||
|
<div
|
||||||
|
v-else-if="entry.kind === 'track'"
|
||||||
|
class="flex h-full w-full items-end gap-0.5 bg-zinc-900 px-4 py-8"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-for="n in 16"
|
||||||
|
:key="n"
|
||||||
|
class="flex-1 rounded-full bg-amber-400/70"
|
||||||
|
:style="{ height: `${20 + ((n * 17) % 60)}%` }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<img
|
<img
|
||||||
v-else-if="thumbSrc(entry)"
|
v-else-if="thumbSrc(entry)"
|
||||||
:src="thumbSrc(entry)"
|
:src="thumbSrc(entry)"
|
||||||
@@ -171,7 +183,7 @@
|
|||||||
class="h-full w-full object-cover"
|
class="h-full w-full object-cover"
|
||||||
>
|
>
|
||||||
<span v-else class="block h-full w-full bg-zinc-800" />
|
<span v-else class="block h-full w-full bg-zinc-800" />
|
||||||
<span class="absolute left-2 top-2 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-zinc-200">{{ entry.kind === 'clip' ? 'Video' : 'Image' }}</span>
|
<span class="absolute left-2 top-2 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-zinc-200">{{ entry.kind === 'clip' ? 'Video' : entry.kind === 'track' ? 'Music' : 'Image' }}</span>
|
||||||
<span
|
<span
|
||||||
v-if="partCount(entry) > 1"
|
v-if="partCount(entry) > 1"
|
||||||
class="absolute right-2 top-2 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] text-amber-100"
|
class="absolute right-2 top-2 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] text-amber-100"
|
||||||
@@ -235,6 +247,9 @@
|
|||||||
controls
|
controls
|
||||||
playsinline
|
playsinline
|
||||||
/>
|
/>
|
||||||
|
<div v-else-if="focused.kind === 'track' && previewAudio" class="p-4">
|
||||||
|
<AudioPlayer :src="previewAudio" :download-name="`${focused.name}.${focused.track?.ext || 'flac'}`" />
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
v-else-if="focused.kind === 'still' && previewStillSrc"
|
v-else-if="focused.kind === 'still' && previewStillSrc"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -263,6 +278,7 @@
|
|||||||
>
|
>
|
||||||
</button>
|
</button>
|
||||||
<FamilyNav
|
<FamilyNav
|
||||||
|
v-if="focused.kind !== 'track'"
|
||||||
:count="focusedNavParts.length"
|
:count="focusedNavParts.length"
|
||||||
:index="focusedNavIndex"
|
:index="focusedNavIndex"
|
||||||
:prev-label="focused?.kind === 'clip' ? 'Previous clip' : 'Previous image'"
|
:prev-label="focused?.kind === 'clip' ? 'Previous clip' : 'Previous image'"
|
||||||
@@ -270,7 +286,7 @@
|
|||||||
@step="stepFocusedFamily"
|
@step="stepFocusedFamily"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
v-if="showPrivacyToggles"
|
v-if="showPrivacyToggles && focused.kind !== 'track'"
|
||||||
type="button"
|
type="button"
|
||||||
class="absolute right-2 top-2 z-30 flex h-8 w-8 items-center justify-center rounded-full border border-white/20 bg-black/60 text-zinc-100 hover:border-amber-300/50 hover:text-amber-100"
|
class="absolute right-2 top-2 z-30 flex h-8 w-8 items-center justify-center rounded-full border border-white/20 bg-black/60 text-zinc-100 hover:border-amber-300/50 hover:text-amber-100"
|
||||||
:title="detailsHidden ? 'Show preview' : 'Hide preview'"
|
:title="detailsHidden ? 'Show preview' : 'Hide preview'"
|
||||||
@@ -300,8 +316,8 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3 flex flex-wrap gap-2">
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
<button type="button" class="rounded-full border border-amber-300/40 px-3 py-1.5 text-xs text-amber-100" @click="openInStudio(focused)">Open in Studio</button>
|
<button type="button" class="rounded-full border border-amber-300/40 px-3 py-1.5 text-xs text-amber-100" @click="openInStudio(focused)">{{ focused.kind === 'track' ? 'Open in Music' : 'Open in Studio' }}</button>
|
||||||
<button type="button" class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 hover:text-white" @click="useInStudio(focused)">Use as input</button>
|
<button v-if="focused.kind !== 'track'" type="button" class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 hover:text-white" @click="useInStudio(focused)">Use as input</button>
|
||||||
<button
|
<button
|
||||||
v-if="focused.kind === 'still' && focusedStillRole !== 'input'"
|
v-if="focused.kind === 'still' && focusedStillRole !== 'input'"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -323,7 +339,7 @@
|
|||||||
class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 hover:text-white"
|
class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 hover:text-white"
|
||||||
>Download</a>
|
>Download</a>
|
||||||
<button
|
<button
|
||||||
v-if="!detailsHidden && (previewVideo || previewStillSrc || thumbSrc(focused))"
|
v-if="!detailsHidden && focused.kind !== 'track' && (previewVideo || previewStillSrc || thumbSrc(focused))"
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 hover:text-white"
|
class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 hover:text-white"
|
||||||
@click="lightboxOpen = true"
|
@click="lightboxOpen = true"
|
||||||
@@ -364,7 +380,7 @@
|
|||||||
|
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<div
|
<div
|
||||||
v-if="lightboxOpen && focused && !detailsHidden"
|
v-if="lightboxOpen && focused && !detailsHidden && focused.kind !== 'track'"
|
||||||
class="fixed inset-0 z-[80] flex items-center justify-center bg-black/85 p-3 sm:p-6"
|
class="fixed inset-0 z-[80] flex items-center justify-center bg-black/85 p-3 sm:p-6"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
@@ -446,10 +462,11 @@ import {
|
|||||||
type LibraryFilter,
|
type LibraryFilter,
|
||||||
type LibraryFolder,
|
type LibraryFolder,
|
||||||
type LibrarySort,
|
type LibrarySort,
|
||||||
type LibraryStill
|
type LibraryStill,
|
||||||
|
type LibraryTrack
|
||||||
} from '~/utils/libraryBrowse'
|
} from '~/utils/libraryBrowse'
|
||||||
import { isXaigenStudio } from '~/utils/videoModels'
|
import { isXaigenStudio } from '~/utils/videoModels'
|
||||||
import { clipSettingsRows, stillSettingsRows } from '~/utils/libraryGeneration'
|
import { clipSettingsRows, stillSettingsRows, trackSettingsRows } from '~/utils/libraryGeneration'
|
||||||
|
|
||||||
const CARD_MIN = 156
|
const CARD_MIN = 156
|
||||||
const GAP = 10
|
const GAP = 10
|
||||||
@@ -459,7 +476,8 @@ const DRAG_MIME = 'application/x-aigen-library'
|
|||||||
const filterOptions = [
|
const filterOptions = [
|
||||||
{ id: 'all' as const, label: 'All' },
|
{ id: 'all' as const, label: 'All' },
|
||||||
{ id: 'images' as const, label: 'Images' },
|
{ id: 'images' as const, label: 'Images' },
|
||||||
{ id: 'videos' as const, label: 'Videos' }
|
{ id: 'videos' as const, label: 'Videos' },
|
||||||
|
{ id: 'music' as const, label: 'Music' }
|
||||||
]
|
]
|
||||||
|
|
||||||
const runtimeConfig = useRuntimeConfig()
|
const runtimeConfig = useRuntimeConfig()
|
||||||
@@ -473,6 +491,7 @@ const showPrivacyToggles = computed(() => isXaigenStudio({
|
|||||||
const folders = ref<LibraryFolder[]>([])
|
const folders = ref<LibraryFolder[]>([])
|
||||||
const clips = ref<LibraryClip[]>([])
|
const clips = ref<LibraryClip[]>([])
|
||||||
const stills = ref<LibraryStill[]>([])
|
const stills = ref<LibraryStill[]>([])
|
||||||
|
const tracks = ref<LibraryTrack[]>([])
|
||||||
const folderId = ref('all')
|
const folderId = ref('all')
|
||||||
const filter = ref<LibraryFilter>('all')
|
const filter = ref<LibraryFilter>('all')
|
||||||
const sort = ref<LibrarySort>('date-desc')
|
const sort = ref<LibrarySort>('date-desc')
|
||||||
@@ -511,6 +530,7 @@ const browseFolder = computed(() => folders.value.find(folder => folder.id === f
|
|||||||
const entries = computed(() => buildLibraryEntries({
|
const entries = computed(() => buildLibraryEntries({
|
||||||
clips: clips.value,
|
clips: clips.value,
|
||||||
stills: stills.value,
|
stills: stills.value,
|
||||||
|
tracks: tracks.value,
|
||||||
folderId: folderId.value,
|
folderId: folderId.value,
|
||||||
filter: filter.value,
|
filter: filter.value,
|
||||||
search: search.value,
|
search: search.value,
|
||||||
@@ -521,6 +541,7 @@ const allCount = computed(() => {
|
|||||||
return buildLibraryEntries({
|
return buildLibraryEntries({
|
||||||
clips: clips.value,
|
clips: clips.value,
|
||||||
stills: stills.value,
|
stills: stills.value,
|
||||||
|
tracks: tracks.value,
|
||||||
folderId: 'all',
|
folderId: 'all',
|
||||||
filter: filter.value,
|
filter: filter.value,
|
||||||
search: '',
|
search: '',
|
||||||
@@ -528,17 +549,21 @@ const allCount = computed(() => {
|
|||||||
}).length
|
}).length
|
||||||
})
|
})
|
||||||
const folderChipCounts = computed(() => {
|
const folderChipCounts = computed(() => {
|
||||||
const map: Record<string, { videos: number; images: number }> = {}
|
const map: Record<string, { videos: number; images: number; music: number }> = {}
|
||||||
for (const clip of clips.value) {
|
for (const clip of clips.value) {
|
||||||
if (clip.hasVideo === false) continue
|
if (clip.hasVideo === false) continue
|
||||||
const row = map[clip.folderId] || (map[clip.folderId] = { videos: 0, images: 0 })
|
const row = map[clip.folderId] || (map[clip.folderId] = { videos: 0, images: 0, music: 0 })
|
||||||
row.videos += 1
|
row.videos += 1
|
||||||
}
|
}
|
||||||
for (const still of stills.value) {
|
for (const still of stills.value) {
|
||||||
if (still.role !== 'output') continue
|
if (still.role !== 'output') continue
|
||||||
const row = map[still.folderId] || (map[still.folderId] = { videos: 0, images: 0 })
|
const row = map[still.folderId] || (map[still.folderId] = { videos: 0, images: 0, music: 0 })
|
||||||
row.images += 1
|
row.images += 1
|
||||||
}
|
}
|
||||||
|
for (const track of tracks.value) {
|
||||||
|
const row = map[track.folderId] || (map[track.folderId] = { videos: 0, images: 0, music: 0 })
|
||||||
|
row.music += 1
|
||||||
|
}
|
||||||
return map
|
return map
|
||||||
})
|
})
|
||||||
const moveFolders = computed(() => folders.value.filter(folder => folder.id !== folderId.value))
|
const moveFolders = computed(() => folders.value.filter(folder => folder.id !== folderId.value))
|
||||||
@@ -552,6 +577,7 @@ const emptyLabel = computed(() => {
|
|||||||
if (search.value.trim()) return 'Nothing matches that search.'
|
if (search.value.trim()) return 'Nothing matches that search.'
|
||||||
if (filter.value === 'images') return 'No generated stills here yet.'
|
if (filter.value === 'images') return 'No generated stills here yet.'
|
||||||
if (filter.value === 'videos') return 'No generated clips here yet.'
|
if (filter.value === 'videos') return 'No generated clips here yet.'
|
||||||
|
if (filter.value === 'music') return 'No generated tracks here yet.'
|
||||||
return 'Nothing in this folder yet.'
|
return 'Nothing in this folder yet.'
|
||||||
})
|
})
|
||||||
const cols = computed(() => Math.max(2, Math.floor((viewportW.value + GAP) / (CARD_MIN + GAP))))
|
const cols = computed(() => Math.max(2, Math.floor((viewportW.value + GAP) / (CARD_MIN + GAP))))
|
||||||
@@ -580,6 +606,10 @@ const previewStillSrc = computed(() => {
|
|||||||
const id = focusedPartId.value || focused.value.stillGroup.head.id
|
const id = focusedPartId.value || focused.value.stillGroup.head.id
|
||||||
return `/api/library/stills/${id}`
|
return `/api/library/stills/${id}`
|
||||||
})
|
})
|
||||||
|
const previewAudio = computed(() => {
|
||||||
|
if (focused.value?.kind !== 'track' || !focused.value.track) return ''
|
||||||
|
return `/api/library/tracks/${focused.value.track.id}/audio`
|
||||||
|
})
|
||||||
const focusedParts = computed(() => {
|
const focusedParts = computed(() => {
|
||||||
if (focused.value?.kind === 'clip') return focused.value.group?.parts || []
|
if (focused.value?.kind === 'clip') return focused.value.group?.parts || []
|
||||||
return focused.value?.stillGroup?.parts || []
|
return focused.value?.stillGroup?.parts || []
|
||||||
@@ -606,6 +636,9 @@ const focusedSettingsRows = computed(() => {
|
|||||||
const item = focused.value.group.parts.find(part => part.id === focusedPartId.value) || focused.value.group.head
|
const item = focused.value.group.parts.find(part => part.id === focusedPartId.value) || focused.value.group.head
|
||||||
return clipSettingsRows(item)
|
return clipSettingsRows(item)
|
||||||
}
|
}
|
||||||
|
if (focused.value?.kind === 'track' && focused.value.track) {
|
||||||
|
return trackSettingsRows(focused.value.track)
|
||||||
|
}
|
||||||
const still = focused.value?.stillGroup?.parts.find(part => part.id === focusedPartId.value) || focused.value?.stillGroup?.head
|
const still = focused.value?.stillGroup?.parts.find(part => part.id === focusedPartId.value) || focused.value?.stillGroup?.head
|
||||||
return still ? stillSettingsRows(still) : []
|
return still ? stillSettingsRows(still) : []
|
||||||
})
|
})
|
||||||
@@ -616,6 +649,9 @@ const detailMeta = computed(() => {
|
|||||||
const item = entry.group.parts.find(part => part.id === focusedPartId.value) || entry.group.head
|
const item = entry.group.parts.find(part => part.id === focusedPartId.value) || entry.group.head
|
||||||
return [item.width && item.height ? `${item.width}×${item.height}` : '', formatClipDuration(item.duration), formatWhen(item.createdAt)].filter(Boolean).join(' · ')
|
return [item.width && item.height ? `${item.width}×${item.height}` : '', formatClipDuration(item.duration), formatWhen(item.createdAt)].filter(Boolean).join(' · ')
|
||||||
}
|
}
|
||||||
|
if (entry.kind === 'track' && entry.track) {
|
||||||
|
return [formatClipDuration(entry.track.duration), entry.track.ext?.toUpperCase(), formatWhen(entry.track.createdAt)].filter(Boolean).join(' · ')
|
||||||
|
}
|
||||||
const still = entry.stillGroup?.parts.find(part => part.id === focusedPartId.value) || entry.stillGroup?.head
|
const still = entry.stillGroup?.parts.find(part => part.id === focusedPartId.value) || entry.stillGroup?.head
|
||||||
if (!still) return ''
|
if (!still) return ''
|
||||||
return [still.width && still.height ? `${still.width}×${still.height}` : 'image', formatWhen(still.createdAt)].filter(Boolean).join(' · ')
|
return [still.width && still.height ? `${still.width}×${still.height}` : 'image', formatWhen(still.createdAt)].filter(Boolean).join(' · ')
|
||||||
@@ -625,6 +661,9 @@ const downloadHref = computed(() => {
|
|||||||
const id = focusedPartId.value || focused.value.group.head.id
|
const id = focusedPartId.value || focused.value.group.head.id
|
||||||
return `/api/library/clips/${id}/video`
|
return `/api/library/clips/${id}/video`
|
||||||
}
|
}
|
||||||
|
if (focused.value?.kind === 'track' && focused.value.track) {
|
||||||
|
return `/api/library/tracks/${focused.value.track.id}/audio`
|
||||||
|
}
|
||||||
if (focused.value?.stillGroup) {
|
if (focused.value?.stillGroup) {
|
||||||
const id = focusedPartId.value || focused.value.stillGroup.head.id
|
const id = focusedPartId.value || focused.value.stillGroup.head.id
|
||||||
return `/api/library/stills/${id}`
|
return `/api/library/stills/${id}`
|
||||||
@@ -648,8 +687,9 @@ function applyLibrary(data: unknown) {
|
|||||||
folders?: LibraryFolder[]
|
folders?: LibraryFolder[]
|
||||||
clips?: LibraryClip[]
|
clips?: LibraryClip[]
|
||||||
stills?: LibraryStill[]
|
stills?: LibraryStill[]
|
||||||
|
tracks?: LibraryTrack[]
|
||||||
}
|
}
|
||||||
if (payload.id && !payload.folders && !payload.clips && !payload.stills) {
|
if (payload.id && !payload.folders && !payload.clips && !payload.stills && !payload.tracks) {
|
||||||
if (typeof payload.hideInput === 'boolean') {
|
if (typeof payload.hideInput === 'boolean') {
|
||||||
const still = stills.value.find(item => item.id === payload.id)
|
const still = stills.value.find(item => item.id === payload.id)
|
||||||
if (still) still.hideInput = payload.hideInput
|
if (still) still.hideInput = payload.hideInput
|
||||||
@@ -663,6 +703,7 @@ function applyLibrary(data: unknown) {
|
|||||||
folders.value = payload.folders || []
|
folders.value = payload.folders || []
|
||||||
clips.value = payload.clips || []
|
clips.value = payload.clips || []
|
||||||
stills.value = payload.stills || []
|
stills.value = payload.stills || []
|
||||||
|
tracks.value = payload.tracks || []
|
||||||
if (folderId.value !== 'all' && !folders.value.some(folder => folder.id === folderId.value)) {
|
if (folderId.value !== 'all' && !folders.value.some(folder => folder.id === folderId.value)) {
|
||||||
folderId.value = folders.value[0]?.id || 'all'
|
folderId.value = folders.value[0]?.id || 'all'
|
||||||
}
|
}
|
||||||
@@ -673,12 +714,13 @@ async function loadLibrary() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function folderChipCount(folder: LibraryFolder) {
|
function folderChipCount(folder: LibraryFolder) {
|
||||||
if (!folder.unlocked) return (folder.clipCount || 0) + (folder.imageCount || 0)
|
if (!folder.unlocked) return (folder.clipCount || 0) + (folder.imageCount || 0) + (folder.trackCount || 0)
|
||||||
const row = folderChipCounts.value[folder.id]
|
const row = folderChipCounts.value[folder.id]
|
||||||
if (!row) return 0
|
if (!row) return 0
|
||||||
if (filter.value === 'videos') return row.videos
|
if (filter.value === 'videos') return row.videos
|
||||||
if (filter.value === 'images') return row.images
|
if (filter.value === 'images') return row.images
|
||||||
return row.videos + row.images
|
if (filter.value === 'music') return row.music
|
||||||
|
return row.videos + row.images + row.music
|
||||||
}
|
}
|
||||||
|
|
||||||
function folderChipClass(id: string) {
|
function folderChipClass(id: string) {
|
||||||
@@ -696,6 +738,7 @@ function cardClass(entry: LibraryEntry) {
|
|||||||
|
|
||||||
function itemHidden(entry: LibraryEntry) {
|
function itemHidden(entry: LibraryEntry) {
|
||||||
if (entry.kind === 'clip') return Boolean(entry.group?.head.hideThumbnail)
|
if (entry.kind === 'clip') return Boolean(entry.group?.head.hideThumbnail)
|
||||||
|
if (entry.kind === 'track') return false
|
||||||
return Boolean(entry.stillGroup?.head.hideInput)
|
return Boolean(entry.stillGroup?.head.hideInput)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -751,11 +794,13 @@ async function toggleFocusedHidden() {
|
|||||||
|
|
||||||
function thumbSrc(entry: LibraryEntry) {
|
function thumbSrc(entry: LibraryEntry) {
|
||||||
if (entry.kind === 'clip' && entry.group) return `/api/library/clips/${entry.group.head.id}/thumb`
|
if (entry.kind === 'clip' && entry.group) return `/api/library/clips/${entry.group.head.id}/thumb`
|
||||||
|
if (entry.kind === 'track') return ''
|
||||||
if (entry.stillGroup) return `/api/library/stills/${entry.stillGroup.head.id}?thumb=1`
|
if (entry.stillGroup) return `/api/library/stills/${entry.stillGroup.head.id}?thumb=1`
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function partCount(entry: LibraryEntry) {
|
function partCount(entry: LibraryEntry) {
|
||||||
|
if (entry.kind === 'track') return 1
|
||||||
return entry.kind === 'clip' ? (entry.group?.parts.length || 1) : (entry.stillGroup?.parts.length || 1)
|
return entry.kind === 'clip' ? (entry.group?.parts.length || 1) : (entry.stillGroup?.parts.length || 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -764,6 +809,9 @@ function cardMeta(entry: LibraryEntry) {
|
|||||||
const branches = familyBranchHint(entry.group.parts)
|
const branches = familyBranchHint(entry.group.parts)
|
||||||
return [formatClipDuration(entry.group.head.duration), formatWhen(entry.createdAt), branches].filter(Boolean).join(' · ')
|
return [formatClipDuration(entry.group.head.duration), formatWhen(entry.createdAt), branches].filter(Boolean).join(' · ')
|
||||||
}
|
}
|
||||||
|
if (entry.kind === 'track' && entry.track) {
|
||||||
|
return [formatClipDuration(entry.track.duration), formatWhen(entry.createdAt)].filter(Boolean).join(' · ')
|
||||||
|
}
|
||||||
return formatWhen(entry.createdAt)
|
return formatWhen(entry.createdAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -847,7 +895,11 @@ function onCardClick(entry: LibraryEntry, event: MouseEvent) {
|
|||||||
}
|
}
|
||||||
const sameCard = focusedKey.value === entry.key
|
const sameCard = focusedKey.value === entry.key
|
||||||
focusedKey.value = entry.key
|
focusedKey.value = entry.key
|
||||||
focusedPartId.value = entry.kind === 'clip' ? (entry.group?.head.id || '') : (entry.stillGroup?.head.id || '')
|
focusedPartId.value = entry.kind === 'clip'
|
||||||
|
? (entry.group?.head.id || '')
|
||||||
|
: entry.kind === 'track'
|
||||||
|
? (entry.track?.id || '')
|
||||||
|
: (entry.stillGroup?.head.id || '')
|
||||||
if (!sameCard) detailsHidden.value = itemHidden(entry) && !revealAll.value
|
if (!sameCard) detailsHidden.value = itemHidden(entry) && !revealAll.value
|
||||||
cancelRename()
|
cancelRename()
|
||||||
}
|
}
|
||||||
@@ -886,7 +938,11 @@ function selectedEntries(keys = selectedKeys.value) {
|
|||||||
|
|
||||||
async function moveKeys(keys: string[], destId: string) {
|
async function moveKeys(keys: string[], destId: string) {
|
||||||
const items = selectedEntries(keys).filter((entry) => {
|
const items = selectedEntries(keys).filter((entry) => {
|
||||||
const current = entry.kind === 'clip' ? entry.group?.head.folderId : entry.stillGroup?.head.folderId
|
const current = entry.kind === 'clip'
|
||||||
|
? entry.group?.head.folderId
|
||||||
|
: entry.kind === 'track'
|
||||||
|
? entry.track?.folderId
|
||||||
|
: entry.stillGroup?.head.folderId
|
||||||
return current && current !== destId
|
return current && current !== destId
|
||||||
})
|
})
|
||||||
if (!items.length) return
|
if (!items.length) return
|
||||||
@@ -895,6 +951,9 @@ async function moveKeys(keys: string[], destId: string) {
|
|||||||
if (entry.kind === 'clip') {
|
if (entry.kind === 'clip') {
|
||||||
return $fetch(`/api/library/clips/${entry.group!.head.id}`, { method: 'PATCH', body: { folderId: destId, family: true } })
|
return $fetch(`/api/library/clips/${entry.group!.head.id}`, { method: 'PATCH', body: { folderId: destId, family: true } })
|
||||||
}
|
}
|
||||||
|
if (entry.kind === 'track') {
|
||||||
|
return $fetch(`/api/library/tracks/${entry.track!.id}`, { method: 'PATCH', body: { folderId: destId } })
|
||||||
|
}
|
||||||
return $fetch(`/api/library/stills/${entry.stillGroup!.head.id}`, { method: 'PATCH', body: { folderId: destId, family: true } })
|
return $fetch(`/api/library/stills/${entry.stillGroup!.head.id}`, { method: 'PATCH', body: { folderId: destId, family: true } })
|
||||||
}))
|
}))
|
||||||
await loadLibrary()
|
await loadLibrary()
|
||||||
@@ -923,6 +982,10 @@ function openInStudio(entry: LibraryEntry) {
|
|||||||
navigateTo({ path: '/', query: { clip: focusedPartId.value || entry.group.head.id } })
|
navigateTo({ path: '/', query: { clip: focusedPartId.value || entry.group.head.id } })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (entry.kind === 'track' && entry.track) {
|
||||||
|
navigateTo({ path: '/music', query: { track: entry.track.id } })
|
||||||
|
return
|
||||||
|
}
|
||||||
if (entry.stillGroup) {
|
if (entry.stillGroup) {
|
||||||
navigateTo({ path: '/', query: { still: focusedPartId.value || entry.stillGroup.head.id } })
|
navigateTo({ path: '/', query: { still: focusedPartId.value || entry.stillGroup.head.id } })
|
||||||
}
|
}
|
||||||
@@ -996,6 +1059,8 @@ async function saveRename() {
|
|||||||
try {
|
try {
|
||||||
if (entry.kind === 'clip' && entry.group) {
|
if (entry.kind === 'clip' && entry.group) {
|
||||||
applyLibrary(await $fetch(`/api/library/clips/${entry.group.head.id}`, { method: 'PATCH', body: { name, family: true } }))
|
applyLibrary(await $fetch(`/api/library/clips/${entry.group.head.id}`, { method: 'PATCH', body: { name, family: true } }))
|
||||||
|
} else if (entry.kind === 'track' && entry.track) {
|
||||||
|
applyLibrary(await $fetch(`/api/library/tracks/${entry.track.id}`, { method: 'PATCH', body: { name } }))
|
||||||
} else if (entry.stillGroup) {
|
} else if (entry.stillGroup) {
|
||||||
applyLibrary(await $fetch(`/api/library/stills/${entry.stillGroup.head.id}`, { method: 'PATCH', body: { name, family: true } }))
|
applyLibrary(await $fetch(`/api/library/stills/${entry.stillGroup.head.id}`, { method: 'PATCH', body: { name, family: true } }))
|
||||||
}
|
}
|
||||||
@@ -1038,6 +1103,9 @@ async function confirmDelete() {
|
|||||||
if (entry.kind === 'clip') {
|
if (entry.kind === 'clip') {
|
||||||
return $fetch(`/api/library/clips/${entry.group!.head.id}`, { method: 'DELETE', query: { family: '1' } })
|
return $fetch(`/api/library/clips/${entry.group!.head.id}`, { method: 'DELETE', query: { family: '1' } })
|
||||||
}
|
}
|
||||||
|
if (entry.kind === 'track') {
|
||||||
|
return $fetch(`/api/library/tracks/${entry.track!.id}`, { method: 'DELETE' })
|
||||||
|
}
|
||||||
return $fetch(`/api/library/stills/${entry.stillGroup!.head.id}`, { method: 'DELETE', query: { family: '1' } })
|
return $fetch(`/api/library/stills/${entry.stillGroup!.head.id}`, { method: 'DELETE', query: { family: '1' } })
|
||||||
}))
|
}))
|
||||||
if (pending.keys.includes(focusedKey.value)) {
|
if (pending.keys.includes(focusedKey.value)) {
|
||||||
|
|||||||
+344
@@ -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()
|
const live = listJobs()
|
||||||
.filter(job => job.library?.ownerKey === owner && jobLooksLive(job, comfyBusy))
|
.filter(job => job.library?.ownerKey === owner && jobLooksLive(job, comfyBusy))
|
||||||
.sort((a, b) => b.startedAt - a.startedAt)
|
.sort((a, b) => b.startedAt - a.startedAt)
|
||||||
const videoLive = live.find(job => job.kind !== 'edit')
|
const videoLive = live.find(job => job.kind !== 'edit' && job.kind !== 'music')
|
||||||
const editLive = live.find(job => job.kind === 'edit')
|
const editLive = live.find(job => job.kind === 'edit')
|
||||||
|
const musicLive = live.find(job => job.kind === 'music')
|
||||||
|
|
||||||
let video = videoLive ? livePayload(videoLive) : null
|
let video = videoLive ? livePayload(videoLive) : null
|
||||||
const edit = editLive ? livePayload(editLive) : null
|
const edit = editLive ? livePayload(editLive) : null
|
||||||
|
const music = musicLive ? livePayload(musicLive) : null
|
||||||
|
|
||||||
if (!video) {
|
if (!video) {
|
||||||
const pending = listPendingJobs()
|
const pending = listPendingJobs()
|
||||||
@@ -70,8 +72,9 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
jobId: video?.jobId || edit?.jobId || null,
|
jobId: video?.jobId || edit?.jobId || music?.jobId || null,
|
||||||
video,
|
video,
|
||||||
edit
|
edit,
|
||||||
|
music
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 params = new URLSearchParams({ filename, subfolder, type })
|
||||||
const res = await comfyFetch(`/view?${params.toString()}`)
|
const res = await comfyFetch(`/view?${params.toString()}`)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw createError({ statusCode: res.status, statusMessage: 'Failed to fetch video from ComfyUI' })
|
throw createError({ statusCode: res.status, statusMessage: 'Failed to fetch media from ComfyUI' })
|
||||||
}
|
}
|
||||||
const buf = Buffer.from(await res.arrayBuffer())
|
const buf = Buffer.from(await res.arrayBuffer())
|
||||||
return sendBufferWithRange(event, buf, res.headers.get('content-type') || 'video/mp4', {
|
const lower = filename.toLowerCase()
|
||||||
|
const fallbackType = lower.endsWith('.wav')
|
||||||
|
? 'audio/wav'
|
||||||
|
: lower.endsWith('.flac')
|
||||||
|
? 'audio/flac'
|
||||||
|
: lower.endsWith('.mp3')
|
||||||
|
? 'audio/mpeg'
|
||||||
|
: lower.endsWith('.ogg') || lower.endsWith('.opus')
|
||||||
|
? 'audio/ogg'
|
||||||
|
: 'video/mp4'
|
||||||
|
return sendBufferWithRange(event, buf, res.headers.get('content-type') || fallbackType, {
|
||||||
'Content-Disposition': `inline; filename="${filename}"`,
|
'Content-Disposition': `inline; filename="${filename}"`,
|
||||||
'Cache-Control': 'private, max-age=0, must-revalidate'
|
'Cache-Control': 'private, max-age=0, must-revalidate'
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAudioFile(item: { filename?: string; format?: string } | null | undefined) {
|
||||||
|
if (!item) return false
|
||||||
|
const name = String(item.filename || '').toLowerCase()
|
||||||
|
const format = String(item.format || '').toLowerCase()
|
||||||
|
return name.endsWith('.wav') || name.endsWith('.flac') || name.endsWith('.mp3') || name.endsWith('.ogg') || name.endsWith('.opus')
|
||||||
|
|| format.includes('audio') || format.includes('wav') || format.includes('flac')
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAudio(item: { filename?: string; subfolder?: string; type?: string }) {
|
||||||
|
return {
|
||||||
|
filename: String(item.filename),
|
||||||
|
subfolder: String(item.subfolder || ''),
|
||||||
|
type: String(item.type || 'output')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findAudio(value: unknown, depth = 0): { filename: string; subfolder: string; type: string } | null {
|
||||||
|
if (!value || typeof value !== 'object' || depth > 8) return null
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const files = value.filter((item): item is { filename?: string; format?: string; subfolder?: string; type?: string } => Boolean(item && typeof item === 'object'))
|
||||||
|
const match = files.find(item => {
|
||||||
|
const name = String(item.filename || '').toLowerCase()
|
||||||
|
return name.endsWith('.flac') || name.endsWith('.wav')
|
||||||
|
}) || files.find(isAudioFile)
|
||||||
|
if (match?.filename) return normalizeAudio(match)
|
||||||
|
for (const item of value) {
|
||||||
|
const nested = findAudio(item, depth + 1)
|
||||||
|
if (nested) return nested
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const record = value as { filename?: string; format?: string; subfolder?: string; type?: string }
|
||||||
|
if (record.filename && isAudioFile(record)) return normalizeAudio(record)
|
||||||
|
for (const nested of Object.values(value as Record<string, unknown>)) {
|
||||||
|
const found = findAudio(nested, depth + 1)
|
||||||
|
if (found) return found
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractAudio(history: Record<string, unknown> | null, promptId: string) {
|
||||||
|
if (!history) return null
|
||||||
|
const wrapped = history[promptId] as { outputs?: Record<string, unknown> } | undefined
|
||||||
|
if (wrapped) {
|
||||||
|
return findAudio(wrapped.outputs || {}) || findAudio(wrapped)
|
||||||
|
}
|
||||||
|
if ((history as { outputs?: unknown }).outputs) {
|
||||||
|
return findAudio((history as { outputs?: unknown }).outputs) || findAudio(history)
|
||||||
|
}
|
||||||
|
return findAudio(history)
|
||||||
|
}
|
||||||
|
|
||||||
export function extractVideo(history: Record<string, unknown> | null, promptId: string) {
|
export function extractVideo(history: Record<string, unknown> | null, promptId: string) {
|
||||||
if (!history) return null
|
if (!history) return null
|
||||||
const wrapped = history[promptId] as { outputs?: Record<string, unknown> } | undefined
|
const wrapped = history[promptId] as { outputs?: Record<string, unknown> } | undefined
|
||||||
|
|||||||
+17
-5
@@ -12,6 +12,7 @@ export interface JobEvent {
|
|||||||
mediaType?: string
|
mediaType?: string
|
||||||
clipId?: string
|
clipId?: string
|
||||||
stillId?: string
|
stillId?: string
|
||||||
|
trackId?: string
|
||||||
hideThumbnail?: boolean
|
hideThumbnail?: boolean
|
||||||
error?: string
|
error?: string
|
||||||
elapsedMs?: number
|
elapsedMs?: number
|
||||||
@@ -31,7 +32,7 @@ export interface JobEvent {
|
|||||||
|
|
||||||
export interface Job {
|
export interface Job {
|
||||||
id: string
|
id: string
|
||||||
kind?: 'video' | 'edit'
|
kind?: 'video' | 'edit' | 'music'
|
||||||
promptId?: string
|
promptId?: string
|
||||||
clientId: string
|
clientId: string
|
||||||
status: JobStatus
|
status: JobStatus
|
||||||
@@ -41,8 +42,10 @@ export interface Job {
|
|||||||
maxStep: number
|
maxStep: number
|
||||||
startedAt: number
|
startedAt: number
|
||||||
video?: { filename: string; subfolder: string; type: string }
|
video?: { filename: string; subfolder: string; type: string }
|
||||||
|
audio?: { filename: string; subfolder: string; type: string }
|
||||||
clipId?: string
|
clipId?: string
|
||||||
stillId?: string
|
stillId?: string
|
||||||
|
trackId?: string
|
||||||
hideThumbnail?: boolean
|
hideThumbnail?: boolean
|
||||||
imageComfyHost?: string
|
imageComfyHost?: string
|
||||||
library?: {
|
library?: {
|
||||||
@@ -102,6 +105,12 @@ export interface Job {
|
|||||||
loraStack?: import('~/utils/loras').LoraStackItem[]
|
loraStack?: import('~/utils/loras').LoraStackItem[]
|
||||||
shotLoras?: string[]
|
shotLoras?: string[]
|
||||||
shotLoraStacks?: import('~/utils/loras').LoraStackItem[][]
|
shotLoraStacks?: import('~/utils/loras').LoraStackItem[][]
|
||||||
|
tags?: string
|
||||||
|
lyrics?: string
|
||||||
|
instrumental?: boolean
|
||||||
|
audioExt?: string
|
||||||
|
engine?: string
|
||||||
|
lyricsStrength?: number
|
||||||
}
|
}
|
||||||
error?: string
|
error?: string
|
||||||
socketReady?: boolean
|
socketReady?: boolean
|
||||||
@@ -115,9 +124,10 @@ export interface Job {
|
|||||||
const jobs = new Map<string, Job>()
|
const jobs = new Map<string, Job>()
|
||||||
const MAX_JOBS = 40
|
const MAX_JOBS = 40
|
||||||
|
|
||||||
export function createJob(): Job {
|
export function createJob(kind: Job['kind'] = 'video'): Job {
|
||||||
const job: Job = {
|
const job: Job = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
|
kind,
|
||||||
clientId: crypto.randomUUID(),
|
clientId: crypto.randomUUID(),
|
||||||
status: 'queued',
|
status: 'queued',
|
||||||
message: 'Queued',
|
message: 'Queued',
|
||||||
@@ -175,6 +185,7 @@ export function listJobs() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function jobSnapshot(job: Job) {
|
export function jobSnapshot(job: Job) {
|
||||||
|
const media = job.kind === 'music' ? job.audio : job.video
|
||||||
return {
|
return {
|
||||||
jobId: job.id,
|
jobId: job.id,
|
||||||
kind: job.kind || 'video',
|
kind: job.kind || 'video',
|
||||||
@@ -186,11 +197,12 @@ export function jobSnapshot(job: Job) {
|
|||||||
maxStep: job.maxStep,
|
maxStep: job.maxStep,
|
||||||
promptId: job.promptId,
|
promptId: job.promptId,
|
||||||
elapsedMs: Date.now() - job.startedAt,
|
elapsedMs: Date.now() - job.startedAt,
|
||||||
filename: job.library?.folderLocked ? undefined : job.video?.filename,
|
filename: job.library?.folderLocked ? undefined : media?.filename,
|
||||||
subfolder: job.library?.folderLocked ? undefined : job.video?.subfolder,
|
subfolder: job.library?.folderLocked ? undefined : media?.subfolder,
|
||||||
mediaType: job.library?.folderLocked ? undefined : job.video?.type,
|
mediaType: job.library?.folderLocked ? undefined : media?.type,
|
||||||
clipId: job.clipId,
|
clipId: job.clipId,
|
||||||
stillId: job.stillId,
|
stillId: job.stillId,
|
||||||
|
trackId: job.trackId,
|
||||||
hideThumbnail: job.hideThumbnail,
|
hideThumbnail: job.hideThumbnail,
|
||||||
error: job.error,
|
error: job.error,
|
||||||
folderLocked: job.library?.folderLocked,
|
folderLocked: job.library?.folderLocked,
|
||||||
|
|||||||
+207
-4
@@ -66,6 +66,27 @@ export interface LibraryClip {
|
|||||||
promptPost?: string
|
promptPost?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LibraryTrack {
|
||||||
|
id: string
|
||||||
|
folderId: string
|
||||||
|
name: string
|
||||||
|
tags: string
|
||||||
|
lyrics: string
|
||||||
|
duration: number
|
||||||
|
steps: number
|
||||||
|
seed: number
|
||||||
|
cfg: number
|
||||||
|
instrumental: boolean
|
||||||
|
createdAt: number
|
||||||
|
filename: string
|
||||||
|
ext: string
|
||||||
|
engine?: string
|
||||||
|
samplerName?: string
|
||||||
|
scheduler?: string
|
||||||
|
lyricsStrength?: number
|
||||||
|
comfyFilename?: string
|
||||||
|
}
|
||||||
|
|
||||||
export type StillRole = 'input' | 'output'
|
export type StillRole = 'input' | 'output'
|
||||||
|
|
||||||
export interface LibraryStill {
|
export interface LibraryStill {
|
||||||
@@ -97,6 +118,7 @@ export interface PublicFolder {
|
|||||||
unlocked: boolean
|
unlocked: boolean
|
||||||
clipCount: number
|
clipCount: number
|
||||||
imageCount: number
|
imageCount: number
|
||||||
|
trackCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QueuedExtension {
|
export interface QueuedExtension {
|
||||||
@@ -146,6 +168,7 @@ interface Catalog {
|
|||||||
folders: LibraryFolder[]
|
folders: LibraryFolder[]
|
||||||
clips: LibraryClip[]
|
clips: LibraryClip[]
|
||||||
stills: LibraryStill[]
|
stills: LibraryStill[]
|
||||||
|
tracks: LibraryTrack[]
|
||||||
drafts: RetryDraft[]
|
drafts: RetryDraft[]
|
||||||
loraAliases: Record<string, string>
|
loraAliases: Record<string, string>
|
||||||
loraTriggers: Record<string, string>
|
loraTriggers: Record<string, string>
|
||||||
@@ -188,6 +211,7 @@ function emptyCatalog(): Catalog {
|
|||||||
}],
|
}],
|
||||||
clips: [],
|
clips: [],
|
||||||
stills: [],
|
stills: [],
|
||||||
|
tracks: [],
|
||||||
drafts: [],
|
drafts: [],
|
||||||
loraAliases: {},
|
loraAliases: {},
|
||||||
loraTriggers: {},
|
loraTriggers: {},
|
||||||
@@ -239,6 +263,30 @@ function normalizeStill(still: LibraryStill): LibraryStill {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeTrack(track: LibraryTrack): LibraryTrack {
|
||||||
|
const ext = String(track.ext || track.filename || 'flac').replace(/^\./, '').toLowerCase() || 'flac'
|
||||||
|
return {
|
||||||
|
id: track.id,
|
||||||
|
folderId: track.folderId,
|
||||||
|
name: String(track.name || '').trim() || 'Untitled track',
|
||||||
|
tags: String(track.tags || ''),
|
||||||
|
lyrics: String(track.lyrics || ''),
|
||||||
|
duration: Number(track.duration) || 0,
|
||||||
|
steps: Number(track.steps) || 0,
|
||||||
|
seed: Number(track.seed) || 0,
|
||||||
|
cfg: Number(track.cfg) || 0,
|
||||||
|
instrumental: track.instrumental === true,
|
||||||
|
createdAt: track.createdAt || Date.now(),
|
||||||
|
filename: String(track.filename || `audio.${ext}`),
|
||||||
|
ext,
|
||||||
|
engine: track.engine,
|
||||||
|
samplerName: track.samplerName,
|
||||||
|
scheduler: track.scheduler,
|
||||||
|
lyricsStrength: track.lyricsStrength,
|
||||||
|
comfyFilename: track.comfyFilename
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeCatalog(parsed: Partial<Catalog> & { passwordHash?: string | null }): Catalog {
|
function normalizeCatalog(parsed: Partial<Catalog> & { passwordHash?: string | null }): Catalog {
|
||||||
const folders = Array.isArray(parsed.folders) && parsed.folders.length
|
const folders = Array.isArray(parsed.folders) && parsed.folders.length
|
||||||
? parsed.folders.map(normalizeFolder)
|
? parsed.folders.map(normalizeFolder)
|
||||||
@@ -252,6 +300,7 @@ function normalizeCatalog(parsed: Partial<Catalog> & { passwordHash?: string | n
|
|||||||
folders,
|
folders,
|
||||||
clips: Array.isArray(parsed.clips) ? parsed.clips : [],
|
clips: Array.isArray(parsed.clips) ? parsed.clips : [],
|
||||||
stills: Array.isArray(parsed.stills) ? parsed.stills.map(normalizeStill) : [],
|
stills: Array.isArray(parsed.stills) ? parsed.stills.map(normalizeStill) : [],
|
||||||
|
tracks: Array.isArray(parsed.tracks) ? parsed.tracks.map(normalizeTrack) : [],
|
||||||
drafts: Array.isArray(parsed.drafts) ? parsed.drafts : [],
|
drafts: Array.isArray(parsed.drafts) ? parsed.drafts : [],
|
||||||
loraAliases: normalizeLoraAliases(parsed.loraAliases),
|
loraAliases: normalizeLoraAliases(parsed.loraAliases),
|
||||||
loraTriggers: normalizeLoraTriggers(parsed.loraTriggers),
|
loraTriggers: normalizeLoraTriggers(parsed.loraTriggers),
|
||||||
@@ -284,6 +333,7 @@ function migrateLegacy(owner: string) {
|
|||||||
function ensureOwner(owner: string) {
|
function ensureOwner(owner: string) {
|
||||||
mkdirSync(join(ownerRoot(owner), 'files'), { recursive: true })
|
mkdirSync(join(ownerRoot(owner), 'files'), { recursive: true })
|
||||||
mkdirSync(join(ownerRoot(owner), 'stills'), { recursive: true })
|
mkdirSync(join(ownerRoot(owner), 'stills'), { recursive: true })
|
||||||
|
mkdirSync(join(ownerRoot(owner), 'tracks'), { recursive: true })
|
||||||
migrateLegacy(owner)
|
migrateLegacy(owner)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,7 +442,11 @@ function existingOutputNames(catalog: Catalog, folderId: string) {
|
|||||||
.filter(item => item.folderId === folderId)
|
.filter(item => item.folderId === folderId)
|
||||||
.map(item => String(item.name || '').trim())
|
.map(item => String(item.name || '').trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
return [...stills, ...clips]
|
const tracks = catalog.tracks
|
||||||
|
.filter(item => item.folderId === folderId)
|
||||||
|
.map(item => String(item.name || '').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
return [...stills, ...clips, ...tracks]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLoraAliases(owner: string) {
|
export function getLoraAliases(owner: string) {
|
||||||
@@ -481,7 +535,7 @@ export function folderUnlocked(event: H3Event, folder: LibraryFolder) {
|
|||||||
return folderUnlocks(event)[folder.id] === passwordVersion(folder.passwordHash)
|
return folderUnlocks(event)[folder.id] === passwordVersion(folder.passwordHash)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function publicFolder(event: H3Event, folder: LibraryFolder, clipCount = 0, imageCount = 0): PublicFolder {
|
export function publicFolder(event: H3Event, folder: LibraryFolder, clipCount = 0, imageCount = 0, trackCount = 0): PublicFolder {
|
||||||
return {
|
return {
|
||||||
id: folder.id,
|
id: folder.id,
|
||||||
name: folder.name,
|
name: folder.name,
|
||||||
@@ -490,7 +544,8 @@ export function publicFolder(event: H3Event, folder: LibraryFolder, clipCount =
|
|||||||
protected: Boolean(folder.passwordHash),
|
protected: Boolean(folder.passwordHash),
|
||||||
unlocked: folderUnlocked(event, folder),
|
unlocked: folderUnlocked(event, folder),
|
||||||
clipCount,
|
clipCount,
|
||||||
imageCount
|
imageCount,
|
||||||
|
trackCount
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -501,6 +556,7 @@ export function publicLibrary(event: H3Event, opts?: { light?: boolean }) {
|
|||||||
const clipIds = new Set(catalog.clips.map(clip => clip.id.toLowerCase()))
|
const clipIds = new Set(catalog.clips.map(clip => clip.id.toLowerCase()))
|
||||||
const clipCounts = new Map<string, number>()
|
const clipCounts = new Map<string, number>()
|
||||||
const imageCounts = new Map<string, number>()
|
const imageCounts = new Map<string, number>()
|
||||||
|
const trackCounts = new Map<string, number>()
|
||||||
for (const clip of catalog.clips) {
|
for (const clip of catalog.clips) {
|
||||||
clipCounts.set(clip.folderId, (clipCounts.get(clip.folderId) || 0) + 1)
|
clipCounts.set(clip.folderId, (clipCounts.get(clip.folderId) || 0) + 1)
|
||||||
}
|
}
|
||||||
@@ -508,11 +564,15 @@ export function publicLibrary(event: H3Event, opts?: { light?: boolean }) {
|
|||||||
if (isPipelineStill(catalog, still, clipIds) || stillRole(still) !== 'output') continue
|
if (isPipelineStill(catalog, still, clipIds) || stillRole(still) !== 'output') continue
|
||||||
imageCounts.set(still.folderId, (imageCounts.get(still.folderId) || 0) + 1)
|
imageCounts.set(still.folderId, (imageCounts.get(still.folderId) || 0) + 1)
|
||||||
}
|
}
|
||||||
|
for (const track of catalog.tracks || []) {
|
||||||
|
trackCounts.set(track.folderId, (trackCounts.get(track.folderId) || 0) + 1)
|
||||||
|
}
|
||||||
const folders = catalog.folders.map(folder => publicFolder(
|
const folders = catalog.folders.map(folder => publicFolder(
|
||||||
event,
|
event,
|
||||||
folder,
|
folder,
|
||||||
clipCounts.get(folder.id) || 0,
|
clipCounts.get(folder.id) || 0,
|
||||||
imageCounts.get(folder.id) || 0
|
imageCounts.get(folder.id) || 0,
|
||||||
|
trackCounts.get(folder.id) || 0
|
||||||
))
|
))
|
||||||
const unlockedIds = new Set(folders.filter(folder => folder.unlocked).map(folder => folder.id))
|
const unlockedIds = new Set(folders.filter(folder => folder.unlocked).map(folder => folder.id))
|
||||||
return {
|
return {
|
||||||
@@ -526,6 +586,12 @@ export function publicLibrary(event: H3Event, opts?: { light?: boolean }) {
|
|||||||
stills: catalog.stills
|
stills: catalog.stills
|
||||||
.filter(still => unlockedIds.has(still.folderId) && !isPipelineStill(catalog, still, clipIds))
|
.filter(still => unlockedIds.has(still.folderId) && !isPipelineStill(catalog, still, clipIds))
|
||||||
.map(still => ({ ...still, role: stillRole(still) })),
|
.map(still => ({ ...still, role: stillRole(still) })),
|
||||||
|
tracks: (catalog.tracks || [])
|
||||||
|
.filter(track => unlockedIds.has(track.folderId))
|
||||||
|
.map(track => ({
|
||||||
|
...track,
|
||||||
|
hasAudio: light ? true : existsSync(trackAudioPath(owner, track.id, track.ext))
|
||||||
|
})),
|
||||||
drafts: catalog.drafts,
|
drafts: catalog.drafts,
|
||||||
loraAliases: catalog.loraAliases || {},
|
loraAliases: catalog.loraAliases || {},
|
||||||
loraTriggers: catalog.loraTriggers || {},
|
loraTriggers: catalog.loraTriggers || {},
|
||||||
@@ -599,15 +665,20 @@ export function deleteFolder(owner: string, id: string) {
|
|||||||
if (!folder) throw createError({ statusCode: 404, statusMessage: 'Folder not found' })
|
if (!folder) throw createError({ statusCode: 404, statusMessage: 'Folder not found' })
|
||||||
const clips = catalog.clips.filter(clip => clip.folderId === id)
|
const clips = catalog.clips.filter(clip => clip.folderId === id)
|
||||||
const stills = catalog.stills.filter(still => still.folderId === id)
|
const stills = catalog.stills.filter(still => still.folderId === id)
|
||||||
|
const tracks = (catalog.tracks || []).filter(track => track.folderId === id)
|
||||||
catalog.folders = catalog.folders.filter(item => item.id !== id)
|
catalog.folders = catalog.folders.filter(item => item.id !== id)
|
||||||
catalog.clips = catalog.clips.filter(clip => clip.folderId !== id)
|
catalog.clips = catalog.clips.filter(clip => clip.folderId !== id)
|
||||||
catalog.stills = catalog.stills.filter(still => still.folderId !== id)
|
catalog.stills = catalog.stills.filter(still => still.folderId !== id)
|
||||||
|
catalog.tracks = (catalog.tracks || []).filter(track => track.folderId !== id)
|
||||||
for (const clip of clips) {
|
for (const clip of clips) {
|
||||||
rmSync(clipDir(owner, clip.id), { recursive: true, force: true })
|
rmSync(clipDir(owner, clip.id), { recursive: true, force: true })
|
||||||
}
|
}
|
||||||
for (const still of stills) {
|
for (const still of stills) {
|
||||||
removeStillFiles(owner, still.id)
|
removeStillFiles(owner, still.id)
|
||||||
}
|
}
|
||||||
|
for (const track of tracks) {
|
||||||
|
rmSync(trackDir(owner, track.id), { recursive: true, force: true })
|
||||||
|
}
|
||||||
return { fallbackFolderId: catalog.folders[0].id }
|
return { fallbackFolderId: catalog.folders[0].id }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -695,6 +766,25 @@ export function clipVideoPath(owner: string, id: string) {
|
|||||||
return join(clipDir(owner, id), 'video.mp4')
|
return join(clipDir(owner, id), 'video.mp4')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function trackDir(owner: string, id: string) {
|
||||||
|
const current = join(ownerRoot(owner), 'tracks', id)
|
||||||
|
if (existsSync(current)) return current
|
||||||
|
const legacy = join(libraryRoot(), 'tracks', id)
|
||||||
|
if (existsSync(legacy)) return legacy
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trackAudioPath(owner: string, id: string, ext = 'flac') {
|
||||||
|
const dir = trackDir(owner, id)
|
||||||
|
const wanted = join(dir, `audio.${String(ext || 'flac').replace(/^\./, '')}`)
|
||||||
|
if (existsSync(wanted)) return wanted
|
||||||
|
for (const fallback of ['flac', 'wav', 'mp3', 'ogg', 'opus']) {
|
||||||
|
const path = join(dir, `audio.${fallback}`)
|
||||||
|
if (existsSync(path)) return path
|
||||||
|
}
|
||||||
|
return wanted
|
||||||
|
}
|
||||||
|
|
||||||
export function clipLastFramePath(owner: string, id: string) {
|
export function clipLastFramePath(owner: string, id: string) {
|
||||||
return join(clipDir(owner, id), 'last-frame.png')
|
return join(clipDir(owner, id), 'last-frame.png')
|
||||||
}
|
}
|
||||||
@@ -1653,6 +1743,103 @@ export function getClip(owner: string, id: string) {
|
|||||||
return clip
|
return clip
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getTrack(owner: string, id: string) {
|
||||||
|
const track = readCatalog(owner).tracks.find(item => item.id === id)
|
||||||
|
if (!track) throw createError({ statusCode: 404, statusMessage: 'Track not found' })
|
||||||
|
return track
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveTrack(params: {
|
||||||
|
ownerKey: string
|
||||||
|
folderId: string
|
||||||
|
name?: string
|
||||||
|
tags: string
|
||||||
|
lyrics: string
|
||||||
|
duration: number
|
||||||
|
steps: number
|
||||||
|
seed: number
|
||||||
|
cfg: number
|
||||||
|
instrumental: boolean
|
||||||
|
audio: Buffer
|
||||||
|
ext?: string
|
||||||
|
engine?: string
|
||||||
|
samplerName?: string
|
||||||
|
scheduler?: string
|
||||||
|
lyricsStrength?: number
|
||||||
|
comfyFilename?: string
|
||||||
|
}) {
|
||||||
|
const catalog = readCatalog(params.ownerKey)
|
||||||
|
const folder = catalog.folders.find(item => item.id === params.folderId) || catalog.folders[0]
|
||||||
|
if (!folder) throw createError({ statusCode: 400, statusMessage: 'No library folder available' })
|
||||||
|
const ext = String(params.ext || 'flac').replace(/^\./, '').toLowerCase() || 'flac'
|
||||||
|
const track: LibraryTrack = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
folderId: folder.id,
|
||||||
|
name: clipTitle({ name: params.name || '', prompt: params.tags }),
|
||||||
|
tags: params.tags,
|
||||||
|
lyrics: params.lyrics,
|
||||||
|
duration: params.duration,
|
||||||
|
steps: params.steps,
|
||||||
|
seed: params.seed,
|
||||||
|
cfg: params.cfg,
|
||||||
|
instrumental: params.instrumental === true,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
filename: `audio.${ext}`,
|
||||||
|
ext,
|
||||||
|
engine: params.engine,
|
||||||
|
samplerName: params.samplerName,
|
||||||
|
scheduler: params.scheduler,
|
||||||
|
lyricsStrength: params.lyricsStrength,
|
||||||
|
comfyFilename: params.comfyFilename
|
||||||
|
}
|
||||||
|
mkdirSync(trackDir(params.ownerKey, track.id), { recursive: true })
|
||||||
|
await writeFile(trackAudioPath(params.ownerKey, track.id, ext), params.audio)
|
||||||
|
if (!(typeof params.duration === 'number' && params.duration > 0)) {
|
||||||
|
try {
|
||||||
|
track.duration = await probeDuration(trackAudioPath(params.ownerKey, track.id, ext))
|
||||||
|
} catch {
|
||||||
|
/* duration stays as posted */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await mutate(params.ownerKey, (next) => {
|
||||||
|
if (!Array.isArray(next.tracks)) next.tracks = []
|
||||||
|
next.tracks.unshift(track)
|
||||||
|
})
|
||||||
|
return track
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteTrack(owner: string, id: string) {
|
||||||
|
return mutate(owner, (catalog) => {
|
||||||
|
const track = catalog.tracks.find(item => item.id === id)
|
||||||
|
if (!track) throw createError({ statusCode: 404, statusMessage: 'Track not found' })
|
||||||
|
catalog.tracks = catalog.tracks.filter(item => item.id !== id)
|
||||||
|
rmSync(trackDir(owner, id), { recursive: true, force: true })
|
||||||
|
return track
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moveTrack(owner: string, id: string, folderId: string) {
|
||||||
|
return mutate(owner, (catalog) => {
|
||||||
|
const track = catalog.tracks.find(item => item.id === id)
|
||||||
|
if (!track) throw createError({ statusCode: 404, statusMessage: 'Track not found' })
|
||||||
|
const folder = catalog.folders.find(item => item.id === folderId)
|
||||||
|
if (!folder) throw createError({ statusCode: 404, statusMessage: 'Folder not found' })
|
||||||
|
track.folderId = folderId
|
||||||
|
return track
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renameTrack(owner: string, id: string, name: string) {
|
||||||
|
return mutate(owner, (catalog) => {
|
||||||
|
const track = catalog.tracks.find(item => item.id === id)
|
||||||
|
if (!track) throw createError({ statusCode: 404, statusMessage: 'Track not found' })
|
||||||
|
const cleaned = String(name || '').trim().slice(0, 80)
|
||||||
|
if (!cleaned) throw createError({ statusCode: 400, statusMessage: 'A name is required' })
|
||||||
|
track.name = cleaned
|
||||||
|
return track
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function stampFamilyId(family: LibraryClip[], fallback?: string) {
|
function stampFamilyId(family: LibraryClip[], fallback?: string) {
|
||||||
const familyId = family.find(item => item.familyId)?.familyId || fallback || crypto.randomUUID()
|
const familyId = family.find(item => item.familyId)?.familyId || fallback || crypto.randomUUID()
|
||||||
for (const item of family) item.familyId = familyId
|
for (const item of family) item.familyId = familyId
|
||||||
@@ -1791,6 +1978,22 @@ export async function downloadComfyVideo(video: { filename: string; subfolder: s
|
|||||||
throw new Error(`Failed to fetch completed video from ComfyUI (${lastStatus})`)
|
throw new Error(`Failed to fetch completed video from ComfyUI (${lastStatus})`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function downloadComfyAudio(audio: { filename: string; subfolder: string; type: string }) {
|
||||||
|
const subfolders = [...new Set([audio.subfolder, 'audio', ''])]
|
||||||
|
let lastStatus = 0
|
||||||
|
for (const subfolder of subfolders) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
filename: audio.filename,
|
||||||
|
subfolder,
|
||||||
|
type: audio.type || 'output'
|
||||||
|
})
|
||||||
|
const res = await comfyFetch(`/view?${params.toString()}`)
|
||||||
|
lastStatus = res.status
|
||||||
|
if (res.ok) return Buffer.from(await res.arrayBuffer())
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to fetch completed audio from ComfyUI (${lastStatus})`)
|
||||||
|
}
|
||||||
|
|
||||||
export async function importMissingComfyVideos(owner: string, folderId?: string) {
|
export async function importMissingComfyVideos(owner: string, folderId?: string) {
|
||||||
const catalog = readCatalog(owner)
|
const catalog = readCatalog(owner)
|
||||||
const folder = catalog.folders.find(item => item.id === folderId) || catalog.folders[0]
|
const folder = catalog.folders.find(item => item.id === folderId) || catalog.folders[0]
|
||||||
|
|||||||
@@ -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 workflowV1 from '../assets/workflow_minimax_video.json'
|
||||||
import workflowV2 from '../assets/workflow_minimax_video_v2.json'
|
import workflowV2 from '../assets/workflow_minimax_video_v2.json'
|
||||||
import workflowLtx from '../assets/workflow_ltx_video.json'
|
import workflowLtx from '../assets/workflow_ltx_video.json'
|
||||||
|
import { MUSIC_NODE_LABELS } from '~/server/utils/musicWorkflow'
|
||||||
import { buildIdentityPrompt } from '~/utils/identityPrompt'
|
import { buildIdentityPrompt } from '~/utils/identityPrompt'
|
||||||
import { applyUserLoraToGraph, resolveGraphLoraNames } from '~/server/utils/loras'
|
import { applyUserLoraToGraph, resolveGraphLoraNames } from '~/server/utils/loras'
|
||||||
import type { LoraStackItem } from '~/utils/loras'
|
import type { LoraStackItem } from '~/utils/loras'
|
||||||
@@ -396,6 +397,7 @@ export const NODE_LABELS: Record<string, string> = {
|
|||||||
...Object.fromEntries(labelsFrom(workflowV1 as WorkflowGraph)),
|
...Object.fromEntries(labelsFrom(workflowV1 as WorkflowGraph)),
|
||||||
...Object.fromEntries(labelsFrom(workflowV2 as WorkflowGraph)),
|
...Object.fromEntries(labelsFrom(workflowV2 as WorkflowGraph)),
|
||||||
...Object.fromEntries(labelsFrom(workflowLtx as WorkflowGraph)),
|
...Object.fromEntries(labelsFrom(workflowLtx as WorkflowGraph)),
|
||||||
|
...MUSIC_NODE_LABELS,
|
||||||
...LABEL_OVERRIDES
|
...LABEL_OVERRIDES
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,12 +52,16 @@ export async function ensureGenerationNotifyPermission() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function notifyGenerationComplete(kind: 'video' | 'image' = 'video') {
|
export async function notifyGenerationComplete(kind: 'video' | 'image' | 'music' = 'video') {
|
||||||
if (!generationNotifySupported() || !generationNotifyPref()) return
|
if (!generationNotifySupported() || !generationNotifyPref()) return
|
||||||
if (Notification.permission !== 'granted') return
|
if (Notification.permission !== 'granted') return
|
||||||
if (typeof document !== 'undefined' && !document.hidden && document.hasFocus()) return
|
if (typeof document !== 'undefined' && !document.hidden && document.hasFocus()) return
|
||||||
const title = appName()
|
const title = appName()
|
||||||
const body = kind === 'image' ? 'Image generation is complete.' : 'Generation is complete.'
|
const body = kind === 'image'
|
||||||
|
? 'Image generation is complete.'
|
||||||
|
: kind === 'music'
|
||||||
|
? 'Music generation is complete.'
|
||||||
|
: 'Generation is complete.'
|
||||||
const options: NotificationOptions = {
|
const options: NotificationOptions = {
|
||||||
body,
|
body,
|
||||||
icon: appIcon(),
|
icon: appIcon(),
|
||||||
|
|||||||
+50
-9
@@ -1,7 +1,7 @@
|
|||||||
import type { StillGenerationSettings } from '~/utils/libraryGeneration'
|
import type { StillGenerationSettings } from '~/utils/libraryGeneration'
|
||||||
import type { LoraStackItem } from '~/utils/loras'
|
import type { LoraStackItem } from '~/utils/loras'
|
||||||
|
|
||||||
export type LibraryFilter = 'all' | 'images' | 'videos'
|
export type LibraryFilter = 'all' | 'images' | 'videos' | 'music'
|
||||||
export type LibrarySort = 'date-desc' | 'date-asc' | 'name-asc' | 'name-desc' | 'type'
|
export type LibrarySort = 'date-desc' | 'date-asc' | 'name-asc' | 'name-desc' | 'type'
|
||||||
|
|
||||||
export type LibraryFolder = {
|
export type LibraryFolder = {
|
||||||
@@ -13,6 +13,7 @@ export type LibraryFolder = {
|
|||||||
unlocked: boolean
|
unlocked: boolean
|
||||||
clipCount?: number
|
clipCount?: number
|
||||||
imageCount?: number
|
imageCount?: number
|
||||||
|
trackCount?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LibraryStill = {
|
export type LibraryStill = {
|
||||||
@@ -66,15 +67,37 @@ export type LibraryClip = {
|
|||||||
loraStack?: LoraStackItem[]
|
loraStack?: LoraStackItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type LibraryTrack = {
|
||||||
|
id: string
|
||||||
|
folderId: string
|
||||||
|
name: string
|
||||||
|
tags: string
|
||||||
|
lyrics: string
|
||||||
|
duration: number
|
||||||
|
steps: number
|
||||||
|
seed: number
|
||||||
|
cfg: number
|
||||||
|
instrumental: boolean
|
||||||
|
createdAt: number
|
||||||
|
filename: string
|
||||||
|
ext: string
|
||||||
|
engine?: string
|
||||||
|
samplerName?: string
|
||||||
|
scheduler?: string
|
||||||
|
lyricsStrength?: number
|
||||||
|
hasAudio?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export type ClipGroup = { key: string; head: LibraryClip; parts: LibraryClip[] }
|
export type ClipGroup = { key: string; head: LibraryClip; parts: LibraryClip[] }
|
||||||
export type StillGroup = { key: string; head: LibraryStill; parts: LibraryStill[] }
|
export type StillGroup = { key: string; head: LibraryStill; parts: LibraryStill[] }
|
||||||
export type LibraryEntry = {
|
export type LibraryEntry = {
|
||||||
kind: 'clip' | 'still'
|
kind: 'clip' | 'still' | 'track'
|
||||||
key: string
|
key: string
|
||||||
createdAt: number
|
createdAt: number
|
||||||
name: string
|
name: string
|
||||||
group?: ClipGroup
|
group?: ClipGroup
|
||||||
stillGroup?: StillGroup
|
stillGroup?: StillGroup
|
||||||
|
track?: LibraryTrack
|
||||||
}
|
}
|
||||||
|
|
||||||
export function familyBaseName(name: string) {
|
export function familyBaseName(name: string) {
|
||||||
@@ -248,6 +271,7 @@ export function stillCollectionTitle(group: StillGroup) {
|
|||||||
|
|
||||||
export function entryTitle(entry: LibraryEntry) {
|
export function entryTitle(entry: LibraryEntry) {
|
||||||
if (entry.kind === 'clip' && entry.group) return collectionTitle(entry.group)
|
if (entry.kind === 'clip' && entry.group) return collectionTitle(entry.group)
|
||||||
|
if (entry.kind === 'track' && entry.track) return entry.track.name
|
||||||
if (entry.stillGroup) return stillCollectionTitle(entry.stillGroup)
|
if (entry.stillGroup) return stillCollectionTitle(entry.stillGroup)
|
||||||
return entry.name
|
return entry.name
|
||||||
}
|
}
|
||||||
@@ -256,6 +280,9 @@ export function entrySearchText(entry: LibraryEntry) {
|
|||||||
if (entry.kind === 'clip' && entry.group) {
|
if (entry.kind === 'clip' && entry.group) {
|
||||||
return entry.group.parts.map(part => `${part.name || ''} ${part.prompt || ''}`).join(' ')
|
return entry.group.parts.map(part => `${part.name || ''} ${part.prompt || ''}`).join(' ')
|
||||||
}
|
}
|
||||||
|
if (entry.kind === 'track' && entry.track) {
|
||||||
|
return `${entry.track.name || ''} ${entry.track.tags || ''} ${entry.track.lyrics || ''}`
|
||||||
|
}
|
||||||
if (entry.stillGroup) {
|
if (entry.stillGroup) {
|
||||||
return entry.stillGroup.parts.map(part => `${part.name || ''} ${part.prompt || ''} ${part.filename || ''}`).join(' ')
|
return entry.stillGroup.parts.map(part => `${part.name || ''} ${part.prompt || ''} ${part.filename || ''}`).join(' ')
|
||||||
}
|
}
|
||||||
@@ -297,6 +324,7 @@ function compareName(a: string, b: string) {
|
|||||||
export function buildLibraryEntries(opts: {
|
export function buildLibraryEntries(opts: {
|
||||||
clips: LibraryClip[]
|
clips: LibraryClip[]
|
||||||
stills: LibraryStill[]
|
stills: LibraryStill[]
|
||||||
|
tracks?: LibraryTrack[]
|
||||||
folderId: string
|
folderId: string
|
||||||
filter: LibraryFilter
|
filter: LibraryFilter
|
||||||
search: string
|
search: string
|
||||||
@@ -305,7 +333,8 @@ export function buildLibraryEntries(opts: {
|
|||||||
const inFolder = (folderId: string) => !opts.folderId || opts.folderId === 'all' || folderId === opts.folderId
|
const inFolder = (folderId: string) => !opts.folderId || opts.folderId === 'all' || folderId === opts.folderId
|
||||||
const clips = opts.clips.filter(clip => inFolder(clip.folderId) && clip.hasVideo !== false)
|
const clips = opts.clips.filter(clip => inFolder(clip.folderId) && clip.hasVideo !== false)
|
||||||
const stills = opts.stills.filter(still => inFolder(still.folderId) && still.role === 'output')
|
const stills = opts.stills.filter(still => inFolder(still.folderId) && still.role === 'output')
|
||||||
const videos: LibraryEntry[] = opts.filter === 'images'
|
const tracks = (opts.tracks || []).filter(track => inFolder(track.folderId) && track.hasAudio !== false)
|
||||||
|
const videos: LibraryEntry[] = opts.filter === 'images' || opts.filter === 'music'
|
||||||
? []
|
? []
|
||||||
: groupClips(clips).map(group => ({
|
: groupClips(clips).map(group => ({
|
||||||
kind: 'clip' as const,
|
kind: 'clip' as const,
|
||||||
@@ -314,7 +343,7 @@ export function buildLibraryEntries(opts: {
|
|||||||
name: collectionTitle(group),
|
name: collectionTitle(group),
|
||||||
group
|
group
|
||||||
}))
|
}))
|
||||||
const images: LibraryEntry[] = opts.filter === 'videos'
|
const images: LibraryEntry[] = opts.filter === 'videos' || opts.filter === 'music'
|
||||||
? []
|
? []
|
||||||
: groupStills(stills).map(group => ({
|
: groupStills(stills).map(group => ({
|
||||||
kind: 'still' as const,
|
kind: 'still' as const,
|
||||||
@@ -323,7 +352,16 @@ export function buildLibraryEntries(opts: {
|
|||||||
name: stillCollectionTitle(group),
|
name: stillCollectionTitle(group),
|
||||||
stillGroup: group
|
stillGroup: group
|
||||||
}))
|
}))
|
||||||
let entries = [...videos, ...images]
|
const music: LibraryEntry[] = opts.filter === 'images' || opts.filter === 'videos'
|
||||||
|
? []
|
||||||
|
: tracks.map(track => ({
|
||||||
|
kind: 'track' as const,
|
||||||
|
key: `track:${track.id}`,
|
||||||
|
createdAt: track.createdAt,
|
||||||
|
name: track.name,
|
||||||
|
track
|
||||||
|
}))
|
||||||
|
let entries = [...videos, ...images, ...music]
|
||||||
const needle = opts.search.trim().toLowerCase()
|
const needle = opts.search.trim().toLowerCase()
|
||||||
if (needle) {
|
if (needle) {
|
||||||
entries = entries.filter(entry => entrySearchText(entry).toLowerCase().includes(needle))
|
entries = entries.filter(entry => entrySearchText(entry).toLowerCase().includes(needle))
|
||||||
@@ -334,7 +372,8 @@ export function buildLibraryEntries(opts: {
|
|||||||
if (sort === 'name-desc') return compareName(b.name, a.name) || b.createdAt - a.createdAt
|
if (sort === 'name-desc') return compareName(b.name, a.name) || b.createdAt - a.createdAt
|
||||||
if (sort === 'date-asc') return a.createdAt - b.createdAt
|
if (sort === 'date-asc') return a.createdAt - b.createdAt
|
||||||
if (sort === 'type') {
|
if (sort === 'type') {
|
||||||
if (a.kind !== b.kind) return a.kind === 'still' ? -1 : 1
|
const order = { still: 0, clip: 1, track: 2 } as const
|
||||||
|
if (a.kind !== b.kind) return order[a.kind] - order[b.kind]
|
||||||
return b.createdAt - a.createdAt
|
return b.createdAt - a.createdAt
|
||||||
}
|
}
|
||||||
return b.createdAt - a.createdAt
|
return b.createdAt - a.createdAt
|
||||||
@@ -342,13 +381,15 @@ export function buildLibraryEntries(opts: {
|
|||||||
return entries
|
return entries
|
||||||
}
|
}
|
||||||
|
|
||||||
export function folderOutputCount(folder: LibraryFolder, clips: LibraryClip[], stills: LibraryStill[], filter: LibraryFilter) {
|
export function folderOutputCount(folder: LibraryFolder, clips: LibraryClip[], stills: LibraryStill[], filter: LibraryFilter, tracks: LibraryTrack[] = []) {
|
||||||
if (!folder.unlocked) return (folder.clipCount || 0) + (folder.imageCount || 0)
|
if (!folder.unlocked) return (folder.clipCount || 0) + (folder.imageCount || 0) + (folder.trackCount || 0)
|
||||||
const videos = clips.filter(clip => clip.folderId === folder.id).length
|
const videos = clips.filter(clip => clip.folderId === folder.id).length
|
||||||
const images = stills.filter(still => still.folderId === folder.id && still.role === 'output').length
|
const images = stills.filter(still => still.folderId === folder.id && still.role === 'output').length
|
||||||
|
const music = tracks.filter(track => track.folderId === folder.id).length
|
||||||
if (filter === 'videos') return videos
|
if (filter === 'videos') return videos
|
||||||
if (filter === 'images') return images
|
if (filter === 'images') return images
|
||||||
return videos + images
|
if (filter === 'music') return music
|
||||||
|
return videos + images + music
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatWhen(createdAt: number) {
|
export function formatWhen(createdAt: number) {
|
||||||
|
|||||||
@@ -280,3 +280,34 @@ export function clipSettingsRows(clip: {
|
|||||||
if (loras) rows.push({ label: 'LoRA', value: loras })
|
if (loras) rows.push({ label: 'LoRA', value: loras })
|
||||||
return rows
|
return rows
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function trackSettingsRows(track: {
|
||||||
|
tags?: string
|
||||||
|
lyrics?: string
|
||||||
|
duration?: number
|
||||||
|
steps?: number
|
||||||
|
seed?: number
|
||||||
|
cfg?: number
|
||||||
|
instrumental?: boolean
|
||||||
|
engine?: string
|
||||||
|
samplerName?: string
|
||||||
|
scheduler?: string
|
||||||
|
lyricsStrength?: number
|
||||||
|
}) {
|
||||||
|
const rows: SettingsRow[] = []
|
||||||
|
if (track.engine) rows.push({ label: 'Engine', value: track.engine === 'ace-step-1.5' ? 'ACE-Step 1.5' : 'ACE-Step' })
|
||||||
|
if (track.tags?.trim()) rows.push({ label: 'Tags', value: track.tags.trim() })
|
||||||
|
if (track.instrumental) rows.push({ label: 'Instrumental', value: 'On' })
|
||||||
|
else if (track.lyrics?.trim()) rows.push({ label: 'Lyrics', value: track.lyrics.trim() })
|
||||||
|
if (typeof track.duration === 'number' && track.duration > 0) {
|
||||||
|
const rounded = Math.round(track.duration * 10) / 10
|
||||||
|
rows.push({ label: 'Duration', value: Number.isInteger(rounded) ? `${rounded}s` : `${rounded.toFixed(1)}s` })
|
||||||
|
}
|
||||||
|
if (track.steps != null) rows.push({ label: 'Steps', value: String(track.steps) })
|
||||||
|
if (track.cfg != null) rows.push({ label: 'CFG', value: formatNum(track.cfg) })
|
||||||
|
if (track.samplerName) rows.push({ label: 'Sampler', value: track.samplerName })
|
||||||
|
if (track.scheduler) rows.push({ label: 'Scheduler', value: track.scheduler })
|
||||||
|
if (track.lyricsStrength != null) rows.push({ label: 'Lyric strength', value: formatNum(track.lyricsStrength) })
|
||||||
|
if (track.seed != null) rows.push({ label: 'Seed', value: String(track.seed) })
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 === 'klein') return 'klein'
|
||||||
if (value === 'ltx') return 'ltx'
|
if (value === 'ltx') return 'ltx'
|
||||||
if (value === 'minimax') return 'minimax'
|
if (value === 'minimax') return 'minimax'
|
||||||
|
if (value === 'ace' || value === 'ace-step') return 'ace'
|
||||||
|
if (value === 'ace15' || value === 'ace-step-1.5') return 'ace15'
|
||||||
|
if (value === 'yue') return 'yue'
|
||||||
return sanitizeFilenameKey(value, 16) || 'gen'
|
return sanitizeFilenameKey(value, 16) || 'gen'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user