Add ACE-Step music generation to the studio and library.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+12
-1
@@ -10,7 +10,7 @@
|
||||
<main class="mx-auto grid min-w-0 max-w-7xl gap-6 px-4 py-6 sm:px-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:items-stretch">
|
||||
<section id="studio-input" class="glass min-w-0 max-w-full space-y-5 rounded-3xl p-5 sm:p-6">
|
||||
<div>
|
||||
<div class="mb-4 grid grid-cols-2 gap-3">
|
||||
<div class="mb-4 grid grid-cols-3 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-3xl border px-4 py-4 text-left transition"
|
||||
@@ -39,6 +39,17 @@
|
||||
<span class="mt-3 block font-display text-lg font-bold text-zinc-100">Image</span>
|
||||
<span class="mt-0.5 block text-xs text-zinc-500">Flux · Krea</span>
|
||||
</button>
|
||||
<NuxtLink
|
||||
to="/music"
|
||||
class="rounded-3xl border border-white/10 px-4 py-4 text-left text-zinc-400 transition hover:border-white/20 hover:text-zinc-200"
|
||||
>
|
||||
<svg class="h-10 w-10" viewBox="0 0 40 40" fill="none" aria-hidden="true">
|
||||
<circle cx="20" cy="20" r="12" stroke="currentColor" stroke-width="2" />
|
||||
<path d="M18 14v12l10-4V12l-10 2Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round" />
|
||||
</svg>
|
||||
<span class="mt-3 block font-display text-lg font-bold text-zinc-100">Music</span>
|
||||
<span class="mt-0.5 block text-xs text-zinc-500">ACE-Step · vocals</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<h2 class="font-display text-2xl font-bold">Input</h2>
|
||||
<p class="text-sm text-zinc-400">{{ studioMode === 'editv2'
|
||||
|
||||
+85
-17
@@ -161,6 +161,18 @@
|
||||
>
|
||||
<div class="relative aspect-[4/5] bg-zinc-900">
|
||||
<span v-if="thumbHidden(entry)" class="sensitive-glow block h-full w-full" />
|
||||
<div
|
||||
v-else-if="entry.kind === 'track'"
|
||||
class="flex h-full w-full items-end gap-0.5 bg-zinc-900 px-4 py-8"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span
|
||||
v-for="n in 16"
|
||||
:key="n"
|
||||
class="flex-1 rounded-full bg-amber-400/70"
|
||||
:style="{ height: `${20 + ((n * 17) % 60)}%` }"
|
||||
/>
|
||||
</div>
|
||||
<img
|
||||
v-else-if="thumbSrc(entry)"
|
||||
:src="thumbSrc(entry)"
|
||||
@@ -171,7 +183,7 @@
|
||||
class="h-full w-full object-cover"
|
||||
>
|
||||
<span v-else class="block h-full w-full bg-zinc-800" />
|
||||
<span class="absolute left-2 top-2 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-zinc-200">{{ entry.kind === 'clip' ? 'Video' : 'Image' }}</span>
|
||||
<span class="absolute left-2 top-2 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-zinc-200">{{ entry.kind === 'clip' ? 'Video' : entry.kind === 'track' ? 'Music' : 'Image' }}</span>
|
||||
<span
|
||||
v-if="partCount(entry) > 1"
|
||||
class="absolute right-2 top-2 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] text-amber-100"
|
||||
@@ -235,6 +247,9 @@
|
||||
controls
|
||||
playsinline
|
||||
/>
|
||||
<div v-else-if="focused.kind === 'track' && previewAudio" class="p-4">
|
||||
<AudioPlayer :src="previewAudio" :download-name="`${focused.name}.${focused.track?.ext || 'flac'}`" />
|
||||
</div>
|
||||
<button
|
||||
v-else-if="focused.kind === 'still' && previewStillSrc"
|
||||
type="button"
|
||||
@@ -263,6 +278,7 @@
|
||||
>
|
||||
</button>
|
||||
<FamilyNav
|
||||
v-if="focused.kind !== 'track'"
|
||||
:count="focusedNavParts.length"
|
||||
:index="focusedNavIndex"
|
||||
:prev-label="focused?.kind === 'clip' ? 'Previous clip' : 'Previous image'"
|
||||
@@ -270,7 +286,7 @@
|
||||
@step="stepFocusedFamily"
|
||||
/>
|
||||
<button
|
||||
v-if="showPrivacyToggles"
|
||||
v-if="showPrivacyToggles && focused.kind !== 'track'"
|
||||
type="button"
|
||||
class="absolute right-2 top-2 z-30 flex h-8 w-8 items-center justify-center rounded-full border border-white/20 bg-black/60 text-zinc-100 hover:border-amber-300/50 hover:text-amber-100"
|
||||
:title="detailsHidden ? 'Show preview' : 'Hide preview'"
|
||||
@@ -300,8 +316,8 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<button type="button" class="rounded-full border border-amber-300/40 px-3 py-1.5 text-xs text-amber-100" @click="openInStudio(focused)">Open in Studio</button>
|
||||
<button type="button" class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 hover:text-white" @click="useInStudio(focused)">Use as input</button>
|
||||
<button type="button" class="rounded-full border border-amber-300/40 px-3 py-1.5 text-xs text-amber-100" @click="openInStudio(focused)">{{ focused.kind === 'track' ? 'Open in Music' : 'Open in Studio' }}</button>
|
||||
<button v-if="focused.kind !== 'track'" type="button" class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 hover:text-white" @click="useInStudio(focused)">Use as input</button>
|
||||
<button
|
||||
v-if="focused.kind === 'still' && focusedStillRole !== 'input'"
|
||||
type="button"
|
||||
@@ -323,7 +339,7 @@
|
||||
class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 hover:text-white"
|
||||
>Download</a>
|
||||
<button
|
||||
v-if="!detailsHidden && (previewVideo || previewStillSrc || thumbSrc(focused))"
|
||||
v-if="!detailsHidden && focused.kind !== 'track' && (previewVideo || previewStillSrc || thumbSrc(focused))"
|
||||
type="button"
|
||||
class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 hover:text-white"
|
||||
@click="lightboxOpen = true"
|
||||
@@ -364,7 +380,7 @@
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="lightboxOpen && focused && !detailsHidden"
|
||||
v-if="lightboxOpen && focused && !detailsHidden && focused.kind !== 'track'"
|
||||
class="fixed inset-0 z-[80] flex items-center justify-center bg-black/85 p-3 sm:p-6"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@@ -446,10 +462,11 @@ import {
|
||||
type LibraryFilter,
|
||||
type LibraryFolder,
|
||||
type LibrarySort,
|
||||
type LibraryStill
|
||||
type LibraryStill,
|
||||
type LibraryTrack
|
||||
} from '~/utils/libraryBrowse'
|
||||
import { isXaigenStudio } from '~/utils/videoModels'
|
||||
import { clipSettingsRows, stillSettingsRows } from '~/utils/libraryGeneration'
|
||||
import { clipSettingsRows, stillSettingsRows, trackSettingsRows } from '~/utils/libraryGeneration'
|
||||
|
||||
const CARD_MIN = 156
|
||||
const GAP = 10
|
||||
@@ -459,7 +476,8 @@ const DRAG_MIME = 'application/x-aigen-library'
|
||||
const filterOptions = [
|
||||
{ id: 'all' as const, label: 'All' },
|
||||
{ id: 'images' as const, label: 'Images' },
|
||||
{ id: 'videos' as const, label: 'Videos' }
|
||||
{ id: 'videos' as const, label: 'Videos' },
|
||||
{ id: 'music' as const, label: 'Music' }
|
||||
]
|
||||
|
||||
const runtimeConfig = useRuntimeConfig()
|
||||
@@ -473,6 +491,7 @@ const showPrivacyToggles = computed(() => isXaigenStudio({
|
||||
const folders = ref<LibraryFolder[]>([])
|
||||
const clips = ref<LibraryClip[]>([])
|
||||
const stills = ref<LibraryStill[]>([])
|
||||
const tracks = ref<LibraryTrack[]>([])
|
||||
const folderId = ref('all')
|
||||
const filter = ref<LibraryFilter>('all')
|
||||
const sort = ref<LibrarySort>('date-desc')
|
||||
@@ -511,6 +530,7 @@ const browseFolder = computed(() => folders.value.find(folder => folder.id === f
|
||||
const entries = computed(() => buildLibraryEntries({
|
||||
clips: clips.value,
|
||||
stills: stills.value,
|
||||
tracks: tracks.value,
|
||||
folderId: folderId.value,
|
||||
filter: filter.value,
|
||||
search: search.value,
|
||||
@@ -521,6 +541,7 @@ const allCount = computed(() => {
|
||||
return buildLibraryEntries({
|
||||
clips: clips.value,
|
||||
stills: stills.value,
|
||||
tracks: tracks.value,
|
||||
folderId: 'all',
|
||||
filter: filter.value,
|
||||
search: '',
|
||||
@@ -528,17 +549,21 @@ const allCount = computed(() => {
|
||||
}).length
|
||||
})
|
||||
const folderChipCounts = computed(() => {
|
||||
const map: Record<string, { videos: number; images: number }> = {}
|
||||
const map: Record<string, { videos: number; images: number; music: number }> = {}
|
||||
for (const clip of clips.value) {
|
||||
if (clip.hasVideo === false) continue
|
||||
const row = map[clip.folderId] || (map[clip.folderId] = { videos: 0, images: 0 })
|
||||
const row = map[clip.folderId] || (map[clip.folderId] = { videos: 0, images: 0, music: 0 })
|
||||
row.videos += 1
|
||||
}
|
||||
for (const still of stills.value) {
|
||||
if (still.role !== 'output') continue
|
||||
const row = map[still.folderId] || (map[still.folderId] = { videos: 0, images: 0 })
|
||||
const row = map[still.folderId] || (map[still.folderId] = { videos: 0, images: 0, music: 0 })
|
||||
row.images += 1
|
||||
}
|
||||
for (const track of tracks.value) {
|
||||
const row = map[track.folderId] || (map[track.folderId] = { videos: 0, images: 0, music: 0 })
|
||||
row.music += 1
|
||||
}
|
||||
return map
|
||||
})
|
||||
const moveFolders = computed(() => folders.value.filter(folder => folder.id !== folderId.value))
|
||||
@@ -552,6 +577,7 @@ const emptyLabel = computed(() => {
|
||||
if (search.value.trim()) return 'Nothing matches that search.'
|
||||
if (filter.value === 'images') return 'No generated stills here yet.'
|
||||
if (filter.value === 'videos') return 'No generated clips here yet.'
|
||||
if (filter.value === 'music') return 'No generated tracks here yet.'
|
||||
return 'Nothing in this folder yet.'
|
||||
})
|
||||
const cols = computed(() => Math.max(2, Math.floor((viewportW.value + GAP) / (CARD_MIN + GAP))))
|
||||
@@ -580,6 +606,10 @@ const previewStillSrc = computed(() => {
|
||||
const id = focusedPartId.value || focused.value.stillGroup.head.id
|
||||
return `/api/library/stills/${id}`
|
||||
})
|
||||
const previewAudio = computed(() => {
|
||||
if (focused.value?.kind !== 'track' || !focused.value.track) return ''
|
||||
return `/api/library/tracks/${focused.value.track.id}/audio`
|
||||
})
|
||||
const focusedParts = computed(() => {
|
||||
if (focused.value?.kind === 'clip') return focused.value.group?.parts || []
|
||||
return focused.value?.stillGroup?.parts || []
|
||||
@@ -606,6 +636,9 @@ const focusedSettingsRows = computed(() => {
|
||||
const item = focused.value.group.parts.find(part => part.id === focusedPartId.value) || focused.value.group.head
|
||||
return clipSettingsRows(item)
|
||||
}
|
||||
if (focused.value?.kind === 'track' && focused.value.track) {
|
||||
return trackSettingsRows(focused.value.track)
|
||||
}
|
||||
const still = focused.value?.stillGroup?.parts.find(part => part.id === focusedPartId.value) || focused.value?.stillGroup?.head
|
||||
return still ? stillSettingsRows(still) : []
|
||||
})
|
||||
@@ -616,6 +649,9 @@ const detailMeta = computed(() => {
|
||||
const item = entry.group.parts.find(part => part.id === focusedPartId.value) || entry.group.head
|
||||
return [item.width && item.height ? `${item.width}×${item.height}` : '', formatClipDuration(item.duration), formatWhen(item.createdAt)].filter(Boolean).join(' · ')
|
||||
}
|
||||
if (entry.kind === 'track' && entry.track) {
|
||||
return [formatClipDuration(entry.track.duration), entry.track.ext?.toUpperCase(), formatWhen(entry.track.createdAt)].filter(Boolean).join(' · ')
|
||||
}
|
||||
const still = entry.stillGroup?.parts.find(part => part.id === focusedPartId.value) || entry.stillGroup?.head
|
||||
if (!still) return ''
|
||||
return [still.width && still.height ? `${still.width}×${still.height}` : 'image', formatWhen(still.createdAt)].filter(Boolean).join(' · ')
|
||||
@@ -625,6 +661,9 @@ const downloadHref = computed(() => {
|
||||
const id = focusedPartId.value || focused.value.group.head.id
|
||||
return `/api/library/clips/${id}/video`
|
||||
}
|
||||
if (focused.value?.kind === 'track' && focused.value.track) {
|
||||
return `/api/library/tracks/${focused.value.track.id}/audio`
|
||||
}
|
||||
if (focused.value?.stillGroup) {
|
||||
const id = focusedPartId.value || focused.value.stillGroup.head.id
|
||||
return `/api/library/stills/${id}`
|
||||
@@ -648,8 +687,9 @@ function applyLibrary(data: unknown) {
|
||||
folders?: LibraryFolder[]
|
||||
clips?: LibraryClip[]
|
||||
stills?: LibraryStill[]
|
||||
tracks?: LibraryTrack[]
|
||||
}
|
||||
if (payload.id && !payload.folders && !payload.clips && !payload.stills) {
|
||||
if (payload.id && !payload.folders && !payload.clips && !payload.stills && !payload.tracks) {
|
||||
if (typeof payload.hideInput === 'boolean') {
|
||||
const still = stills.value.find(item => item.id === payload.id)
|
||||
if (still) still.hideInput = payload.hideInput
|
||||
@@ -663,6 +703,7 @@ function applyLibrary(data: unknown) {
|
||||
folders.value = payload.folders || []
|
||||
clips.value = payload.clips || []
|
||||
stills.value = payload.stills || []
|
||||
tracks.value = payload.tracks || []
|
||||
if (folderId.value !== 'all' && !folders.value.some(folder => folder.id === folderId.value)) {
|
||||
folderId.value = folders.value[0]?.id || 'all'
|
||||
}
|
||||
@@ -673,12 +714,13 @@ async function loadLibrary() {
|
||||
}
|
||||
|
||||
function folderChipCount(folder: LibraryFolder) {
|
||||
if (!folder.unlocked) return (folder.clipCount || 0) + (folder.imageCount || 0)
|
||||
if (!folder.unlocked) return (folder.clipCount || 0) + (folder.imageCount || 0) + (folder.trackCount || 0)
|
||||
const row = folderChipCounts.value[folder.id]
|
||||
if (!row) return 0
|
||||
if (filter.value === 'videos') return row.videos
|
||||
if (filter.value === 'images') return row.images
|
||||
return row.videos + row.images
|
||||
if (filter.value === 'music') return row.music
|
||||
return row.videos + row.images + row.music
|
||||
}
|
||||
|
||||
function folderChipClass(id: string) {
|
||||
@@ -696,6 +738,7 @@ function cardClass(entry: LibraryEntry) {
|
||||
|
||||
function itemHidden(entry: LibraryEntry) {
|
||||
if (entry.kind === 'clip') return Boolean(entry.group?.head.hideThumbnail)
|
||||
if (entry.kind === 'track') return false
|
||||
return Boolean(entry.stillGroup?.head.hideInput)
|
||||
}
|
||||
|
||||
@@ -751,11 +794,13 @@ async function toggleFocusedHidden() {
|
||||
|
||||
function thumbSrc(entry: LibraryEntry) {
|
||||
if (entry.kind === 'clip' && entry.group) return `/api/library/clips/${entry.group.head.id}/thumb`
|
||||
if (entry.kind === 'track') return ''
|
||||
if (entry.stillGroup) return `/api/library/stills/${entry.stillGroup.head.id}?thumb=1`
|
||||
return ''
|
||||
}
|
||||
|
||||
function partCount(entry: LibraryEntry) {
|
||||
if (entry.kind === 'track') return 1
|
||||
return entry.kind === 'clip' ? (entry.group?.parts.length || 1) : (entry.stillGroup?.parts.length || 1)
|
||||
}
|
||||
|
||||
@@ -764,6 +809,9 @@ function cardMeta(entry: LibraryEntry) {
|
||||
const branches = familyBranchHint(entry.group.parts)
|
||||
return [formatClipDuration(entry.group.head.duration), formatWhen(entry.createdAt), branches].filter(Boolean).join(' · ')
|
||||
}
|
||||
if (entry.kind === 'track' && entry.track) {
|
||||
return [formatClipDuration(entry.track.duration), formatWhen(entry.createdAt)].filter(Boolean).join(' · ')
|
||||
}
|
||||
return formatWhen(entry.createdAt)
|
||||
}
|
||||
|
||||
@@ -847,7 +895,11 @@ function onCardClick(entry: LibraryEntry, event: MouseEvent) {
|
||||
}
|
||||
const sameCard = focusedKey.value === entry.key
|
||||
focusedKey.value = entry.key
|
||||
focusedPartId.value = entry.kind === 'clip' ? (entry.group?.head.id || '') : (entry.stillGroup?.head.id || '')
|
||||
focusedPartId.value = entry.kind === 'clip'
|
||||
? (entry.group?.head.id || '')
|
||||
: entry.kind === 'track'
|
||||
? (entry.track?.id || '')
|
||||
: (entry.stillGroup?.head.id || '')
|
||||
if (!sameCard) detailsHidden.value = itemHidden(entry) && !revealAll.value
|
||||
cancelRename()
|
||||
}
|
||||
@@ -886,7 +938,11 @@ function selectedEntries(keys = selectedKeys.value) {
|
||||
|
||||
async function moveKeys(keys: string[], destId: string) {
|
||||
const items = selectedEntries(keys).filter((entry) => {
|
||||
const current = entry.kind === 'clip' ? entry.group?.head.folderId : entry.stillGroup?.head.folderId
|
||||
const current = entry.kind === 'clip'
|
||||
? entry.group?.head.folderId
|
||||
: entry.kind === 'track'
|
||||
? entry.track?.folderId
|
||||
: entry.stillGroup?.head.folderId
|
||||
return current && current !== destId
|
||||
})
|
||||
if (!items.length) return
|
||||
@@ -895,6 +951,9 @@ async function moveKeys(keys: string[], destId: string) {
|
||||
if (entry.kind === 'clip') {
|
||||
return $fetch(`/api/library/clips/${entry.group!.head.id}`, { method: 'PATCH', body: { folderId: destId, family: true } })
|
||||
}
|
||||
if (entry.kind === 'track') {
|
||||
return $fetch(`/api/library/tracks/${entry.track!.id}`, { method: 'PATCH', body: { folderId: destId } })
|
||||
}
|
||||
return $fetch(`/api/library/stills/${entry.stillGroup!.head.id}`, { method: 'PATCH', body: { folderId: destId, family: true } })
|
||||
}))
|
||||
await loadLibrary()
|
||||
@@ -923,6 +982,10 @@ function openInStudio(entry: LibraryEntry) {
|
||||
navigateTo({ path: '/', query: { clip: focusedPartId.value || entry.group.head.id } })
|
||||
return
|
||||
}
|
||||
if (entry.kind === 'track' && entry.track) {
|
||||
navigateTo({ path: '/music', query: { track: entry.track.id } })
|
||||
return
|
||||
}
|
||||
if (entry.stillGroup) {
|
||||
navigateTo({ path: '/', query: { still: focusedPartId.value || entry.stillGroup.head.id } })
|
||||
}
|
||||
@@ -996,6 +1059,8 @@ async function saveRename() {
|
||||
try {
|
||||
if (entry.kind === 'clip' && entry.group) {
|
||||
applyLibrary(await $fetch(`/api/library/clips/${entry.group.head.id}`, { method: 'PATCH', body: { name, family: true } }))
|
||||
} else if (entry.kind === 'track' && entry.track) {
|
||||
applyLibrary(await $fetch(`/api/library/tracks/${entry.track.id}`, { method: 'PATCH', body: { name } }))
|
||||
} else if (entry.stillGroup) {
|
||||
applyLibrary(await $fetch(`/api/library/stills/${entry.stillGroup.head.id}`, { method: 'PATCH', body: { name, family: true } }))
|
||||
}
|
||||
@@ -1038,6 +1103,9 @@ async function confirmDelete() {
|
||||
if (entry.kind === 'clip') {
|
||||
return $fetch(`/api/library/clips/${entry.group!.head.id}`, { method: 'DELETE', query: { family: '1' } })
|
||||
}
|
||||
if (entry.kind === 'track') {
|
||||
return $fetch(`/api/library/tracks/${entry.track!.id}`, { method: 'DELETE' })
|
||||
}
|
||||
return $fetch(`/api/library/stills/${entry.stillGroup!.head.id}`, { method: 'DELETE', query: { family: '1' } })
|
||||
}))
|
||||
if (pending.keys.includes(focusedKey.value)) {
|
||||
|
||||
+344
@@ -0,0 +1,344 @@
|
||||
<template>
|
||||
<div class="min-h-dvh min-w-0">
|
||||
<AppHeader
|
||||
subtitle="ACE-Step · vocals"
|
||||
current="music"
|
||||
/>
|
||||
|
||||
<main class="mx-auto grid min-w-0 max-w-7xl gap-6 px-4 py-6 sm:px-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:items-stretch">
|
||||
<section class="glass min-w-0 max-w-full space-y-5 rounded-3xl p-5 sm:p-6">
|
||||
<div>
|
||||
<h2 class="font-display text-2xl font-bold">Music Generator</h2>
|
||||
<p class="mt-1 text-sm text-zinc-400">
|
||||
Tags describe style and voice. Lyrics use structure chips. Instrumental skips the vocal line.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="rounded-2xl border border-red-400/30 bg-red-400/10 px-4 py-3 text-sm text-red-200">{{ error }}</p>
|
||||
|
||||
<label class="block text-sm">
|
||||
<span class="mb-1 block font-medium text-zinc-300">Genre & style tags</span>
|
||||
<input
|
||||
v-model="tags"
|
||||
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
||||
:placeholder="tagsPlaceholder"
|
||||
maxlength="2000"
|
||||
>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<div class="mb-1 flex flex-wrap items-center justify-between gap-2">
|
||||
<span class="text-sm font-medium text-zinc-300">Lyrics</span>
|
||||
<label class="flex cursor-pointer items-center gap-2 text-xs text-zinc-400">
|
||||
<input v-model="instrumental" type="checkbox" class="accent-amber-400">
|
||||
Instrumental
|
||||
</label>
|
||||
</div>
|
||||
<div class="mb-2 flex flex-wrap gap-1.5">
|
||||
<button
|
||||
v-for="chip in chips"
|
||||
:key="chip"
|
||||
type="button"
|
||||
class="rounded-full border border-white/10 px-2.5 py-1 text-[11px] text-zinc-300 hover:border-amber-300/50 hover:text-white disabled:opacity-40"
|
||||
:disabled="instrumental"
|
||||
@click="insertChip(chip)"
|
||||
>{{ chip }}</button>
|
||||
</div>
|
||||
<textarea
|
||||
ref="lyricsEl"
|
||||
v-model="lyrics"
|
||||
rows="12"
|
||||
class="max-h-[min(28rem,50vh)] w-full overflow-y-auto rounded-2xl border border-white/10 bg-zinc-950/70 px-4 py-3 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 [overflow-wrap:anywhere] focus:ring-2 disabled:opacity-50"
|
||||
:disabled="instrumental"
|
||||
:placeholder="lyricsPlaceholder"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<label class="block text-sm">
|
||||
<span class="mb-1 block font-medium text-zinc-300">Duration · {{ duration }}s</span>
|
||||
<input
|
||||
v-model.number="duration"
|
||||
type="range"
|
||||
class="aigen-slider w-full"
|
||||
:min="durationMin"
|
||||
:max="durationMax"
|
||||
step="5"
|
||||
>
|
||||
<span class="mt-1 block text-[11px] text-zinc-500">{{ durationMin }}–{{ durationMax }} seconds</span>
|
||||
</label>
|
||||
<label class="block text-sm">
|
||||
<span class="mb-1 block font-medium text-zinc-300">Steps · {{ steps }}</span>
|
||||
<input
|
||||
v-model.number="steps"
|
||||
type="range"
|
||||
class="aigen-slider w-full"
|
||||
:min="stepsMin"
|
||||
:max="stepsMax"
|
||||
step="1"
|
||||
>
|
||||
<span class="mt-1 block text-[11px] text-zinc-500">30–50 is the usual range</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<label class="block text-sm">
|
||||
<span class="mb-1 block font-medium text-zinc-300">Seed</span>
|
||||
<input
|
||||
v-model="seed"
|
||||
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
||||
placeholder="random"
|
||||
>
|
||||
</label>
|
||||
<label class="block text-sm">
|
||||
<span class="mb-1 block font-medium text-zinc-300">Folder</span>
|
||||
<select v-model="folderId" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm">
|
||||
<option v-for="folder in folders" :key="folder.id" :value="folder.id">{{ folder.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="block text-sm">
|
||||
<span class="mb-1 block font-medium text-zinc-300">Name</span>
|
||||
<input
|
||||
v-model="name"
|
||||
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
||||
maxlength="80"
|
||||
placeholder="Optional track name"
|
||||
>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex w-full items-center justify-center gap-2 rounded-2xl bg-amber-400 px-4 py-3 text-sm font-semibold text-zinc-950 hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="busy || blocked"
|
||||
@click="generate"
|
||||
>
|
||||
<span
|
||||
v-if="busy"
|
||||
class="h-4 w-4 animate-spin rounded-full border-2 border-zinc-950/20 border-t-zinc-950"
|
||||
/>
|
||||
{{ busy ? (status || 'Generating…') : 'Generate' }}
|
||||
</button>
|
||||
<p v-if="blockReason" class="text-xs text-zinc-400">{{ blockReason }}</p>
|
||||
</section>
|
||||
|
||||
<section class="glass min-w-0 max-w-full space-y-4 rounded-3xl p-5 sm:p-6">
|
||||
<div>
|
||||
<h2 class="font-display text-2xl font-bold">Output</h2>
|
||||
<p class="mt-1 text-sm text-zinc-400">{{ status || 'Waiting for a generate.' }}</p>
|
||||
</div>
|
||||
<div v-if="busy" class="h-2 overflow-hidden rounded-full bg-zinc-800">
|
||||
<div class="progress-glow h-full rounded-full bg-amber-400 transition-[width]" :style="{ width: `${Math.max(progress, 4)}%` }" />
|
||||
</div>
|
||||
<AudioPlayer
|
||||
v-if="audioUrl"
|
||||
:src="audioUrl"
|
||||
:download-name="downloadName"
|
||||
/>
|
||||
<div v-else class="flex min-h-44 items-center justify-center rounded-2xl border border-dashed border-white/10 text-sm text-zinc-500">
|
||||
Generated FLAC / WAV plays here
|
||||
</div>
|
||||
<p v-if="trackId" class="text-xs text-zinc-500">
|
||||
Saved to library.
|
||||
<NuxtLink to="/library" class="text-amber-200 hover:text-amber-100">Open Library</NuxtLink>
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DEFAULT_MUSIC_LYRICS,
|
||||
DEFAULT_MUSIC_TAGS,
|
||||
MUSIC_DURATION_DEFAULT,
|
||||
MUSIC_DURATION_MAX,
|
||||
MUSIC_DURATION_MIN,
|
||||
MUSIC_STEPS_DEFAULT,
|
||||
MUSIC_STEPS_MAX,
|
||||
MUSIC_STEPS_MIN,
|
||||
MUSIC_STRUCTURE_CHIPS
|
||||
} from '~/utils/music'
|
||||
import { ensureGenerationNotifyPermission, notifyGenerationComplete } from '~/utils/generationNotify'
|
||||
import type { LibraryFolder } from '~/utils/libraryBrowse'
|
||||
|
||||
const tagsPlaceholder = DEFAULT_MUSIC_TAGS
|
||||
const lyricsPlaceholder = DEFAULT_MUSIC_LYRICS
|
||||
const chips = MUSIC_STRUCTURE_CHIPS
|
||||
const durationMin = MUSIC_DURATION_MIN
|
||||
const durationMax = MUSIC_DURATION_MAX
|
||||
const stepsMin = MUSIC_STEPS_MIN
|
||||
const stepsMax = MUSIC_STEPS_MAX
|
||||
|
||||
const tags = ref(DEFAULT_MUSIC_TAGS)
|
||||
const lyrics = ref(DEFAULT_MUSIC_LYRICS)
|
||||
const instrumental = ref(false)
|
||||
const duration = ref(MUSIC_DURATION_DEFAULT)
|
||||
const steps = ref(MUSIC_STEPS_DEFAULT)
|
||||
const seed = ref('')
|
||||
const name = ref('')
|
||||
const folderId = ref('')
|
||||
const folders = ref<LibraryFolder[]>([])
|
||||
const lyricsEl = ref<HTMLTextAreaElement | null>(null)
|
||||
const busy = ref(false)
|
||||
const error = ref('')
|
||||
const status = ref('')
|
||||
const progress = ref(0)
|
||||
const audioUrl = ref('')
|
||||
const trackId = ref('')
|
||||
const jobId = ref('')
|
||||
const downloadName = ref('track.flac')
|
||||
let source: EventSource | null = null
|
||||
let listenGen = 0
|
||||
let settled = false
|
||||
|
||||
const blocked = computed(() => !tags.value.trim() || (!instrumental.value && !lyrics.value.trim()) || !folderId.value)
|
||||
const blockReason = computed(() => {
|
||||
if (!folderId.value) return 'Choose a library folder before generating.'
|
||||
if (!tags.value.trim()) return 'Add genre and style tags.'
|
||||
if (!instrumental.value && !lyrics.value.trim()) return 'Write lyrics, or turn on Instrumental.'
|
||||
return ''
|
||||
})
|
||||
|
||||
function insertChip(chip: string) {
|
||||
const el = lyricsEl.value
|
||||
const token = `${chip}\n`
|
||||
if (!el) {
|
||||
lyrics.value = `${lyrics.value}${lyrics.value.endsWith('\n') || !lyrics.value ? '' : '\n'}${token}`
|
||||
return
|
||||
}
|
||||
const start = el.selectionStart ?? lyrics.value.length
|
||||
const end = el.selectionEnd ?? start
|
||||
lyrics.value = `${lyrics.value.slice(0, start)}${token}${lyrics.value.slice(end)}`
|
||||
nextTick(() => {
|
||||
const pos = start + token.length
|
||||
el.focus()
|
||||
el.setSelectionRange(pos, pos)
|
||||
})
|
||||
}
|
||||
|
||||
async function loadLibrary() {
|
||||
const payload = await $fetch<{ folders?: LibraryFolder[] }>('/api/library?light=1').catch(() => ({ folders: [] }))
|
||||
folders.value = payload.folders || []
|
||||
if (!folderId.value || !folders.value.some(folder => folder.id === folderId.value)) {
|
||||
folderId.value = folders.value.find(folder => folder.unlocked)?.id || folders.value[0]?.id || ''
|
||||
}
|
||||
}
|
||||
|
||||
function stopListen() {
|
||||
listenGen += 1
|
||||
source?.close()
|
||||
source = null
|
||||
}
|
||||
|
||||
function applyEvent(payload: Record<string, any>) {
|
||||
if (payload.message) status.value = payload.message
|
||||
if (typeof payload.progress === 'number') progress.value = payload.progress
|
||||
if (payload.type === 'complete' || payload.status === 'complete') {
|
||||
settled = true
|
||||
busy.value = false
|
||||
status.value = payload.message || 'Track ready'
|
||||
progress.value = 100
|
||||
if (payload.trackId) {
|
||||
trackId.value = payload.trackId
|
||||
audioUrl.value = `/api/library/tracks/${payload.trackId}/audio`
|
||||
downloadName.value = `${(name.value || 'track').replace(/[^\w.-]+/g, '-')}.flac`
|
||||
} else if (payload.filename) {
|
||||
const params = new URLSearchParams({
|
||||
filename: payload.filename,
|
||||
subfolder: payload.subfolder || 'audio',
|
||||
type: payload.mediaType || 'output'
|
||||
})
|
||||
audioUrl.value = `/api/media?${params.toString()}`
|
||||
}
|
||||
stopListen()
|
||||
void notifyGenerationComplete('music')
|
||||
}
|
||||
if (payload.type === 'error' || payload.status === 'error') {
|
||||
settled = true
|
||||
busy.value = false
|
||||
error.value = payload.error || payload.message || 'Generation failed'
|
||||
status.value = error.value
|
||||
stopListen()
|
||||
}
|
||||
}
|
||||
|
||||
function listen(id: string) {
|
||||
stopListen()
|
||||
const gen = listenGen
|
||||
settled = false
|
||||
const stream = new EventSource(`/api/generate/${id}/stream`)
|
||||
source = stream
|
||||
stream.onmessage = (event) => {
|
||||
if (gen !== listenGen) return
|
||||
applyEvent(JSON.parse(event.data))
|
||||
}
|
||||
stream.onerror = () => {
|
||||
if (gen !== listenGen) return
|
||||
stream.close()
|
||||
setTimeout(() => {
|
||||
if (gen !== listenGen || settled) return
|
||||
listen(id)
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
async function generate() {
|
||||
if (blocked.value) {
|
||||
error.value = blockReason.value
|
||||
return
|
||||
}
|
||||
error.value = ''
|
||||
await ensureGenerationNotifyPermission()
|
||||
busy.value = true
|
||||
settled = false
|
||||
progress.value = 2
|
||||
audioUrl.value = ''
|
||||
trackId.value = ''
|
||||
status.value = 'Queueing ACE-Step…'
|
||||
try {
|
||||
const started = await $fetch<{ jobId: string; name?: string }>(
|
||||
'/api/generate/music',
|
||||
{
|
||||
method: 'POST',
|
||||
body: {
|
||||
tags: tags.value,
|
||||
lyrics: lyrics.value,
|
||||
duration: duration.value,
|
||||
steps: steps.value,
|
||||
seed: seed.value || 'random',
|
||||
instrumental: instrumental.value,
|
||||
folderId: folderId.value,
|
||||
name: name.value.trim()
|
||||
}
|
||||
}
|
||||
)
|
||||
jobId.value = started.jobId
|
||||
if (started.name) downloadName.value = `${started.name}.flac`
|
||||
listen(started.jobId)
|
||||
} catch (err: any) {
|
||||
busy.value = false
|
||||
error.value = err?.data?.statusMessage || err?.statusMessage || err?.message || 'Could not queue music generation'
|
||||
status.value = error.value
|
||||
}
|
||||
}
|
||||
|
||||
watch(instrumental, (on) => {
|
||||
if (on && !lyrics.value.trim()) lyrics.value = DEFAULT_MUSIC_LYRICS
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await loadLibrary()
|
||||
const query = useRoute().query
|
||||
if (typeof query.track === 'string' && query.track) {
|
||||
audioUrl.value = `/api/library/tracks/${query.track}/audio`
|
||||
trackId.value = query.track
|
||||
status.value = 'Loaded from library'
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopListen()
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user