diff --git a/components/AppHeader.vue b/components/AppHeader.vue index 20af8af..34b020c 100644 --- a/components/AppHeader.vue +++ b/components/AppHeader.vue @@ -55,6 +55,13 @@ role="menuitem" @click="menuOpen = false" >Director + Music withDefaults(defineProps<{ subtitle: string - current?: 'studio' | 'library' | 'director' | 'queue' | 'settings' + current?: 'studio' | 'library' | 'director' | 'queue' | 'settings' | 'music' wide?: boolean queueCount?: number }>(), { diff --git a/components/AudioPlayer.vue b/components/AudioPlayer.vue new file mode 100644 index 0000000..f0e62d4 --- /dev/null +++ b/components/AudioPlayer.vue @@ -0,0 +1,219 @@ + + + + + + + + + {{ playing ? 'Pause' : 'Play' }} + + {{ clock }} + + Vol + + + Download + + + {{ error }} + + + + diff --git a/nuxt.config.ts b/nuxt.config.ts index 7df42e1..695c4c5 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -58,6 +58,7 @@ export default defineNuxtConfig({ backupIngestToken: process.env.BACKUP_INGEST_TOKEN || 'carr-aigen-xaigen-backup', purgeComfyOutputs: process.env.PURGE_COMFY_OUTPUTS !== 'false', comfyFilenamePrefix: process.env.COMFY_FILENAME_PREFIX || 'video/MiniMax_H3', + comfyAudioPrefix: process.env.COMFY_AUDIO_PREFIX || 'audio/AIGen', kreaConceptLora: process.env.NUXT_KREA_CONCEPT_LORA || '', public: { authEnabled: process.env.NUXT_PUBLIC_AUTH_ENABLED === 'true', diff --git a/pages/index.vue b/pages/index.vue index c6dd246..2054a8c 100644 --- a/pages/index.vue +++ b/pages/index.vue @@ -10,7 +10,7 @@ - + Image Flux · Krea + + + + + + Music + ACE-Step · vocals + Input {{ studioMode === 'editv2' diff --git a/pages/library.vue b/pages/library.vue index e31319e..8c8cc27 100644 --- a/pages/library.vue +++ b/pages/library.vue @@ -161,6 +161,18 @@ > + + + - {{ entry.kind === 'clip' ? 'Video' : 'Image' }} + {{ entry.kind === 'clip' ? 'Video' : entry.kind === 'track' ? 'Music' : 'Image' }} + + + - Open in Studio - Use as input + {{ focused.kind === 'track' ? 'Open in Music' : 'Open in Studio' }} + Use as input Download isXaigenStudio({ const folders = ref([]) const clips = ref([]) const stills = ref([]) +const tracks = ref([]) const folderId = ref('all') const filter = ref('all') const sort = ref('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 = {} + const map: Record = {} 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)) { diff --git a/pages/music.vue b/pages/music.vue new file mode 100644 index 0000000..c6d578e --- /dev/null +++ b/pages/music.vue @@ -0,0 +1,344 @@ + + + + + + + + Music Generator + + Tags describe style and voice. Lyrics use structure chips. Instrumental skips the vocal line. + + + + {{ error }} + + + Genre & style tags + + + + + + Lyrics + + + Instrumental + + + + {{ chip }} + + + + + + + Duration · {{ duration }}s + + {{ durationMin }}–{{ durationMax }} seconds + + + Steps · {{ steps }} + + 30–50 is the usual range + + + + + + Seed + + + + Folder + + {{ folder.name }} + + + + + + Name + + + + + + {{ busy ? (status || 'Generating…') : 'Generate' }} + + {{ blockReason }} + + + + + Output + {{ status || 'Waiting for a generate.' }} + + + + + + + Generated FLAC / WAV plays here + + + Saved to library. + Open Library + + + + + + + diff --git a/scripts/download_music_models.ps1 b/scripts/download_music_models.ps1 new file mode 100644 index 0000000..a88a1e0 --- /dev/null +++ b/scripts/download_music_models.ps1 @@ -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 diff --git a/server/api/generate/active.get.ts b/server/api/generate/active.get.ts index 5184132..d8127b3 100644 --- a/server/api/generate/active.get.ts +++ b/server/api/generate/active.get.ts @@ -32,11 +32,13 @@ export default defineEventHandler(async (event) => { const live = listJobs() .filter(job => job.library?.ownerKey === owner && jobLooksLive(job, comfyBusy)) .sort((a, b) => b.startedAt - a.startedAt) - const videoLive = live.find(job => job.kind !== 'edit') + const videoLive = live.find(job => job.kind !== 'edit' && job.kind !== 'music') const editLive = live.find(job => job.kind === 'edit') + const musicLive = live.find(job => job.kind === 'music') let video = videoLive ? livePayload(videoLive) : null const edit = editLive ? livePayload(editLive) : null + const music = musicLive ? livePayload(musicLive) : null if (!video) { const pending = listPendingJobs() @@ -70,8 +72,9 @@ export default defineEventHandler(async (event) => { } return { - jobId: video?.jobId || edit?.jobId || null, + jobId: video?.jobId || edit?.jobId || music?.jobId || null, video, - edit + edit, + music } }) diff --git a/server/api/generate/music.post.ts b/server/api/generate/music.post.ts new file mode 100644 index 0000000..3f52742 --- /dev/null +++ b/server/api/generate/music.post.ts @@ -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)) + + 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 + } +}) diff --git a/server/api/library/tracks/[id].delete.ts b/server/api/library/tracks/[id].delete.ts new file mode 100644 index 0000000..1b65fbf --- /dev/null +++ b/server/api/library/tracks/[id].delete.ts @@ -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 } +}) diff --git a/server/api/library/tracks/[id].patch.ts b/server/api/library/tracks/[id].patch.ts new file mode 100644 index 0000000..f7431d4 --- /dev/null +++ b/server/api/library/tracks/[id].patch.ts @@ -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) +}) diff --git a/server/api/library/tracks/[id]/audio.get.ts b/server/api/library/tracks/[id]/audio.get.ts new file mode 100644 index 0000000..5751eea --- /dev/null +++ b/server/api/library/tracks/[id]/audio.get.ts @@ -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' + }) +}) diff --git a/server/api/media.get.ts b/server/api/media.get.ts index 58a001b..ad23346 100644 --- a/server/api/media.get.ts +++ b/server/api/media.get.ts @@ -11,10 +11,20 @@ export default defineEventHandler(async (event) => { const params = new URLSearchParams({ filename, subfolder, type }) const res = await comfyFetch(`/view?${params.toString()}`) if (!res.ok) { - throw createError({ statusCode: res.status, statusMessage: 'Failed to fetch video from ComfyUI' }) + throw createError({ statusCode: res.status, statusMessage: 'Failed to fetch media from ComfyUI' }) } const buf = Buffer.from(await res.arrayBuffer()) - return sendBufferWithRange(event, buf, res.headers.get('content-type') || 'video/mp4', { + const lower = filename.toLowerCase() + const fallbackType = lower.endsWith('.wav') + ? 'audio/wav' + : lower.endsWith('.flac') + ? 'audio/flac' + : lower.endsWith('.mp3') + ? 'audio/mpeg' + : lower.endsWith('.ogg') || lower.endsWith('.opus') + ? 'audio/ogg' + : 'video/mp4' + return sendBufferWithRange(event, buf, res.headers.get('content-type') || fallbackType, { 'Content-Disposition': `inline; filename="${filename}"`, 'Cache-Control': 'private, max-age=0, must-revalidate' }) diff --git a/server/assets/workflow_ace_step.json b/server/assets/workflow_ace_step.json new file mode 100644 index 0000000..783ef6c --- /dev/null +++ b/server/assets/workflow_ace_step.json @@ -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" + } + } +} diff --git a/server/utils/comfy.ts b/server/utils/comfy.ts index 9d0c0d1..d24ac2b 100644 --- a/server/utils/comfy.ts +++ b/server/utils/comfy.ts @@ -243,6 +243,58 @@ function findVideo(value: unknown, depth = 0): { filename: string; subfolder: st return null } +function isAudioFile(item: { filename?: string; format?: string } | null | undefined) { + if (!item) return false + const name = String(item.filename || '').toLowerCase() + const format = String(item.format || '').toLowerCase() + return name.endsWith('.wav') || name.endsWith('.flac') || name.endsWith('.mp3') || name.endsWith('.ogg') || name.endsWith('.opus') + || format.includes('audio') || format.includes('wav') || format.includes('flac') +} + +function normalizeAudio(item: { filename?: string; subfolder?: string; type?: string }) { + return { + filename: String(item.filename), + subfolder: String(item.subfolder || ''), + type: String(item.type || 'output') + } +} + +function findAudio(value: unknown, depth = 0): { filename: string; subfolder: string; type: string } | null { + if (!value || typeof value !== 'object' || depth > 8) return null + if (Array.isArray(value)) { + const files = value.filter((item): item is { filename?: string; format?: string; subfolder?: string; type?: string } => Boolean(item && typeof item === 'object')) + const match = files.find(item => { + const name = String(item.filename || '').toLowerCase() + return name.endsWith('.flac') || name.endsWith('.wav') + }) || files.find(isAudioFile) + if (match?.filename) return normalizeAudio(match) + for (const item of value) { + const nested = findAudio(item, depth + 1) + if (nested) return nested + } + return null + } + const record = value as { filename?: string; format?: string; subfolder?: string; type?: string } + if (record.filename && isAudioFile(record)) return normalizeAudio(record) + for (const nested of Object.values(value as Record)) { + const found = findAudio(nested, depth + 1) + if (found) return found + } + return null +} + +export function extractAudio(history: Record | null, promptId: string) { + if (!history) return null + const wrapped = history[promptId] as { outputs?: Record } | 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 | null, promptId: string) { if (!history) return null const wrapped = history[promptId] as { outputs?: Record } | undefined diff --git a/server/utils/jobs.ts b/server/utils/jobs.ts index a04d419..704979a 100644 --- a/server/utils/jobs.ts +++ b/server/utils/jobs.ts @@ -12,6 +12,7 @@ export interface JobEvent { mediaType?: string clipId?: string stillId?: string + trackId?: string hideThumbnail?: boolean error?: string elapsedMs?: number @@ -31,7 +32,7 @@ export interface JobEvent { export interface Job { id: string - kind?: 'video' | 'edit' + kind?: 'video' | 'edit' | 'music' promptId?: string clientId: string status: JobStatus @@ -41,8 +42,10 @@ export interface Job { maxStep: number startedAt: number video?: { filename: string; subfolder: string; type: string } + audio?: { filename: string; subfolder: string; type: string } clipId?: string stillId?: string + trackId?: string hideThumbnail?: boolean imageComfyHost?: string library?: { @@ -102,6 +105,12 @@ export interface Job { loraStack?: import('~/utils/loras').LoraStackItem[] shotLoras?: string[] shotLoraStacks?: import('~/utils/loras').LoraStackItem[][] + tags?: string + lyrics?: string + instrumental?: boolean + audioExt?: string + engine?: string + lyricsStrength?: number } error?: string socketReady?: boolean @@ -115,9 +124,10 @@ export interface Job { const jobs = new Map() const MAX_JOBS = 40 -export function createJob(): Job { +export function createJob(kind: Job['kind'] = 'video'): Job { const job: Job = { id: crypto.randomUUID(), + kind, clientId: crypto.randomUUID(), status: 'queued', message: 'Queued', @@ -175,6 +185,7 @@ export function listJobs() { } export function jobSnapshot(job: Job) { + const media = job.kind === 'music' ? job.audio : job.video return { jobId: job.id, kind: job.kind || 'video', @@ -186,11 +197,12 @@ export function jobSnapshot(job: Job) { maxStep: job.maxStep, promptId: job.promptId, elapsedMs: Date.now() - job.startedAt, - filename: job.library?.folderLocked ? undefined : job.video?.filename, - subfolder: job.library?.folderLocked ? undefined : job.video?.subfolder, - mediaType: job.library?.folderLocked ? undefined : job.video?.type, + filename: job.library?.folderLocked ? undefined : media?.filename, + subfolder: job.library?.folderLocked ? undefined : media?.subfolder, + mediaType: job.library?.folderLocked ? undefined : media?.type, clipId: job.clipId, stillId: job.stillId, + trackId: job.trackId, hideThumbnail: job.hideThumbnail, error: job.error, folderLocked: job.library?.folderLocked, diff --git a/server/utils/library.ts b/server/utils/library.ts index 82aaaef..1597c33 100644 --- a/server/utils/library.ts +++ b/server/utils/library.ts @@ -66,6 +66,27 @@ export interface LibraryClip { promptPost?: string } +export interface LibraryTrack { + id: string + folderId: string + name: string + tags: string + lyrics: string + duration: number + steps: number + seed: number + cfg: number + instrumental: boolean + createdAt: number + filename: string + ext: string + engine?: string + samplerName?: string + scheduler?: string + lyricsStrength?: number + comfyFilename?: string +} + export type StillRole = 'input' | 'output' export interface LibraryStill { @@ -97,6 +118,7 @@ export interface PublicFolder { unlocked: boolean clipCount: number imageCount: number + trackCount: number } export interface QueuedExtension { @@ -146,6 +168,7 @@ interface Catalog { folders: LibraryFolder[] clips: LibraryClip[] stills: LibraryStill[] + tracks: LibraryTrack[] drafts: RetryDraft[] loraAliases: Record loraTriggers: Record @@ -188,6 +211,7 @@ function emptyCatalog(): Catalog { }], clips: [], stills: [], + tracks: [], drafts: [], loraAliases: {}, loraTriggers: {}, @@ -239,6 +263,30 @@ function normalizeStill(still: LibraryStill): LibraryStill { } } +function normalizeTrack(track: LibraryTrack): LibraryTrack { + const ext = String(track.ext || track.filename || 'flac').replace(/^\./, '').toLowerCase() || 'flac' + return { + id: track.id, + folderId: track.folderId, + name: String(track.name || '').trim() || 'Untitled track', + tags: String(track.tags || ''), + lyrics: String(track.lyrics || ''), + duration: Number(track.duration) || 0, + steps: Number(track.steps) || 0, + seed: Number(track.seed) || 0, + cfg: Number(track.cfg) || 0, + instrumental: track.instrumental === true, + createdAt: track.createdAt || Date.now(), + filename: String(track.filename || `audio.${ext}`), + ext, + engine: track.engine, + samplerName: track.samplerName, + scheduler: track.scheduler, + lyricsStrength: track.lyricsStrength, + comfyFilename: track.comfyFilename + } +} + function normalizeCatalog(parsed: Partial & { passwordHash?: string | null }): Catalog { const folders = Array.isArray(parsed.folders) && parsed.folders.length ? parsed.folders.map(normalizeFolder) @@ -252,6 +300,7 @@ function normalizeCatalog(parsed: Partial & { passwordHash?: string | n folders, clips: Array.isArray(parsed.clips) ? parsed.clips : [], stills: Array.isArray(parsed.stills) ? parsed.stills.map(normalizeStill) : [], + tracks: Array.isArray(parsed.tracks) ? parsed.tracks.map(normalizeTrack) : [], drafts: Array.isArray(parsed.drafts) ? parsed.drafts : [], loraAliases: normalizeLoraAliases(parsed.loraAliases), loraTriggers: normalizeLoraTriggers(parsed.loraTriggers), @@ -284,6 +333,7 @@ function migrateLegacy(owner: string) { function ensureOwner(owner: string) { mkdirSync(join(ownerRoot(owner), 'files'), { recursive: true }) mkdirSync(join(ownerRoot(owner), 'stills'), { recursive: true }) + mkdirSync(join(ownerRoot(owner), 'tracks'), { recursive: true }) migrateLegacy(owner) } @@ -392,7 +442,11 @@ function existingOutputNames(catalog: Catalog, folderId: string) { .filter(item => item.folderId === folderId) .map(item => String(item.name || '').trim()) .filter(Boolean) - return [...stills, ...clips] + const tracks = catalog.tracks + .filter(item => item.folderId === folderId) + .map(item => String(item.name || '').trim()) + .filter(Boolean) + return [...stills, ...clips, ...tracks] } export function getLoraAliases(owner: string) { @@ -481,7 +535,7 @@ export function folderUnlocked(event: H3Event, folder: LibraryFolder) { return folderUnlocks(event)[folder.id] === passwordVersion(folder.passwordHash) } -export function publicFolder(event: H3Event, folder: LibraryFolder, clipCount = 0, imageCount = 0): PublicFolder { +export function publicFolder(event: H3Event, folder: LibraryFolder, clipCount = 0, imageCount = 0, trackCount = 0): PublicFolder { return { id: folder.id, name: folder.name, @@ -490,7 +544,8 @@ export function publicFolder(event: H3Event, folder: LibraryFolder, clipCount = protected: Boolean(folder.passwordHash), unlocked: folderUnlocked(event, folder), clipCount, - imageCount + imageCount, + trackCount } } @@ -501,6 +556,7 @@ export function publicLibrary(event: H3Event, opts?: { light?: boolean }) { const clipIds = new Set(catalog.clips.map(clip => clip.id.toLowerCase())) const clipCounts = new Map() const imageCounts = new Map() + const trackCounts = new Map() for (const clip of catalog.clips) { clipCounts.set(clip.folderId, (clipCounts.get(clip.folderId) || 0) + 1) } @@ -508,11 +564,15 @@ export function publicLibrary(event: H3Event, opts?: { light?: boolean }) { if (isPipelineStill(catalog, still, clipIds) || stillRole(still) !== 'output') continue imageCounts.set(still.folderId, (imageCounts.get(still.folderId) || 0) + 1) } + for (const track of catalog.tracks || []) { + trackCounts.set(track.folderId, (trackCounts.get(track.folderId) || 0) + 1) + } const folders = catalog.folders.map(folder => publicFolder( event, folder, clipCounts.get(folder.id) || 0, - imageCounts.get(folder.id) || 0 + imageCounts.get(folder.id) || 0, + trackCounts.get(folder.id) || 0 )) const unlockedIds = new Set(folders.filter(folder => folder.unlocked).map(folder => folder.id)) return { @@ -526,6 +586,12 @@ export function publicLibrary(event: H3Event, opts?: { light?: boolean }) { stills: catalog.stills .filter(still => unlockedIds.has(still.folderId) && !isPipelineStill(catalog, still, clipIds)) .map(still => ({ ...still, role: stillRole(still) })), + tracks: (catalog.tracks || []) + .filter(track => unlockedIds.has(track.folderId)) + .map(track => ({ + ...track, + hasAudio: light ? true : existsSync(trackAudioPath(owner, track.id, track.ext)) + })), drafts: catalog.drafts, loraAliases: catalog.loraAliases || {}, loraTriggers: catalog.loraTriggers || {}, @@ -599,15 +665,20 @@ export function deleteFolder(owner: string, id: string) { if (!folder) throw createError({ statusCode: 404, statusMessage: 'Folder not found' }) const clips = catalog.clips.filter(clip => clip.folderId === id) const stills = catalog.stills.filter(still => still.folderId === id) + const tracks = (catalog.tracks || []).filter(track => track.folderId === id) catalog.folders = catalog.folders.filter(item => item.id !== id) catalog.clips = catalog.clips.filter(clip => clip.folderId !== id) catalog.stills = catalog.stills.filter(still => still.folderId !== id) + catalog.tracks = (catalog.tracks || []).filter(track => track.folderId !== id) for (const clip of clips) { rmSync(clipDir(owner, clip.id), { recursive: true, force: true }) } for (const still of stills) { removeStillFiles(owner, still.id) } + for (const track of tracks) { + rmSync(trackDir(owner, track.id), { recursive: true, force: true }) + } return { fallbackFolderId: catalog.folders[0].id } }) } @@ -695,6 +766,25 @@ export function clipVideoPath(owner: string, id: string) { return join(clipDir(owner, id), 'video.mp4') } +export function trackDir(owner: string, id: string) { + const current = join(ownerRoot(owner), 'tracks', id) + if (existsSync(current)) return current + const legacy = join(libraryRoot(), 'tracks', id) + if (existsSync(legacy)) return legacy + return current +} + +export function trackAudioPath(owner: string, id: string, ext = 'flac') { + const dir = trackDir(owner, id) + const wanted = join(dir, `audio.${String(ext || 'flac').replace(/^\./, '')}`) + if (existsSync(wanted)) return wanted + for (const fallback of ['flac', 'wav', 'mp3', 'ogg', 'opus']) { + const path = join(dir, `audio.${fallback}`) + if (existsSync(path)) return path + } + return wanted +} + export function clipLastFramePath(owner: string, id: string) { return join(clipDir(owner, id), 'last-frame.png') } @@ -1653,6 +1743,103 @@ export function getClip(owner: string, id: string) { return clip } +export function getTrack(owner: string, id: string) { + const track = readCatalog(owner).tracks.find(item => item.id === id) + if (!track) throw createError({ statusCode: 404, statusMessage: 'Track not found' }) + return track +} + +export async function saveTrack(params: { + ownerKey: string + folderId: string + name?: string + tags: string + lyrics: string + duration: number + steps: number + seed: number + cfg: number + instrumental: boolean + audio: Buffer + ext?: string + engine?: string + samplerName?: string + scheduler?: string + lyricsStrength?: number + comfyFilename?: string +}) { + const catalog = readCatalog(params.ownerKey) + const folder = catalog.folders.find(item => item.id === params.folderId) || catalog.folders[0] + if (!folder) throw createError({ statusCode: 400, statusMessage: 'No library folder available' }) + const ext = String(params.ext || 'flac').replace(/^\./, '').toLowerCase() || 'flac' + const track: LibraryTrack = { + id: crypto.randomUUID(), + folderId: folder.id, + name: clipTitle({ name: params.name || '', prompt: params.tags }), + tags: params.tags, + lyrics: params.lyrics, + duration: params.duration, + steps: params.steps, + seed: params.seed, + cfg: params.cfg, + instrumental: params.instrumental === true, + createdAt: Date.now(), + filename: `audio.${ext}`, + ext, + engine: params.engine, + samplerName: params.samplerName, + scheduler: params.scheduler, + lyricsStrength: params.lyricsStrength, + comfyFilename: params.comfyFilename + } + mkdirSync(trackDir(params.ownerKey, track.id), { recursive: true }) + await writeFile(trackAudioPath(params.ownerKey, track.id, ext), params.audio) + if (!(typeof params.duration === 'number' && params.duration > 0)) { + try { + track.duration = await probeDuration(trackAudioPath(params.ownerKey, track.id, ext)) + } catch { + /* duration stays as posted */ + } + } + await mutate(params.ownerKey, (next) => { + if (!Array.isArray(next.tracks)) next.tracks = [] + next.tracks.unshift(track) + }) + return track +} + +export function deleteTrack(owner: string, id: string) { + return mutate(owner, (catalog) => { + const track = catalog.tracks.find(item => item.id === id) + if (!track) throw createError({ statusCode: 404, statusMessage: 'Track not found' }) + catalog.tracks = catalog.tracks.filter(item => item.id !== id) + rmSync(trackDir(owner, id), { recursive: true, force: true }) + return track + }) +} + +export function moveTrack(owner: string, id: string, folderId: string) { + return mutate(owner, (catalog) => { + const track = catalog.tracks.find(item => item.id === id) + if (!track) throw createError({ statusCode: 404, statusMessage: 'Track not found' }) + const folder = catalog.folders.find(item => item.id === folderId) + if (!folder) throw createError({ statusCode: 404, statusMessage: 'Folder not found' }) + track.folderId = folderId + return track + }) +} + +export function renameTrack(owner: string, id: string, name: string) { + return mutate(owner, (catalog) => { + const track = catalog.tracks.find(item => item.id === id) + if (!track) throw createError({ statusCode: 404, statusMessage: 'Track not found' }) + const cleaned = String(name || '').trim().slice(0, 80) + if (!cleaned) throw createError({ statusCode: 400, statusMessage: 'A name is required' }) + track.name = cleaned + return track + }) +} + function stampFamilyId(family: LibraryClip[], fallback?: string) { const familyId = family.find(item => item.familyId)?.familyId || fallback || crypto.randomUUID() for (const item of family) item.familyId = familyId @@ -1791,6 +1978,22 @@ export async function downloadComfyVideo(video: { filename: string; subfolder: s throw new Error(`Failed to fetch completed video from ComfyUI (${lastStatus})`) } +export async function downloadComfyAudio(audio: { filename: string; subfolder: string; type: string }) { + const subfolders = [...new Set([audio.subfolder, 'audio', ''])] + let lastStatus = 0 + for (const subfolder of subfolders) { + const params = new URLSearchParams({ + filename: audio.filename, + subfolder, + type: audio.type || 'output' + }) + const res = await comfyFetch(`/view?${params.toString()}`) + lastStatus = res.status + if (res.ok) return Buffer.from(await res.arrayBuffer()) + } + throw new Error(`Failed to fetch completed audio from ComfyUI (${lastStatus})`) +} + export async function importMissingComfyVideos(owner: string, folderId?: string) { const catalog = readCatalog(owner) const folder = catalog.folders.find(item => item.id === folderId) || catalog.folders[0] diff --git a/server/utils/musicChain.ts b/server/utils/musicChain.ts new file mode 100644 index 0000000..947e281 --- /dev/null +++ b/server/utils/musicChain.ts @@ -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 { + job.socketReady = false + job.saving = false + const ws = new WebSocket(comfyWsUrl(job.clientId)) + let settled = false + let finishing = false + let pollTimer: ReturnType | null = null + let timeout: ReturnType | 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[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 } + 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 +} diff --git a/server/utils/musicWorkflow.ts b/server/utils/musicWorkflow.ts new file mode 100644 index 0000000..80d421f --- /dev/null +++ b/server/utils/musicWorkflow.ts @@ -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; _meta?: { title?: string } } +type WorkflowGraph = Record + +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 = Object.fromEntries( + Object.entries(workflowAceStep as WorkflowGraph).map(([id, node]) => [id, node._meta?.title || `Node ${id}`]) +) diff --git a/server/utils/workflow.ts b/server/utils/workflow.ts index a913e96..8f4a3b2 100644 --- a/server/utils/workflow.ts +++ b/server/utils/workflow.ts @@ -2,6 +2,7 @@ import workflowV1 from '../assets/workflow_minimax_video.json' import workflowV2 from '../assets/workflow_minimax_video_v2.json' import workflowLtx from '../assets/workflow_ltx_video.json' +import { MUSIC_NODE_LABELS } from '~/server/utils/musicWorkflow' import { buildIdentityPrompt } from '~/utils/identityPrompt' import { applyUserLoraToGraph, resolveGraphLoraNames } from '~/server/utils/loras' import type { LoraStackItem } from '~/utils/loras' @@ -396,6 +397,7 @@ export const NODE_LABELS: Record = { ...Object.fromEntries(labelsFrom(workflowV1 as WorkflowGraph)), ...Object.fromEntries(labelsFrom(workflowV2 as WorkflowGraph)), ...Object.fromEntries(labelsFrom(workflowLtx as WorkflowGraph)), + ...MUSIC_NODE_LABELS, ...LABEL_OVERRIDES } diff --git a/utils/generationNotify.ts b/utils/generationNotify.ts index a4c855c..4eb29a1 100644 --- a/utils/generationNotify.ts +++ b/utils/generationNotify.ts @@ -52,12 +52,16 @@ export async function ensureGenerationNotifyPermission() { } } -export async function notifyGenerationComplete(kind: 'video' | 'image' = 'video') { +export async function notifyGenerationComplete(kind: 'video' | 'image' | 'music' = 'video') { if (!generationNotifySupported() || !generationNotifyPref()) return if (Notification.permission !== 'granted') return if (typeof document !== 'undefined' && !document.hidden && document.hasFocus()) return const title = appName() - const body = kind === 'image' ? 'Image generation is complete.' : 'Generation is complete.' + const body = kind === 'image' + ? 'Image generation is complete.' + : kind === 'music' + ? 'Music generation is complete.' + : 'Generation is complete.' const options: NotificationOptions = { body, icon: appIcon(), diff --git a/utils/libraryBrowse.ts b/utils/libraryBrowse.ts index 84f86c6..515495f 100644 --- a/utils/libraryBrowse.ts +++ b/utils/libraryBrowse.ts @@ -1,7 +1,7 @@ import type { StillGenerationSettings } from '~/utils/libraryGeneration' import type { LoraStackItem } from '~/utils/loras' -export type LibraryFilter = 'all' | 'images' | 'videos' +export type LibraryFilter = 'all' | 'images' | 'videos' | 'music' export type LibrarySort = 'date-desc' | 'date-asc' | 'name-asc' | 'name-desc' | 'type' export type LibraryFolder = { @@ -13,6 +13,7 @@ export type LibraryFolder = { unlocked: boolean clipCount?: number imageCount?: number + trackCount?: number } export type LibraryStill = { @@ -66,15 +67,37 @@ export type LibraryClip = { loraStack?: LoraStackItem[] } +export type LibraryTrack = { + id: string + folderId: string + name: string + tags: string + lyrics: string + duration: number + steps: number + seed: number + cfg: number + instrumental: boolean + createdAt: number + filename: string + ext: string + engine?: string + samplerName?: string + scheduler?: string + lyricsStrength?: number + hasAudio?: boolean +} + export type ClipGroup = { key: string; head: LibraryClip; parts: LibraryClip[] } export type StillGroup = { key: string; head: LibraryStill; parts: LibraryStill[] } export type LibraryEntry = { - kind: 'clip' | 'still' + kind: 'clip' | 'still' | 'track' key: string createdAt: number name: string group?: ClipGroup stillGroup?: StillGroup + track?: LibraryTrack } export function familyBaseName(name: string) { @@ -248,6 +271,7 @@ export function stillCollectionTitle(group: StillGroup) { export function entryTitle(entry: LibraryEntry) { if (entry.kind === 'clip' && entry.group) return collectionTitle(entry.group) + if (entry.kind === 'track' && entry.track) return entry.track.name if (entry.stillGroup) return stillCollectionTitle(entry.stillGroup) return entry.name } @@ -256,6 +280,9 @@ export function entrySearchText(entry: LibraryEntry) { if (entry.kind === 'clip' && entry.group) { return entry.group.parts.map(part => `${part.name || ''} ${part.prompt || ''}`).join(' ') } + if (entry.kind === 'track' && entry.track) { + return `${entry.track.name || ''} ${entry.track.tags || ''} ${entry.track.lyrics || ''}` + } if (entry.stillGroup) { return entry.stillGroup.parts.map(part => `${part.name || ''} ${part.prompt || ''} ${part.filename || ''}`).join(' ') } @@ -297,6 +324,7 @@ function compareName(a: string, b: string) { export function buildLibraryEntries(opts: { clips: LibraryClip[] stills: LibraryStill[] + tracks?: LibraryTrack[] folderId: string filter: LibraryFilter search: string @@ -305,7 +333,8 @@ export function buildLibraryEntries(opts: { const inFolder = (folderId: string) => !opts.folderId || opts.folderId === 'all' || folderId === opts.folderId const clips = opts.clips.filter(clip => inFolder(clip.folderId) && clip.hasVideo !== false) const stills = opts.stills.filter(still => inFolder(still.folderId) && still.role === 'output') - const videos: LibraryEntry[] = opts.filter === 'images' + const tracks = (opts.tracks || []).filter(track => inFolder(track.folderId) && track.hasAudio !== false) + const videos: LibraryEntry[] = opts.filter === 'images' || opts.filter === 'music' ? [] : groupClips(clips).map(group => ({ kind: 'clip' as const, @@ -314,7 +343,7 @@ export function buildLibraryEntries(opts: { name: collectionTitle(group), group })) - const images: LibraryEntry[] = opts.filter === 'videos' + const images: LibraryEntry[] = opts.filter === 'videos' || opts.filter === 'music' ? [] : groupStills(stills).map(group => ({ kind: 'still' as const, @@ -323,7 +352,16 @@ export function buildLibraryEntries(opts: { name: stillCollectionTitle(group), stillGroup: group })) - let entries = [...videos, ...images] + const music: LibraryEntry[] = opts.filter === 'images' || opts.filter === 'videos' + ? [] + : tracks.map(track => ({ + kind: 'track' as const, + key: `track:${track.id}`, + createdAt: track.createdAt, + name: track.name, + track + })) + let entries = [...videos, ...images, ...music] const needle = opts.search.trim().toLowerCase() if (needle) { entries = entries.filter(entry => entrySearchText(entry).toLowerCase().includes(needle)) @@ -334,7 +372,8 @@ export function buildLibraryEntries(opts: { if (sort === 'name-desc') return compareName(b.name, a.name) || b.createdAt - a.createdAt if (sort === 'date-asc') return a.createdAt - b.createdAt if (sort === 'type') { - if (a.kind !== b.kind) return a.kind === 'still' ? -1 : 1 + const order = { still: 0, clip: 1, track: 2 } as const + if (a.kind !== b.kind) return order[a.kind] - order[b.kind] return b.createdAt - a.createdAt } return b.createdAt - a.createdAt @@ -342,13 +381,15 @@ export function buildLibraryEntries(opts: { return entries } -export function folderOutputCount(folder: LibraryFolder, clips: LibraryClip[], stills: LibraryStill[], filter: LibraryFilter) { - if (!folder.unlocked) return (folder.clipCount || 0) + (folder.imageCount || 0) +export function folderOutputCount(folder: LibraryFolder, clips: LibraryClip[], stills: LibraryStill[], filter: LibraryFilter, tracks: LibraryTrack[] = []) { + if (!folder.unlocked) return (folder.clipCount || 0) + (folder.imageCount || 0) + (folder.trackCount || 0) const videos = clips.filter(clip => clip.folderId === folder.id).length const images = stills.filter(still => still.folderId === folder.id && still.role === 'output').length + const music = tracks.filter(track => track.folderId === folder.id).length if (filter === 'videos') return videos if (filter === 'images') return images - return videos + images + if (filter === 'music') return music + return videos + images + music } export function formatWhen(createdAt: number) { diff --git a/utils/libraryGeneration.ts b/utils/libraryGeneration.ts index 80f7001..62f5b65 100644 --- a/utils/libraryGeneration.ts +++ b/utils/libraryGeneration.ts @@ -280,3 +280,34 @@ export function clipSettingsRows(clip: { if (loras) rows.push({ label: 'LoRA', value: loras }) return rows } + +export function trackSettingsRows(track: { + tags?: string + lyrics?: string + duration?: number + steps?: number + seed?: number + cfg?: number + instrumental?: boolean + engine?: string + samplerName?: string + scheduler?: string + lyricsStrength?: number +}) { + const rows: SettingsRow[] = [] + if (track.engine) rows.push({ label: 'Engine', value: track.engine === 'ace-step-1.5' ? 'ACE-Step 1.5' : 'ACE-Step' }) + if (track.tags?.trim()) rows.push({ label: 'Tags', value: track.tags.trim() }) + if (track.instrumental) rows.push({ label: 'Instrumental', value: 'On' }) + else if (track.lyrics?.trim()) rows.push({ label: 'Lyrics', value: track.lyrics.trim() }) + if (typeof track.duration === 'number' && track.duration > 0) { + const rounded = Math.round(track.duration * 10) / 10 + rows.push({ label: 'Duration', value: Number.isInteger(rounded) ? `${rounded}s` : `${rounded.toFixed(1)}s` }) + } + if (track.steps != null) rows.push({ label: 'Steps', value: String(track.steps) }) + if (track.cfg != null) rows.push({ label: 'CFG', value: formatNum(track.cfg) }) + if (track.samplerName) rows.push({ label: 'Sampler', value: track.samplerName }) + if (track.scheduler) rows.push({ label: 'Scheduler', value: track.scheduler }) + if (track.lyricsStrength != null) rows.push({ label: 'Lyric strength', value: formatNum(track.lyricsStrength) }) + if (track.seed != null) rows.push({ label: 'Seed', value: String(track.seed) }) + return rows +} diff --git a/utils/music.ts b/utils/music.ts new file mode 100644 index 0000000..6dbdf20 --- /dev/null +++ b/utils/music.ts @@ -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' +} diff --git a/utils/outputNames.ts b/utils/outputNames.ts index a60157f..3b1cf7d 100644 --- a/utils/outputNames.ts +++ b/utils/outputNames.ts @@ -77,6 +77,9 @@ export function engineFilenameKey(engine: string) { if (value === 'klein') return 'klein' if (value === 'ltx') return 'ltx' if (value === 'minimax') return 'minimax' + if (value === 'ace' || value === 'ace-step') return 'ace' + if (value === 'ace15' || value === 'ace-step-1.5') return 'ace15' + if (value === 'yue') return 'yue' return sanitizeFilenameKey(value, 16) || 'gen' }
{{ clock }}
{{ error }}
{{ studioMode === 'editv2' diff --git a/pages/library.vue b/pages/library.vue index e31319e..8c8cc27 100644 --- a/pages/library.vue +++ b/pages/library.vue @@ -161,6 +161,18 @@ >
+ Tags describe style and voice. Lyrics use structure chips. Instrumental skips the vocal line. +
{{ blockReason }}
{{ status || 'Waiting for a generate.' }}
+ Saved to library. + Open Library +