Add ACE-Step music generation to the studio and library.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-09-02 23:58:18 -05:00
co-authored by Cursor
parent c79cca39ae
commit 5555155884
25 changed files with 2157 additions and 44 deletions
+85 -17
View File
@@ -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)) {