Compare commits
8
Commits
840440d7ef
...
2ebc3066e6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ebc3066e6 | ||
|
|
c868605d8a | ||
|
|
1db62e9187 | ||
|
|
a216afc4ce | ||
|
|
cce1faa9b1 | ||
|
|
c50bde723f | ||
|
|
17cc07e3bc | ||
|
|
df885d5732 |
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<nav v-if="!advanced" aria-label="Generation type" class="grid grid-cols-3 gap-2 rounded-2xl border border-white/10 bg-zinc-950/50 p-2">
|
<nav v-if="!advanced" aria-label="Generation type" class="grid grid-cols-3 gap-2 rounded-2xl border border-white/10 bg-zinc-950/50 p-2">
|
||||||
<button v-for="kind in ['image', 'video'] as const" :key="kind" type="button" :aria-pressed="current === kind" class="rounded-xl px-3 py-3 font-semibold capitalize" :class="current === kind ? 'bg-amber-400 text-zinc-950' : 'text-zinc-300 hover:bg-white/5'" @click="emit('select', kind)">{{ kind }}</button>
|
<button v-for="kind in ['image', 'video'] as const" :key="kind" type="button" :aria-pressed="current === kind" class="rounded-xl px-3 py-3 font-semibold capitalize" :class="current === kind ? 'bg-amber-400 text-zinc-950' : 'text-zinc-300 hover:bg-white/5'" @click="emit('select', kind)">{{ kind }}</button>
|
||||||
<NuxtLink to="/music" class="rounded-xl px-3 py-3 text-center font-semibold" :aria-current="current === 'music' ? 'page' : undefined" :class="current === 'music' ? 'bg-amber-400 text-zinc-950' : 'text-zinc-300 hover:bg-white/5'">Music</NuxtLink>
|
<NuxtLink to="/music?engine=yue2" class="rounded-xl px-3 py-3 text-center font-semibold" :aria-current="current === 'music' ? 'page' : undefined" :class="current === 'music' ? 'bg-amber-400 text-zinc-950' : 'text-zinc-300 hover:bg-white/5'">Music</NuxtLink>
|
||||||
</nav>
|
</nav>
|
||||||
<div v-else class="mb-4 grid grid-cols-3 gap-3">
|
<div v-else class="mb-4 grid grid-cols-3 gap-3">
|
||||||
<div
|
<div
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
:class="current === 'music' ? 'border-amber-300 bg-amber-400/10 text-amber-50' : 'border-white/10 text-zinc-400'"
|
:class="current === 'music' ? 'border-amber-300 bg-amber-400/10 text-amber-50' : 'border-white/10 text-zinc-400'"
|
||||||
>
|
>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/music"
|
to="/music?engine=yue2"
|
||||||
class="block w-full px-4 pb-3 pt-4 text-left transition"
|
class="block w-full px-4 pb-3 pt-4 text-left transition"
|
||||||
:class="current === 'music' ? '' : 'hover:text-zinc-200'"
|
:class="current === 'music' ? '' : 'hover:text-zinc-200'"
|
||||||
>
|
>
|
||||||
@@ -99,35 +99,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span class="mt-3 block font-display text-lg font-bold text-zinc-100">Music</span>
|
<span class="mt-3 block font-display text-lg font-bold text-zinc-100">Music</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<div class="grid grid-cols-3 border-t border-white/10">
|
<p class="border-t border-white/10 px-4 py-2 text-xs text-zinc-400">YuE2 · 3B lyrics-to-song</p>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="border-r border-white/10 px-3 py-2.5 text-left text-sm transition"
|
|
||||||
:class="engineBtnClass(current === 'music' && musicEngine === 'ace')"
|
|
||||||
@click="pickMusic('ace')"
|
|
||||||
>
|
|
||||||
<span class="block font-semibold">ACE</span>
|
|
||||||
<span class="text-xs opacity-80">Step</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="border-r border-white/10 px-3 py-2.5 text-left text-sm transition"
|
|
||||||
:class="engineBtnClass(current === 'music' && musicEngine === 'yue')"
|
|
||||||
@click="pickMusic('yue')"
|
|
||||||
>
|
|
||||||
<span class="block font-semibold">YuE</span>
|
|
||||||
<span class="text-xs opacity-80">v1</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="px-3 py-2.5 text-left text-sm transition"
|
|
||||||
:class="engineBtnClass(current === 'music' && musicEngine === 'yue2')"
|
|
||||||
@click="pickMusic('yue2')"
|
|
||||||
>
|
|
||||||
<span class="block font-semibold">YuE2</span>
|
|
||||||
<span class="text-xs opacity-80">3B</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -140,20 +112,19 @@ const props = withDefaults(defineProps<{
|
|||||||
videoEngine?: 'minimax' | 'ltx'
|
videoEngine?: 'minimax' | 'ltx'
|
||||||
videoStart?: 'still' | 'text'
|
videoStart?: 'still' | 'text'
|
||||||
imageEngine?: 'flux' | 'krea'
|
imageEngine?: 'flux' | 'krea'
|
||||||
musicEngine?: 'ace' | 'yue' | 'yue2'
|
musicEngine?: 'yue2'
|
||||||
}>(), {
|
}>(), {
|
||||||
ltxEnabled: false,
|
ltxEnabled: false,
|
||||||
videoEngine: 'minimax',
|
videoEngine: 'minimax',
|
||||||
videoStart: 'still',
|
videoStart: 'still',
|
||||||
imageEngine: 'flux',
|
imageEngine: 'flux',
|
||||||
musicEngine: 'ace'
|
musicEngine: 'yue2'
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
select: [kind: 'video' | 'image']
|
select: [kind: 'video' | 'image']
|
||||||
'video-engine': [engine: 'minimax' | 'ltx']
|
'video-engine': [engine: 'minimax' | 'ltx']
|
||||||
'image-engine': [engine: 'flux' | 'krea']
|
'image-engine': [engine: 'flux' | 'krea']
|
||||||
'music-engine': [engine: 'ace' | 'yue' | 'yue2']
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
function engineBtnClass(active: boolean) {
|
function engineBtnClass(active: boolean) {
|
||||||
@@ -170,12 +141,4 @@ function pickImage(engine: 'flux' | 'krea') {
|
|||||||
emit('image-engine', engine)
|
emit('image-engine', engine)
|
||||||
if (props.current !== 'image') emit('select', 'image')
|
if (props.current !== 'image') emit('select', 'image')
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickMusic(engine: 'ace' | 'yue' | 'yue2') {
|
|
||||||
if (props.current === 'music') {
|
|
||||||
emit('music-engine', engine)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
void navigateTo({ path: '/music', query: { engine } })
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
+299
-55
@@ -1,101 +1,309 @@
|
|||||||
<template>
|
<template>
|
||||||
<main class="w-full min-w-0 space-y-5 p-4 md:p-6 xl:p-8">
|
<main class="w-full min-w-0 space-y-5 p-4 md:p-6 xl:p-8">
|
||||||
<header class="flex flex-wrap items-center justify-between gap-4">
|
<header class="flex flex-wrap items-end justify-between gap-4">
|
||||||
<div><h1 class="font-display text-3xl font-bold">Studio 2</h1><p class="text-sm text-zinc-400">One hero. Every shot.</p></div>
|
<div>
|
||||||
|
<h1 class="font-display text-3xl font-bold tracking-tight text-zinc-50">Studio 2</h1>
|
||||||
|
<p class="mt-1 text-sm text-zinc-400">One hero. Every shot.</p>
|
||||||
|
</div>
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
<label class="text-sm">Folder <select v-model="form.folderId" class="rounded-lg bg-zinc-800 p-2"><option v-for="f in library.folders" :value="f.id" :key="f.id">{{ f.name }}</option></select></label>
|
<label class="text-sm text-zinc-300">Folder
|
||||||
<label class="text-sm"><input v-model="form.lockFace" type="checkbox"> Lock face <span class="block text-xs text-zinc-500">Face, hair, glasses, body</span></label><label class="text-sm"><input v-model="form.lockOutfit" type="checkbox"> Lock outfit <span class="block text-xs text-zinc-500">Wardrobe from hero</span></label>
|
<select v-model="form.folderId" class="ml-2 rounded-lg border border-white/10 bg-zinc-900 px-3 py-2 outline-none ring-amber-300/40 focus:ring-2">
|
||||||
<NuxtLink to="/library" class="text-sm text-amber-200">Library</NuxtLink><NuxtLink to="/" class="text-sm text-zinc-400">Studio 1</NuxtLink>
|
<option v-for="f in library.folders" :key="f.id" :value="f.id">{{ f.name }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<nav class="flex flex-wrap items-center gap-3 text-sm" aria-label="Studio links">
|
||||||
|
<NuxtLink to="/library" class="text-amber-200 outline-none ring-amber-300/40 focus-visible:ring-2">Library</NuxtLink>
|
||||||
|
<NuxtLink to="/queue" class="text-zinc-300 outline-none ring-amber-300/40 focus-visible:ring-2">Queue</NuxtLink>
|
||||||
|
<NuxtLink to="/settings" class="text-zinc-300 outline-none ring-amber-300/40 focus-visible:ring-2">Settings</NuxtLink>
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="flex flex-wrap justify-between gap-3 rounded-xl border border-white/10 px-4 py-2 text-sm"><Studio2GpuStatus :status="gpu" /><div v-if="xaigen" class="flex gap-4"><label><input v-model="form.hideInput" type="checkbox"> Hide input stills</label><label><input v-model="hideOutput" type="checkbox"> Hide output preview</label></div></div>
|
|
||||||
<nav class="flex flex-wrap gap-2"><button v-for="task in tasks" :key="task" class="rounded-full border px-4 py-2 capitalize" :class="form.mode===task?'border-amber-300 bg-amber-300 text-black':'border-white/15'" @click="chooseTask(task)">{{ task }}</button></nav>
|
<div class="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-white/10 px-4 py-2 text-sm">
|
||||||
|
<Studio2GpuStatus :status="gpu" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="flex flex-wrap gap-2" aria-label="Tasks">
|
||||||
|
<button
|
||||||
|
v-for="task in tasks"
|
||||||
|
:key="task"
|
||||||
|
type="button"
|
||||||
|
class="rounded-full border px-4 py-2 capitalize outline-none ring-amber-300/50 focus-visible:ring-2"
|
||||||
|
:class="form.mode===task ? 'border-amber-300 bg-amber-300 text-black' : 'border-white/15 text-zinc-200 hover:border-white/30'"
|
||||||
|
:aria-pressed="form.mode===task"
|
||||||
|
@click="chooseTask(task)"
|
||||||
|
>{{ task }}</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<div class="grid min-w-0 gap-6 xl:grid-cols-[minmax(0,3fr)_minmax(24rem,2fr)] 2xl:gap-8">
|
<div class="grid min-w-0 gap-6 xl:grid-cols-[minmax(0,3fr)_minmax(24rem,2fr)] 2xl:gap-8">
|
||||||
<section class="min-w-0 space-y-4">
|
<section class="min-w-0 space-y-4">
|
||||||
<div class="flex min-h-80 items-center justify-center overflow-hidden rounded-3xl border border-white/10 bg-black">
|
<div class="relative flex min-h-80 items-center justify-center overflow-hidden rounded-3xl border border-white/10 bg-black">
|
||||||
<p v-if="selected && xaigen && (hideOutput || (selected.role==='input' && form.hideInput))" class="text-zinc-400">Output loaded · preview hidden</p>
|
<button
|
||||||
|
v-if="selected && xaigen"
|
||||||
|
type="button"
|
||||||
|
class="absolute right-3 top-3 z-10 rounded-full border border-white/20 bg-zinc-950/80 p-2 text-zinc-200 outline-none ring-amber-300/50 hover:border-amber-300/50 focus-visible:ring-2"
|
||||||
|
:aria-pressed="!isHidden(selected)"
|
||||||
|
:aria-label="isHidden(selected) ? 'Show preview' : 'Hide preview'"
|
||||||
|
@click.stop="toggleReveal(selected.id)"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">{{ isHidden(selected) ? 'Show' : 'Hide' }}</span>
|
||||||
|
</button>
|
||||||
|
<p v-if="selected && isHidden(selected)" class="text-zinc-400">{{ selected.kind==='track' ? 'Track loaded · preview hidden' : selected.role==='input' ? 'Still loaded · preview hidden' : 'Output hidden' }}</p>
|
||||||
|
<audio v-else-if="selected?.kind==='track'" :key="selected.id" :src="`/api/library/tracks/${selected.id}/audio`" controls class="w-full max-w-xl px-6" />
|
||||||
<video v-else-if="selected?.kind==='clip'" :key="selected.id" :src="`/api/library/clips/${selected.id}/video`" controls class="max-h-[65vh] w-full object-contain" />
|
<video v-else-if="selected?.kind==='clip'" :key="selected.id" :src="`/api/library/clips/${selected.id}/video`" controls class="max-h-[65vh] w-full object-contain" />
|
||||||
<img v-else-if="selected" :src="stillUrl(selected.id)" class="max-h-[65vh] max-w-full object-contain" alt="Current output">
|
<img v-else-if="selected" :src="stillUrl(selected.id)" class="max-h-[65vh] max-w-full object-contain" alt="Current output">
|
||||||
<p v-else class="text-zinc-500">Select a result to view it here</p>
|
<p v-else class="text-zinc-500">Select a result to view it here</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="selected" class="flex flex-wrap items-start gap-3"><button class="rounded-full border border-white/20 px-4 py-2 text-sm" @click="restore(selected)">Use as input</button><button v-if="selected.kind==='still'" class="rounded-full border border-white/20 px-4 py-2 text-sm" @click="Object.assign(form,useAsSource(form,selected))">Use as source still</button><button v-if="selected.kind==='still'" class="rounded-full border border-white/20 px-4 py-2 text-sm" @click="Object.assign(form,useAsHero(form,selected))">Use as hero</button><button class="rounded-full border border-white/20 px-4 py-2 text-sm" @click="detailsOpen=true">Details</button><button class="rounded-full border border-red-400/40 px-4 py-2 text-sm text-red-200" @click="deleteOutput">Delete</button><VideoUpscaleControl v-if="selected.kind==='clip'" :clip-id="selected.id" queue-only @complete="refresh" /></div>
|
|
||||||
<div class="flex min-w-0 max-w-full gap-2 overflow-x-auto" aria-label="Current family outputs"><button v-for="o in family" :key="o.id" @click="selectOutput(o)" class="shrink-0 rounded-lg border p-1" :class="selected?.id===o.id?'border-amber-300':'border-white/10'"><span v-if="previewHidden(o,xaigen,form.hideInput,hideOutput)" class="flex h-20 w-24 items-center justify-center p-1 text-xs text-zinc-400">{{ o.role==='input'?'Still loaded · preview hidden':'Output hidden' }}</span><img v-else :src="o.kind==='clip'?`/api/library/clips/${o.id}/thumb`:stillUrl(o.id)" class="h-20 w-24 rounded object-cover" alt="Family output"></button></div>
|
|
||||||
<section aria-label="Source" class="rounded-2xl border border-white/10 p-3"><h2 class="mb-3 text-xs uppercase tracking-wider text-zinc-400">Source</h2><div class="flex flex-wrap gap-4">
|
|
||||||
<Studio2AssetSlot label="Hero" :asset="stills.find((a:any)=>a.id===form.identityStillId)" :hidden="inputHidden" upload :disabled="uploading" @pick="picker='identityStillId'" @upload="upload($event,'hero')" />
|
|
||||||
<Studio2AssetSlot :label="form.mode==='extend'?'Previous clip':'Start still'" :asset="sourceSlot" :hidden="inputHidden" upload :disabled="uploading" @pick="picker=form.mode==='extend'?'startClipId':'imageAId'" @upload="upload($event,'source')" />
|
|
||||||
<Studio2AssetSlot v-if="form.mode==='compose'" label="Second still" :asset="stills.find((a:any)=>a.id===form.imageBId)" :hidden="inputHidden" @pick="picker='imageBId'" />
|
|
||||||
<Studio2AssetSlot v-if="form.mode==='refine'" label="Mask" :asset="stills.find((a:any)=>a.id===form.maskId)" :hidden="inputHidden" @pick="picker='maskId'" />
|
|
||||||
<Studio2AssetSlot v-if="video" label="End still" :asset="stills.find((a:any)=>a.id===form.endStillId)" :hidden="inputHidden" @pick="picker='endStillId'" />
|
|
||||||
</div><label v-if="form.mode==='refine'" class="mt-3 block text-sm">Mask still (white = change). On-canvas mask comes later.<select v-model="form.maskId" class="mt-1 w-full rounded-xl bg-zinc-800 p-2"><option value="">Choose mask still</option><option v-for="a in stills" :key="a.id" :value="a.id">{{ assetLabel(a) }}</option></select></label><p v-if="uploading || uploadMessage" role="status" class="mt-2 text-sm text-amber-200">{{ uploading?'Uploading image…':uploadMessage }}</p></section>
|
|
||||||
<details v-if="video" class="rounded-xl border border-white/10 p-3"><summary>Add Guide stills</summary><div v-for="(g,i) in form.guides" :key="i" class="mt-2 flex gap-2"><Studio2AssetSlot label="Guide" :asset="stills.find((a:any)=>a.id===g.stillId)" :hidden="inputHidden" @pick="picker='guide:'+i" /><input v-model.number="g.frame" type="number" min="1" aria-label="Guide frame" class="w-20 bg-zinc-800 p-2"><button @click="form.guides.splice(i,1)">Remove</button></div><button class="mt-3 text-amber-200" @click="form.guides.push({stillId:'',frame:24})">Add guide</button></details>
|
|
||||||
</section>
|
|
||||||
<section class="min-w-0 space-y-4">
|
|
||||||
|
|
||||||
<label class="block">Prompt<textarea v-model="form.promptSections.action" rows="4" class="mt-1 w-full rounded-xl bg-zinc-900 p-3" placeholder="Describe the image or the shot." /></label>
|
<div v-if="selected" class="flex flex-wrap items-start gap-3">
|
||||||
<Studio2PromptControls v-if="video" v-model="form.promptSections" /><Studio2StyleChips v-else v-model="form.imageStyles" />
|
<button v-if="selected.kind!=='track'" class="rounded-full border border-white/20 px-4 py-2 text-sm outline-none ring-amber-300/40 focus-visible:ring-2" @click="restore(selected)">Use as input</button>
|
||||||
<label v-if="video" class="block">Extra constraints (optional) — do not repeat the Prompt. Example: keep the black glasses, no extra people.<textarea v-model="form.promptSections.locks" rows="2" class="mt-1 w-full rounded-xl bg-zinc-900 p-3" /></label>
|
<button v-if="selected.kind==='still'" class="rounded-full border border-white/20 px-4 py-2 text-sm outline-none ring-amber-300/40 focus-visible:ring-2" @click="Object.assign(form,useAsSource(form,selected))">Use as source still</button>
|
||||||
<details v-if="video || form.mode==='edit'" class="rounded-xl border border-white/10 p-3"><summary>{{ video?'Queue more shots':'Queue extra edit passes' }}</summary><p class="mt-2 text-xs text-zinc-400">Each blank-line-separated block is another job. Hero and settings stay. Press Enter twice between shots. A wrapped sentence is still one shot.{{ video?'':' Each extra pass edits the previous result with Klein.' }}</p><textarea v-model="batchText" rows="5" class="mt-2 w-full rounded-xl bg-zinc-900 p-3" placeholder="First additional prompt Next additional prompt" /></details>
|
<button v-if="selected.kind==='still'" class="rounded-full border border-white/20 px-4 py-2 text-sm outline-none ring-amber-300/40 focus-visible:ring-2" @click="Object.assign(form,useAsHero(form,selected))">Use as hero</button>
|
||||||
<button class="text-sm text-amber-200" @click="settingsOpen=true">Generation settings</button>
|
<button v-if="selected.kind!=='track'" class="rounded-full border border-white/20 px-4 py-2 text-sm outline-none ring-amber-300/40 focus-visible:ring-2" @click="detailsOpen=true">Details</button>
|
||||||
<p v-if="error" role="alert" class="text-sm text-red-300">{{ error }}</p>
|
<button class="rounded-full border border-red-400/40 px-4 py-2 text-sm text-red-200 outline-none ring-amber-300/40 focus-visible:ring-2" @click="deleteOutput">Delete</button>
|
||||||
<p v-if="form.mode==='extend'" class="text-sm text-amber-200">{{ form.startClipId ? 'Extend · '+(clips.find((c:any)=>c.id===form.startClipId)?.name || form.startClipId)+' · last frame' : 'Select the clip in the family strip or Previous clip.' }}</p>
|
<VideoUpscaleControl v-if="selected.kind==='clip'" :clip-id="selected.id" queue-only @complete="refresh" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex min-w-0 max-w-full gap-2 overflow-x-auto" aria-label="Current family outputs">
|
||||||
|
<div
|
||||||
|
v-for="o in family"
|
||||||
|
:key="o.id"
|
||||||
|
class="relative shrink-0 rounded-lg border p-1"
|
||||||
|
:class="selected?.id===o.id ? 'border-amber-300' : 'border-white/10'"
|
||||||
|
>
|
||||||
|
<button type="button" class="block outline-none ring-amber-300/40 focus-visible:ring-2" @click="selectOutput(o)">
|
||||||
|
<span v-if="isHidden(o)" class="flex h-20 w-24 items-center justify-center p-1 text-xs text-zinc-400">{{ o.kind==='track' ? 'Audio hidden' : o.role==='input' ? 'Still loaded · preview hidden' : 'Output hidden' }}</span>
|
||||||
|
<span v-else-if="o.kind==='track'" class="flex h-20 w-24 items-center justify-center rounded bg-zinc-900 text-xs text-amber-100">Audio</span>
|
||||||
|
<img v-else :src="o.kind==='clip' ? `/api/library/clips/${o.id}/thumb` : stillUrl(o.id)" class="h-20 w-24 rounded object-cover" alt="Family output">
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="xaigen"
|
||||||
|
type="button"
|
||||||
|
class="absolute right-1 top-1 rounded-md border border-white/20 bg-zinc-950/80 px-1.5 py-0.5 text-[10px] text-zinc-200 outline-none ring-amber-300/50 focus-visible:ring-2"
|
||||||
|
:aria-label="isHidden(o) ? 'Show tile' : 'Hide tile'"
|
||||||
|
:aria-pressed="!isHidden(o)"
|
||||||
|
@click.stop="toggleReveal(o.id)"
|
||||||
|
>{{ isHidden(o) ? 'Show' : 'Hide' }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section v-if="form.mode!=='music'" aria-label="Source" class="rounded-2xl border border-white/10 p-3">
|
||||||
|
<div class="mb-3 flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<h2 class="text-xs uppercase tracking-wider text-zinc-400">Source</h2>
|
||||||
|
<div class="flex flex-wrap gap-4 text-sm text-zinc-300" aria-label="Identity locks">
|
||||||
|
<label class="flex items-start gap-2"><input v-model="form.lockFace" type="checkbox" class="mt-1 accent-amber-400"><span>Lock face<span class="block text-xs text-zinc-500">Face, hair, glasses, body</span></span></label>
|
||||||
|
<label class="flex items-start gap-2"><input v-model="form.lockOutfit" type="checkbox" class="mt-1 accent-amber-400"><span>Lock outfit<span class="block text-xs text-zinc-500">Wardrobe from hero</span></span></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-4">
|
||||||
|
<Studio2AssetSlot label="Hero" :asset="stills.find((a:any)=>a.id===form.identityStillId)" :hidden="slotHidden(form.identityStillId)" upload :disabled="uploading" @pick="picker='identityStillId'" @upload="upload($event,'hero')" />
|
||||||
|
<Studio2AssetSlot :label="form.mode==='extend'?'Previous clip':'Start still'" :asset="sourceSlot" :hidden="slotHidden(form.mode==='extend'?form.startClipId:form.imageAId)" upload :disabled="uploading" @pick="picker=form.mode==='extend'?'startClipId':'imageAId'" @upload="upload($event,'source')" />
|
||||||
|
<Studio2AssetSlot v-if="form.mode==='compose'" label="Second still" :asset="stills.find((a:any)=>a.id===form.imageBId)" :hidden="slotHidden(form.imageBId)" @pick="picker='imageBId'" />
|
||||||
|
<Studio2AssetSlot v-if="form.mode==='refine'" label="Mask" :asset="stills.find((a:any)=>a.id===form.maskId)" :hidden="slotHidden(form.maskId)" @pick="picker='maskId'" />
|
||||||
|
<Studio2AssetSlot v-if="video" label="End still" :asset="stills.find((a:any)=>a.id===form.endStillId)" :hidden="slotHidden(form.endStillId)" @pick="picker='endStillId'" />
|
||||||
|
</div>
|
||||||
|
<label v-if="form.mode==='refine'" class="mt-3 block text-sm">Mask still (white = change). On-canvas mask comes later.
|
||||||
|
<select v-model="form.maskId" class="mt-1 w-full rounded-xl bg-zinc-800 p-2"><option value="">Choose mask still</option><option v-for="a in stills" :key="a.id" :value="a.id">{{ assetLabel(a) }}</option></select>
|
||||||
|
</label>
|
||||||
|
<p v-if="uploading || uploadMessage" role="status" class="mt-2 text-sm text-amber-200">{{ uploading?'Uploading image…':uploadMessage }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<details v-if="video" class="rounded-xl border border-white/10 p-3"><summary>Add Guide stills</summary>
|
||||||
|
<div v-for="(g,i) in form.guides" :key="i" class="mt-2 flex gap-2">
|
||||||
|
<Studio2AssetSlot label="Guide" :asset="stills.find((a:any)=>a.id===g.stillId)" :hidden="slotHidden(g.stillId)" @pick="picker='guide:'+i" />
|
||||||
|
<input v-model.number="g.frame" type="number" min="1" aria-label="Guide frame" class="w-20 bg-zinc-800 p-2">
|
||||||
|
<button type="button" @click="form.guides.splice(i,1)">Remove</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="mt-3 text-amber-200" @click="form.guides.push({stillId:'',frame:24})">Add guide</button>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="min-w-0 space-y-4">
|
||||||
|
<template v-if="form.mode==='music'">
|
||||||
|
<p class="text-sm text-zinc-400">YuE2 · lyrics-to-song · plan → synthesize → decode</p>
|
||||||
|
<label class="block text-sm">Genre / style tags
|
||||||
|
<textarea v-model="music.tags" rows="3" class="mt-1 w-full rounded-xl bg-zinc-900 p-3 outline-none ring-amber-300/40 focus:ring-2" :placeholder="tagsPlaceholder" />
|
||||||
|
</label>
|
||||||
|
<label class="block text-sm">Lyrics
|
||||||
|
<textarea v-model="music.lyrics" rows="8" class="mt-1 w-full rounded-xl bg-zinc-900 p-3 outline-none ring-amber-300/40 focus:ring-2" :placeholder="lyricsPlaceholder" />
|
||||||
|
</label>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<label class="block text-sm">Duration (sec)
|
||||||
|
<input v-model.number="music.duration" type="number" min="30" max="150" class="mt-1 w-full rounded-xl bg-zinc-900 p-3 outline-none ring-amber-300/40 focus:ring-2">
|
||||||
|
</label>
|
||||||
|
<label class="block text-sm">Seed
|
||||||
|
<input v-model="music.seed" placeholder="random" class="mt-1 w-full rounded-xl bg-zinc-900 p-3 outline-none ring-amber-300/40 focus:ring-2">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label class="block text-sm">Track name (optional)
|
||||||
|
<input v-model="music.name" class="mt-1 w-full rounded-xl bg-zinc-900 p-3 outline-none ring-amber-300/40 focus:ring-2" placeholder="Optional track name">
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<label class="block">Prompt<textarea v-model="form.promptSections.action" rows="4" class="mt-1 w-full rounded-xl bg-zinc-900 p-3 outline-none ring-amber-300/40 focus:ring-2" placeholder="Describe the image or the shot." /></label>
|
||||||
|
<Studio2PromptControls v-if="video" v-model="form.promptSections" />
|
||||||
|
<Studio2StyleChips v-else v-model="form.imageStyles" />
|
||||||
|
<label v-if="video" class="block">Extra constraints (optional) — do not repeat the Prompt. Example: keep the black glasses, no extra people.
|
||||||
|
<textarea v-model="form.promptSections.locks" rows="2" class="mt-1 w-full rounded-xl bg-zinc-900 p-3 outline-none ring-amber-300/40 focus:ring-2" />
|
||||||
|
</label>
|
||||||
|
<details v-if="video || form.mode==='edit'" class="rounded-xl border border-white/10 p-3">
|
||||||
|
<summary>{{ video?'Queue more shots':'Queue extra edit passes' }}</summary>
|
||||||
|
<p class="mt-2 text-xs text-zinc-400">Each blank-line-separated block is another job. Hero and settings stay. Press Enter twice between shots. A wrapped sentence is still one shot.{{ video?'':' Each extra pass edits the previous result with Klein.' }}</p>
|
||||||
|
<textarea v-model="batchText" rows="5" class="mt-2 w-full rounded-xl bg-zinc-900 p-3 outline-none ring-amber-300/40 focus:ring-2" placeholder="First additional prompt Next additional prompt" />
|
||||||
|
</details>
|
||||||
|
<button type="button" class="text-sm text-amber-200 outline-none ring-amber-300/40 focus-visible:ring-2" @click="settingsOpen=true">Generation settings</button>
|
||||||
<p v-if="!video && form.engine==='krea'" class="text-sm text-zinc-400">Krea does not attach the hero identity reference.</p>
|
<p v-if="!video && form.engine==='krea'" class="text-sm text-zinc-400">Krea does not attach the hero identity reference.</p>
|
||||||
<button :disabled="submitting" @click="queue" class="w-full rounded-2xl bg-amber-300 p-4 font-semibold text-black disabled:opacity-50">{{ submitting?'Queuing…':'Queue' }}</button>
|
<p v-if="form.mode==='extend'" class="text-sm text-amber-200">{{ form.startClipId ? 'Extend · '+(clips.find((c:any)=>c.id===form.startClipId)?.name || form.startClipId)+' · last frame' : 'Select the clip in the family strip or Previous clip.' }}</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<p v-if="error" role="alert" class="text-sm text-red-300">{{ error }}</p>
|
||||||
|
<button type="button" :disabled="submitting" class="w-full rounded-2xl bg-amber-300 p-4 font-semibold text-black outline-none ring-amber-300/60 focus-visible:ring-2 disabled:opacity-50" @click="queue">{{ submitting?'Queuing…':'Queue' }}</button>
|
||||||
|
|
||||||
<h2 class="text-sm font-semibold text-zinc-300">Job queue</h2>
|
<h2 class="text-sm font-semibold text-zinc-300">Job queue</h2>
|
||||||
<div v-for="job in upscales.filter(j=>j.folderId===form.folderId && ['queued','running'].includes(j.status))" :key="job.id" class="space-y-2 rounded-xl border border-amber-300/25 bg-zinc-900 p-4 text-sm" role="status"><div class="flex justify-between gap-3"><strong>Video upscale</strong><span class="capitalize text-amber-200">{{ job.status }}</span></div><p class="truncate text-zinc-300">{{ job.name }}</p><p class="text-xs text-zinc-400">{{ job.width?job.width+' × '+job.height+' · ':'' }}{{ Math.round((job.elapsedMs||0)/1000) }}s elapsed</p><progress class="h-2 w-full accent-amber-300" max="100" :value="job.progress || 0" /><p class="text-xs text-zinc-400">{{ job.message }}</p></div>
|
<div v-for="job in upscales.filter(j=>j.folderId===form.folderId && ['queued','running'].includes(j.status))" :key="job.id" class="space-y-2 rounded-xl border border-amber-300/25 bg-zinc-900 p-4 text-sm" role="status">
|
||||||
|
<div class="flex justify-between gap-3"><strong>Video upscale</strong><span class="capitalize text-amber-200">{{ job.status }}</span></div>
|
||||||
|
<p class="truncate text-zinc-300">{{ job.name }}</p>
|
||||||
|
<p class="text-xs text-zinc-400">{{ job.width?job.width+' × '+job.height+' · ':'' }}{{ Math.round((job.elapsedMs||0)/1000) }}s elapsed</p>
|
||||||
|
<progress class="h-2 w-full accent-amber-300" max="100" :value="job.progress || 0" />
|
||||||
|
<p class="text-xs text-zinc-400">{{ job.message }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-for="job in musicJobs" :key="job.id" class="space-y-2 rounded-xl border border-white/10 p-3 text-sm" aria-live="polite">
|
||||||
|
<div class="flex justify-between gap-3">
|
||||||
|
<strong class="truncate">{{ job.name || 'YuE2' }}</strong>
|
||||||
|
<span class="capitalize text-amber-200">{{ job.status }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-zinc-400">Music · {{ job.payload?.duration || '—' }}s</p>
|
||||||
|
<p v-if="job.lastError" role="alert" class="text-red-300">{{ job.lastError }}</p>
|
||||||
|
</div>
|
||||||
<Studio2JobProgress v-for="job in benchJobs" :key="job.id" :job="job" :now="now" @select="focusJob(job)" @cancel="cancel(job)" />
|
<Studio2JobProgress v-for="job in benchJobs" :key="job.id" :job="job" :now="now" @select="focusJob(job)" @cancel="cancel(job)" />
|
||||||
<button v-if="folderJobs.some(j=>['complete','failed','cancelled'].includes(j.state))" class="text-sm text-zinc-400" @click="clearFinished">Clear finished</button>
|
<button v-if="folderJobs.some(j=>['complete','failed','cancelled'].includes(j.state)) || musicJobs.some(j=>['complete','done','error','cancelled'].includes(j.status))" type="button" class="text-sm text-zinc-400 outline-none ring-amber-300/40 focus-visible:ring-2" @click="clearFinished">Clear finished</button>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="settingsOpen || detailsOpen" class="fixed inset-0 z-50 bg-black/70" @click.self="settingsOpen=false;detailsOpen=false">
|
<div v-if="settingsOpen || detailsOpen" class="fixed inset-0 z-50 bg-black/70" @click.self="settingsOpen=false;detailsOpen=false">
|
||||||
<aside class="absolute inset-y-0 right-0 w-full max-w-md overflow-auto border-l border-white/15 bg-zinc-950 p-6"><button class="float-right" @click="settingsOpen=false;detailsOpen=false">Close</button>
|
<aside class="absolute inset-y-0 right-0 w-full max-w-md overflow-auto border-l border-white/15 bg-zinc-950 p-6">
|
||||||
<template v-if="settingsOpen"><h2 class="mb-6 text-xl font-semibold">Generation settings</h2><label class="mb-4 block">Engine<select v-model="form.engine" class="mt-1 w-full rounded bg-zinc-800 p-2"><template v-if="video"><option value="minimax">MiniMax H3</option></template><template v-else><option value="flux">Flux Klein 9B Base</option><option value="krea" :disabled="['edit','compose','iterate'].includes(form.mode)">Krea 2 (Generate / Refine)</option></template></select></label><AspectRatioPick v-model="form.settings.aspect" heading="Aspect" :options="aspectOptions" /><p class="my-3 text-sm text-zinc-400">{{ resolvedSize.width }} × {{ resolvedSize.height }} · fit with padding, never stretch</p><label v-for="key in ['steps','cfg',...(video?['duration','fps']:[])]" :key="key" class="mb-4 block capitalize">{{ key }}<input v-model.number="form.settings[key]" type="number" class="mt-1 w-full rounded bg-zinc-800 p-2"></label><label class="mb-4 block">Seed · {{ form.settings.seedMode==='locked'?'Locked':'Random each job' }}<input :value="form.settings.seed" type="number" min="0" max="2147483647" class="mt-1 w-full rounded bg-zinc-800 p-2" @input="form.settings.seed=Number(($event.target as HTMLInputElement).value);form.settings.seedMode='locked'"></label><button class="mb-4 text-sm text-amber-200" @click="form.settings.seedMode='random';form.settings.seed=randomSeed()">Random</button><label v-if="video"><input v-model="form.settings.turbo" type="checkbox"> Turbo</label><LoraSelect v-model="form.settings.loraStack" class="mt-5" :names="pickerLoras" :use="currentLoraUse" :clip-strength="true" filename-subtitle :aliases="{...loraNames.aliases,...library.loraAliases}" :triggers="{...loraNames.triggers,...library.loraTriggers}" missing-label="No cached LoRAs yet — Refresh to check Comfy" @insert-trigger="form.promptSections.action += ' '+$event" /><button class="mt-2 text-sm text-amber-200" @click="loadLoras(true)">Refresh LoRAs</button><details class="mt-6"><summary>Compiled prompt</summary><pre class="whitespace-pre-wrap text-xs text-zinc-400">{{ compiled }}</pre><p v-if="!video" class="mt-3 text-xs text-zinc-400">Negative: {{ stylePrompt(form.imageStyles,true) || 'None' }}</p></details></template>
|
<button type="button" class="float-right outline-none ring-amber-300/40 focus-visible:ring-2" @click="settingsOpen=false;detailsOpen=false">Close</button>
|
||||||
<template v-else><h2 class="mb-6 text-xl font-semibold">Output details</h2><Studio2Details :metadata="selected?.studio2" /></template>
|
<template v-if="settingsOpen">
|
||||||
|
<h2 class="mb-6 text-xl font-semibold">Generation settings</h2>
|
||||||
|
<label class="mb-4 block">Engine
|
||||||
|
<select v-model="form.engine" class="mt-1 w-full rounded bg-zinc-800 p-2 outline-none ring-amber-300/40 focus:ring-2">
|
||||||
|
<template v-if="video">
|
||||||
|
<option value="minimax">MiniMax H3</option>
|
||||||
|
<option v-if="xaigen" value="ltx">LTX Pink Cherry</option>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<option value="flux">Flux Klein 9B Base</option>
|
||||||
|
<option value="krea" :disabled="['edit','compose','iterate'].includes(form.mode)">Krea 2 (Generate / Refine)</option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<AspectRatioPick v-model="form.settings.aspect" heading="Aspect" :options="aspectOptions" />
|
||||||
|
<p class="my-3 text-sm text-zinc-400">{{ resolvedSize.width }} × {{ resolvedSize.height }} · fit with padding, never stretch</p>
|
||||||
|
<label v-for="key in ['steps','cfg',...(video?['duration','fps']:[])]" :key="key" class="mb-4 block capitalize">{{ key }}
|
||||||
|
<input v-model.number="form.settings[key]" type="number" class="mt-1 w-full rounded bg-zinc-800 p-2 outline-none ring-amber-300/40 focus:ring-2">
|
||||||
|
</label>
|
||||||
|
<label class="mb-4 block">Seed · {{ form.settings.seedMode==='locked'?'Locked':'Random each job' }}
|
||||||
|
<input :value="form.settings.seed" type="number" min="0" max="2147483647" class="mt-1 w-full rounded bg-zinc-800 p-2 outline-none ring-amber-300/40 focus:ring-2" @input="form.settings.seed=Number(($event.target as HTMLInputElement).value);form.settings.seedMode='locked'">
|
||||||
|
</label>
|
||||||
|
<button type="button" class="mb-4 text-sm text-amber-200 outline-none ring-amber-300/40 focus-visible:ring-2" @click="form.settings.seedMode='random';form.settings.seed=randomSeed()">Random</button>
|
||||||
|
<label v-if="video"><input v-model="form.settings.turbo" type="checkbox" class="accent-amber-400"> Turbo</label>
|
||||||
|
<LoraSelect v-model="form.settings.loraStack" class="mt-5" :names="pickerLoras" :use="currentLoraUse" :clip-strength="true" filename-subtitle :aliases="{...loraNames.aliases,...library.loraAliases}" :triggers="{...loraNames.triggers,...library.loraTriggers}" missing-label="No cached LoRAs yet — Refresh to check Comfy" @insert-trigger="form.promptSections.action += ' '+$event" />
|
||||||
|
<button type="button" class="mt-2 text-sm text-amber-200 outline-none ring-amber-300/40 focus-visible:ring-2" @click="loadLoras(true)">Refresh LoRAs</button>
|
||||||
|
<details class="mt-6"><summary>Compiled prompt</summary>
|
||||||
|
<pre class="whitespace-pre-wrap text-xs text-zinc-400">{{ compiled }}</pre>
|
||||||
|
<p v-if="!video" class="mt-3 text-xs text-zinc-400">Negative: {{ stylePrompt(form.imageStyles,true) || 'None' }}</p>
|
||||||
|
</details>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<h2 class="mb-6 text-xl font-semibold">Output details</h2>
|
||||||
|
<Studio2Details :metadata="selected?.studio2" />
|
||||||
|
</template>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="picker" class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-6" @click.self="picker=''">
|
<div v-if="picker" class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-6" @click.self="picker=''">
|
||||||
<section role="dialog" aria-modal="true" aria-label="Choose source asset" class="max-h-[80vh] w-full max-w-4xl overflow-auto rounded-2xl border border-white/15 bg-zinc-950 p-5"><div class="mb-4 flex justify-between"><h2>Choose {{ picker==='identityStillId'?'hero':'source' }}</h2><button @click="picker=''">Close</button></div><button class="mb-4 text-sm text-zinc-400" @click="pickAsset(null)">Clear slot</button><div class="grid grid-cols-2 gap-3 sm:grid-cols-4 lg:grid-cols-5"><button v-for="a in pickerAssets" :key="a.id" class="rounded-xl border border-white/15 p-2 text-left" @click="pickAsset(a)"><span v-if="inputHidden" class="flex h-24 items-center text-xs text-zinc-400">Still loaded · preview hidden</span><img v-else :src="a.kind==='clip'?'/api/library/clips/'+a.id+'/thumb':stillUrl(a.id)" class="h-24 w-full object-contain" alt="Source option"><span class="mt-2 block truncate text-xs">{{ assetLabel(a) }}</span></button></div></section>
|
<section role="dialog" aria-modal="true" aria-label="Choose source asset" class="max-h-[80vh] w-full max-w-4xl overflow-auto rounded-2xl border border-white/15 bg-zinc-950 p-5">
|
||||||
|
<div class="mb-4 flex justify-between"><h2>Choose {{ picker==='identityStillId'?'hero':'source' }}</h2><button type="button" @click="picker=''">Close</button></div>
|
||||||
|
<button type="button" class="mb-4 text-sm text-zinc-400" @click="pickAsset(null)">Clear slot</button>
|
||||||
|
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4 lg:grid-cols-5">
|
||||||
|
<button v-for="a in pickerAssets" :key="a.id" type="button" class="rounded-xl border border-white/15 p-2 text-left outline-none ring-amber-300/40 focus-visible:ring-2" @click="pickAsset(a)">
|
||||||
|
<span v-if="isHidden(a)" class="flex h-24 items-center text-xs text-zinc-400">Still loaded · preview hidden</span>
|
||||||
|
<img v-else :src="a.kind==='clip'?'/api/library/clips/'+a.id+'/thumb':stillUrl(a.id)" class="h-24 w-full object-contain" alt="Source option">
|
||||||
|
<span class="mt-2 block truncate text-xs">{{ assetLabel(a) }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {createLoraMemory,loraUse} from '~/shared/studio2/lora-memory.mjs'
|
import {createLoraMemory,loraUse} from '~/shared/studio2/lora-memory.mjs'
|
||||||
import {filterLorasForStudio,filterLorasForUse} from '~/utils/loras'
|
import {filterLorasForStudio,filterLorasForUse} from '~/utils/loras'
|
||||||
import {compilePrompt,sectionsFrom} from '~/shared/studio2/contracts.mjs'
|
import {compilePrompt,sectionsFrom} from '~/shared/studio2/contracts.mjs'
|
||||||
import {aspectOptions,resolveSize,bindClip,restoreAsset,extendSelected,changeSource,restoreAll,useAsHero,useAsSource} from '~/shared/studio2/input.mjs'
|
import {aspectOptions,resolveSize,bindClip,changeSource,restoreAll,useAsHero,useAsSource} from '~/shared/studio2/input.mjs'
|
||||||
import {folderAssets,newestOutput,assetLabel,reconcileSelection,activeJobs,previewHidden} from '~/shared/studio2/viewer.mjs'
|
import {folderAssets,newestOutput,assetLabel,reconcileSelection,previewHidden} from '~/shared/studio2/viewer.mjs'
|
||||||
import {queueJobs} from '~/shared/studio2/queue-view.mjs'
|
import {queueJobs} from '~/shared/studio2/queue-view.mjs'
|
||||||
import {gpuStatus} from '~/shared/studio2/status.mjs'
|
import {gpuStatus} from '~/shared/studio2/status.mjs'
|
||||||
import {applyUploadedStill} from '~/shared/studio2/upload.mjs'
|
import {applyUploadedStill} from '~/shared/studio2/upload.mjs'
|
||||||
const xaigen=useRuntimeConfig().public.studio==='xaigen',hideOutput=ref(false),picker=ref('')
|
|
||||||
const inputHidden=computed(()=>xaigen && form.hideInput)
|
|
||||||
const uploading=ref(false),uploadMessage=ref('')
|
|
||||||
import {parseBatch,stylePrompt} from '~/shared/studio2/styles.mjs'
|
import {parseBatch,stylePrompt} from '~/shared/studio2/styles.mjs'
|
||||||
import {randomSeed} from '~/shared/studio2/seed.mjs'
|
import {randomSeed} from '~/shared/studio2/seed.mjs'
|
||||||
|
import {DEFAULT_MUSIC_LYRICS,DEFAULT_MUSIC_TAGS} from '~/utils/music'
|
||||||
|
|
||||||
|
const xaigen=useRuntimeConfig().public.studio==='xaigen'
|
||||||
|
const picker=ref('')
|
||||||
|
const uploading=ref(false),uploadMessage=ref('')
|
||||||
|
const revealed=ref<Set<string>>(new Set())
|
||||||
const measured=reactive<Record<string,{width:number;height:number}>>({}),seenOutputs=new Set<string>()
|
const measured=reactive<Record<string,{width:number;height:number}>>({}),seenOutputs=new Set<string>()
|
||||||
const loraNames=ref<any>({image:[],video:[]}),now=ref(Date.now()),healthState=ref<any>(null),upscales=ref<any[]>([])
|
const loraNames=ref<any>({image:[],video:[]}),now=ref(Date.now()),healthState=ref<any>(null),upscales=ref<any[]>([])
|
||||||
const gpu=computed(()=>gpuStatus(jobs.value,healthState.value,upscales.value))
|
const gpu=computed(()=>gpuStatus(jobs.value,healthState.value,upscales.value))
|
||||||
const library=ref<any>({folders:[],stills:[],clips:[]}),jobs=ref<any[]>([]),selected=ref<any>(null)
|
const library=ref<any>({folders:[],stills:[],clips:[],tracks:[]}),jobs=ref<any[]>([]),selected=ref<any>(null)
|
||||||
const form=reactive<any>({mode:'generate',engine:'flux',imageStyles:{positive:[],negative:[]},lockFace:true,lockOutfit:true,hideInput:false,folderId:'',identityStillId:'',imageAId:'',imageBId:'',maskId:'',startClipId:'',endStillId:'',guides:[],promptSections:sectionsFrom({}),settings:{aspect:'auto',fps:24,steps:24,cfg:4,seed:null,seedMode:'random',duration:5,turbo:true,loraStack:[]}})
|
const studioQueue=ref<any[]>([])
|
||||||
|
const form=reactive<any>({mode:'generate',engine:'flux',imageStyles:{positive:[],negative:[]},lockFace:true,lockOutfit:true,hideInput:xaigen,folderId:'',identityStillId:'',imageAId:'',imageBId:'',maskId:'',startClipId:'',endStillId:'',guides:[],promptSections:sectionsFrom({}),settings:{aspect:'auto',fps:24,steps:24,cfg:4,seed:null,seedMode:'random',duration:5,turbo:true,loraStack:[]}})
|
||||||
|
const music=reactive({tags:DEFAULT_MUSIC_TAGS,lyrics:DEFAULT_MUSIC_LYRICS,duration:60,seed:'',name:''})
|
||||||
|
const tagsPlaceholder=DEFAULT_MUSIC_TAGS,lyricsPlaceholder=DEFAULT_MUSIC_LYRICS
|
||||||
let loraMemory=createLoraMemory(xaigen)
|
let loraMemory=createLoraMemory(xaigen)
|
||||||
const currentLoraUse=computed(()=>loraUse(form))
|
const currentLoraUse=computed(()=>loraUse(form))
|
||||||
const pickerLoras=computed(()=>!xaigen && currentLoraUse.value==='ltx'?[]:filterLorasForUse(filterLorasForStudio([...loraNames.value.image,...loraNames.value.video],xaigen),currentLoraUse.value))
|
const pickerLoras=computed(()=>!xaigen && currentLoraUse.value==='ltx'?[]:filterLorasForUse(filterLorasForStudio([...loraNames.value.image,...loraNames.value.video],xaigen),currentLoraUse.value))
|
||||||
watch(()=>[currentLoraUse.value,form.settings.loraStack],()=>loraMemory.sync(form),{deep:true,flush:'sync'})
|
watch(()=>[currentLoraUse.value,form.settings.loraStack],()=>loraMemory.sync(form),{deep:true,flush:'sync'})
|
||||||
const route=useRoute()
|
const route=useRoute()
|
||||||
const tasks=['generate','edit','compose','refine','video','extend'],settingsOpen=ref(false),detailsOpen=ref(false),submitting=ref(false),error=ref(''),batchText=ref('')
|
const tasks=['generate','edit','compose','refine','video','extend','music']
|
||||||
const video=computed(()=>['video','extend'].includes(form.mode)),stills=computed(()=>library.value.stills.filter((s:any)=>s.folderId===form.folderId)),clips=computed(()=>library.value.clips.filter((s:any)=>s.folderId===form.folderId)),compiled=computed(()=>compilePrompt(form.promptSections,video.value,form))
|
const settingsOpen=ref(false),detailsOpen=ref(false),submitting=ref(false),error=ref(''),batchText=ref('')
|
||||||
|
const video=computed(()=>['video','extend'].includes(form.mode))
|
||||||
|
const stills=computed(()=>library.value.stills.filter((s:any)=>s.folderId===form.folderId))
|
||||||
|
const clips=computed(()=>library.value.clips.filter((s:any)=>s.folderId===form.folderId))
|
||||||
|
const compiled=computed(()=>compilePrompt(form.promptSections,video.value,form))
|
||||||
const stillUrl=(id:string)=>`/api/library/stills/${id}`
|
const stillUrl=(id:string)=>`/api/library/stills/${id}`
|
||||||
const folderJobs=computed(()=>jobs.value.filter(j=>j.request.folderId===form.folderId))
|
const folderJobs=computed(()=>jobs.value.filter(j=>j.request.folderId===form.folderId))
|
||||||
const benchJobs=computed(()=>queueJobs(folderJobs.value))
|
const benchJobs=computed(()=>queueJobs(folderJobs.value))
|
||||||
|
const musicJobs=computed(()=>studioQueue.value.filter((j:any)=>j.kind==='music' && j.payload?.folderId===form.folderId && !['complete','done'].includes(j.status)))
|
||||||
const sourceSlot=computed(()=>{const clip=clips.value.find((a:any)=>a.id===form.startClipId);return form.mode==='extend'?(clip?{...clip,kind:'clip'}:null):inputStill.value})
|
const sourceSlot=computed(()=>{const clip=clips.value.find((a:any)=>a.id===form.startClipId);return form.mode==='extend'?(clip?{...clip,kind:'clip'}:null):inputStill.value})
|
||||||
const pickerAssets=computed(()=>picker.value==='startClipId'?clips.value.map((a:any)=>({...a,kind:'clip'})):stills.value.map((a:any)=>({...a,kind:'still'})))
|
const pickerAssets=computed(()=>picker.value==='startClipId'?clips.value.map((a:any)=>({...a,kind:'clip'})):stills.value.map((a:any)=>({...a,kind:'still'})))
|
||||||
const activeJob=computed(()=>folderJobs.value.find(j=>!['complete','failed','cancelled'].includes(j.state)))
|
|
||||||
const inputStill=computed(()=>stills.value.find((s:any)=>s.id===form.imageAId))
|
const inputStill=computed(()=>stills.value.find((s:any)=>s.id===form.imageAId))
|
||||||
const family=computed(()=>folderAssets(library.value,jobs.value,form.folderId))
|
const family=computed(()=>folderAssets(library.value,jobs.value,form.folderId))
|
||||||
const sourceAsset=computed(()=>form.startClipId?clips.value.find((c:any)=>c.id===form.startClipId):stills.value.find((s:any)=>s.id===(form.imageAId||form.identityStillId)))
|
const sourceAsset=computed(()=>form.startClipId?clips.value.find((c:any)=>c.id===form.startClipId):stills.value.find((s:any)=>s.id===(form.imageAId||form.identityStillId)))
|
||||||
const sourceSize=computed(()=>measured[sourceAsset.value?.id] || sourceAsset.value)
|
const sourceSize=computed(()=>measured[sourceAsset.value?.id] || sourceAsset.value)
|
||||||
const resolvedSize=computed(()=>resolveSize(form.settings.aspect,sourceSize.value))
|
const resolvedSize=computed(()=>resolveSize(form.settings.aspect,sourceSize.value))
|
||||||
|
|
||||||
|
function isHidden(asset:any){return previewHidden(asset,revealed.value,xaigen)}
|
||||||
|
function slotHidden(id:string){return xaigen && !!id && !revealed.value.has(id)}
|
||||||
|
function toggleReveal(id:string){
|
||||||
|
if(!id)return
|
||||||
|
const next=new Set(revealed.value)
|
||||||
|
if(next.has(id))next.delete(id);else next.add(id)
|
||||||
|
revealed.value=next
|
||||||
|
if(import.meta.client)localStorage.setItem(`studio2-revealed:${form.folderId}`,JSON.stringify([...next]))
|
||||||
|
}
|
||||||
|
function loadRevealed(folderId:string){
|
||||||
|
if(!import.meta.client||!xaigen){revealed.value=new Set();return}
|
||||||
|
try{revealed.value=new Set(JSON.parse(localStorage.getItem(`studio2-revealed:${folderId}`)||'[]'))}
|
||||||
|
catch{revealed.value=new Set()}
|
||||||
|
}
|
||||||
|
|
||||||
watch(()=>sourceAsset.value?.id,id=>{const asset=sourceAsset.value;if(!import.meta.client||!asset||measured[id])return;const media=asset.id===form.startClipId?document.createElement('video'):new Image();if(media.tagName==='VIDEO')(media as HTMLVideoElement).preload='metadata';const done=()=>{measured[asset.id]={width:(media as HTMLVideoElement).videoWidth||(media as HTMLImageElement).naturalWidth,height:(media as HTMLVideoElement).videoHeight||(media as HTMLImageElement).naturalHeight}};media.addEventListener(media.tagName==='VIDEO'?'loadedmetadata':'load',done,{once:true});media.src=media.tagName==='VIDEO'?`/api/library/clips/${asset.id}/video`:stillUrl(asset.id)})
|
watch(()=>sourceAsset.value?.id,id=>{const asset=sourceAsset.value;if(!import.meta.client||!asset||measured[id])return;const media=asset.id===form.startClipId?document.createElement('video'):new Image();if(media.tagName==='VIDEO')(media as HTMLVideoElement).preload='metadata';const done=()=>{measured[asset.id]={width:(media as HTMLVideoElement).videoWidth||(media as HTMLImageElement).naturalWidth,height:(media as HTMLVideoElement).videoHeight||(media as HTMLImageElement).naturalHeight}};media.addEventListener(media.tagName==='VIDEO'?'loadedmetadata':'load',done,{once:true});media.src=media.tagName==='VIDEO'?`/api/library/clips/${asset.id}/video`:stillUrl(asset.id)})
|
||||||
|
|
||||||
function chooseTask(task:string){
|
function chooseTask(task:string){
|
||||||
|
if(task==='music'){form.mode='music';return}
|
||||||
if(task==='extend'){
|
if(task==='extend'){
|
||||||
form.mode='extend'
|
form.mode='extend'
|
||||||
form.engine=xaigen && form.engine==='ltx'?'ltx':'minimax'
|
form.engine=xaigen && form.engine==='ltx'?'ltx':'minimax'
|
||||||
@@ -106,42 +314,78 @@ function chooseTask(task:string){
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const was=video.value;form.mode=task;form.startClipId='';if(video.value!==was){form.engine=video.value?'minimax':'flux';form.settings.steps=video.value?8:24;form.settings.cfg=video.value?1.5:4}
|
const was=video.value;form.mode=task;form.startClipId='';if(video.value!==was){form.engine=video.value?(xaigen && form.engine==='ltx'?'ltx':'minimax'):'flux';form.settings.steps=video.value?8:24;form.settings.cfg=video.value?1.5:4}
|
||||||
}
|
}
|
||||||
function chooseClip(id:string){if(!id){form.startClipId='';return}const clip=clips.value.find((c:any)=>c.id===id);if(clip){try{Object.assign(form,bindClip(form,clip,xaigen,loraMemory))}catch(e:any){error.value=e?.message||'Could not copy clip settings.';form.startClipId=clip.id;form.mode='extend';form.engine=xaigen && form.engine==='ltx'?'ltx':'minimax'}}}
|
function chooseClip(id:string){if(!id){form.startClipId='';return}const clip=clips.value.find((c:any)=>c.id===id);if(clip){try{Object.assign(form,bindClip(form,clip,xaigen,loraMemory))}catch(e:any){error.value=e?.message||'Could not copy clip settings.';form.startClipId=clip.id;form.mode='extend';form.engine=xaigen && form.engine==='ltx'?'ltx':'minimax'}}}
|
||||||
function selectOutput(item:any){selected.value=item;if(form.mode==='extend' && item?.kind==='clip')chooseClip(item.id)}
|
function selectOutput(item:any){selected.value=item;if(form.mode==='extend' && item?.kind==='clip')chooseClip(item.id)}
|
||||||
function sourceChanged(){Object.assign(form,changeSource(form))}
|
|
||||||
function restore(item:any){Object.assign(form,restoreAll(form,item,xaigen,loraMemory));form.guides=form.guides||[];batchText.value=''}
|
function restore(item:any){Object.assign(form,restoreAll(form,item,xaigen,loraMemory));form.guides=form.guides||[];batchText.value=''}
|
||||||
function focusJob(job:any){selectOutput(job.outputs?.at(-1)||null)}
|
function focusJob(job:any){selectOutput(job.outputs?.at(-1)||null)}
|
||||||
async function loadLoras(fresh=false){try{loraNames.value=await $fetch('/api/studio-2/loras'+(fresh?'?fresh=1':''))}catch{error.value='Could not refresh LoRAs. The last loaded list is still available.'}}
|
async function loadLoras(fresh=false){try{loraNames.value=await $fetch('/api/studio-2/loras'+(fresh?'?fresh=1':''))}catch{error.value='Could not refresh LoRAs. The last loaded list is still available.'}}
|
||||||
function pickAsset(asset:any){const key=picker.value;if(key.startsWith('guide:'))form.guides[Number(key.split(':')[1])].stillId=asset?.id||'';else if(key==='startClipId')chooseClip(asset?.id||'');else form[key]=asset?.id||'';picker.value=''}
|
function pickAsset(asset:any){const key=picker.value;if(key.startsWith('guide:'))form.guides[Number(key.split(':')[1])].stillId=asset?.id||'';else if(key==='startClipId')chooseClip(asset?.id||'');else form[key]=asset?.id||'';picker.value=''}
|
||||||
async function clearFinished(){await $fetch('/api/studio-2/clear-finished',{method:'POST'});await refresh()}
|
async function clearFinished(){await $fetch('/api/studio-2/clear-finished',{method:'POST'});await refresh()}
|
||||||
async function deleteOutput(){if(!selected.value)return;try{await $fetch('/api/library/'+(selected.value.kind==='clip'?'clips/':'stills/')+selected.value.id,{method:'DELETE'});await refresh()}catch(e:any){error.value=e.data?.statusMessage||e.message}}
|
async function deleteOutput(){
|
||||||
async function queue(){submitting.value=true;error.value='';try{if(form.settings.seedMode!=='locked')form.settings.seed=randomSeed();const queued=await $fetch<any>('/api/studio-2/jobs',{method:'POST',body:{...form,mode:form.mode==='edit' && parseBatch(batchText.value).length?'iterate':form.mode,hideInput:inputHidden.value,hideOutput:xaigen && hideOutput.value,promptSections:video.value?form.promptSections:{action:form.promptSections.action},settings:{...form.settings,...resolvedSize.value},batch:video.value || form.mode==='edit'?parseBatch(batchText.value):[]}});form.settings.seed=queued.seed;form.settings.width=queued.width;form.settings.height=queued.height;await refresh()}catch(e:any){error.value=e.data?.statusMessage||e.message}finally{submitting.value=false}}
|
if(!selected.value)return
|
||||||
|
try{
|
||||||
|
const kind=selected.value.kind==='clip'?'clips/':selected.value.kind==='track'?'tracks/':'stills/'
|
||||||
|
await $fetch('/api/library/'+kind+selected.value.id,{method:'DELETE'})
|
||||||
|
await refresh()
|
||||||
|
}catch(e:any){error.value=e.data?.statusMessage||e.message}
|
||||||
|
}
|
||||||
|
async function queue(){
|
||||||
|
submitting.value=true;error.value=''
|
||||||
|
try{
|
||||||
|
if(form.mode==='music'){
|
||||||
|
await $fetch('/api/generate/music',{method:'POST',body:{engine:'yue2',tags:music.tags,lyrics:music.lyrics,duration:music.duration,seed:music.seed||'random',folderId:form.folderId,name:music.name}})
|
||||||
|
await refresh()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if(form.settings.seedMode!=='locked')form.settings.seed=randomSeed()
|
||||||
|
const queued=await $fetch<any>('/api/studio-2/jobs',{method:'POST',body:{...form,mode:form.mode==='edit' && parseBatch(batchText.value).length?'iterate':form.mode,hideInput:xaigen,hideOutput:false,promptSections:video.value?form.promptSections:{action:form.promptSections.action},settings:{...form.settings,...resolvedSize.value},batch:video.value || form.mode==='edit'?parseBatch(batchText.value):[]}})
|
||||||
|
form.settings.seed=queued.seed;form.settings.width=queued.width;form.settings.height=queued.height
|
||||||
|
await refresh()
|
||||||
|
}catch(e:any){error.value=e.data?.statusMessage||e.message}
|
||||||
|
finally{submitting.value=false}
|
||||||
|
}
|
||||||
async function cancel(job:any){await $fetch('/api/studio-2/cancel',{method:'POST',body:{id:job.id}});await refresh()}
|
async function cancel(job:any){await $fetch('/api/studio-2/cancel',{method:'POST',body:{id:job.id}});await refresh()}
|
||||||
async function upload(event:Event,target:'source'|'hero'){
|
async function upload(event:Event,target:'source'|'hero'){
|
||||||
const input=event.target as HTMLInputElement,file=input.files?.[0];if(!file)return
|
const input=event.target as HTMLInputElement,file=input.files?.[0];if(!file)return
|
||||||
uploading.value=true;uploadMessage.value='';error.value=''
|
uploading.value=true;uploadMessage.value='';error.value=''
|
||||||
const body=new FormData();body.append('image',file);body.append('folderId',form.folderId);body.append('hideInput',String(form.hideInput))
|
const body=new FormData();body.append('image',file);body.append('folderId',form.folderId);body.append('hideInput',String(xaigen))
|
||||||
try{const response=await $fetch<any>('/api/studio-2/stills',{method:'POST',body});library.value=response;Object.assign(form,applyUploadedStill(form,response,target));measured[response.still.id]={width:response.still.width,height:response.still.height};uploadMessage.value=`Loaded ${response.still.name || file.name} as ${target==='hero'?'hero identity':'start still'}.`}
|
try{const response=await $fetch<any>('/api/studio-2/stills',{method:'POST',body});library.value=response;Object.assign(form,applyUploadedStill(form,response,target));measured[response.still.id]={width:response.still.width,height:response.still.height};uploadMessage.value=`Loaded ${response.still.name || file.name} as ${target==='hero'?'hero identity':'start still'}.`}
|
||||||
catch(e:any){error.value=e.data?.statusMessage||e.message}
|
catch(e:any){error.value=e.data?.statusMessage||e.message}
|
||||||
finally{uploading.value=false;input.value=''}
|
finally{uploading.value=false;input.value=''}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refresh(){
|
async function refresh(){
|
||||||
const [nextJobs,nextLibrary,nextUpscales]=await Promise.all([$fetch<any[]>('/api/studio-2/jobs'),$fetch<any>('/api/studio-2/library'),$fetch<any[]>('/api/studio-2/upscales')])
|
const [nextJobs,nextLibrary,nextUpscales,queue]=await Promise.all([
|
||||||
|
$fetch<any[]>('/api/studio-2/jobs'),
|
||||||
|
$fetch<any>('/api/studio-2/library'),
|
||||||
|
$fetch<any[]>('/api/studio-2/upscales'),
|
||||||
|
$fetch<any>('/api/studio-queue?full=1').catch(()=>({jobs:[]}))
|
||||||
|
])
|
||||||
jobs.value=nextJobs;library.value=nextLibrary;upscales.value=nextUpscales
|
jobs.value=nextJobs;library.value=nextLibrary;upscales.value=nextUpscales
|
||||||
|
studioQueue.value=Array.isArray(queue)?queue:(queue?.jobs||[])
|
||||||
const fresh=newestOutput(nextJobs,seenOutputs,form.folderId)
|
const fresh=newestOutput(nextJobs,seenOutputs,form.folderId)
|
||||||
for(const job of nextJobs)for(const output of job.outputs||[])seenOutputs.add(output.id)
|
for(const job of nextJobs)for(const output of job.outputs||[])seenOutputs.add(output.id)
|
||||||
const existingFresh=family.value.find(a=>a.id===fresh?.id)
|
const existingFresh=family.value.find(a=>a.id===fresh?.id)
|
||||||
|
const newestTrack=(library.value.tracks||[]).filter((t:any)=>t.folderId===form.folderId).sort((a:any,b:any)=>(b.createdAt||0)-(a.createdAt||0))[0]
|
||||||
|
if(newestTrack && !seenOutputs.has(newestTrack.id)){seenOutputs.add(newestTrack.id);selected.value={...newestTrack,kind:'track'};return}
|
||||||
selected.value=existingFresh || reconcileSelection(selected.value,family.value)
|
selected.value=existingFresh || reconcileSelection(selected.value,family.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(()=>form.folderId,id=>{selected.value=null;form.imageAId='';form.startClipId='';if(import.meta.client)form.identityStillId=localStorage.getItem(`studio2-hero:${id}`)||''},{flush:'sync'})
|
watch(()=>form.folderId,id=>{selected.value=null;form.imageAId='';form.startClipId='';loadRevealed(id);if(import.meta.client)form.identityStillId=localStorage.getItem(`studio2-hero:${id}`)||''},{flush:'sync'})
|
||||||
watch(()=>form.identityStillId,id=>{if(import.meta.client&&form.folderId)localStorage.setItem(`studio2-hero:${form.folderId}`,id)})
|
watch(()=>form.identityStillId,id=>{if(import.meta.client&&form.folderId)localStorage.setItem(`studio2-hero:${form.folderId}`,id)})
|
||||||
watch(hideOutput,value=>{if(import.meta.client)localStorage.setItem('studio2-hide-output',String(value))})
|
|
||||||
watch(()=>form.hideInput,value=>{if(import.meta.client)localStorage.setItem('studio2-hide-input',String(value))})
|
|
||||||
let timer:ReturnType<typeof setTimeout>,clock:ReturnType<typeof setInterval>,disposed=false
|
let timer:ReturnType<typeof setTimeout>,clock:ReturnType<typeof setInterval>,disposed=false
|
||||||
onMounted(async()=>{let storage;try{storage=localStorage}catch{};loraMemory=createLoraMemory(xaigen,storage);loraMemory.sync(form);form.settings.seed=randomSeed();form.hideInput=xaigen && localStorage.getItem('studio2-hide-input')==='true';hideOutput.value=xaigen && localStorage.getItem('studio2-hide-output')==='true';clock=setInterval(()=>{now.value=Date.now()},1000);void loadLoras();library.value=await $fetch('/api/studio-2/library');form.folderId=library.value.folders[0]?.id||'';const id=String(route.query.still||route.query.clip||'');const asset=[...library.value.stills,...library.value.clips].find((a:any)=>a.id===id);if(asset){selected.value={...asset,kind:route.query.clip?'clip':'still'};if(route.query.use==='1')restore(selected.value)};const poll=async()=>{try{await refresh();const health=await $fetch<any>('/api/health');healthState.value=health}catch(e:any){healthState.value=null;error.value=e.data?.statusMessage||e.message}if(!disposed)timer=setTimeout(poll,3000)};void poll()})
|
onMounted(async()=>{
|
||||||
|
let storage;try{storage=localStorage}catch{}
|
||||||
|
loraMemory=createLoraMemory(xaigen,storage);loraMemory.sync(form);form.settings.seed=randomSeed()
|
||||||
|
clock=setInterval(()=>{now.value=Date.now()},1000);void loadLoras()
|
||||||
|
library.value=await $fetch('/api/studio-2/library');form.folderId=library.value.folders[0]?.id||'';loadRevealed(form.folderId)
|
||||||
|
const id=String(route.query.still||route.query.clip||route.query.track||'')
|
||||||
|
const asset=[...library.value.stills,...library.value.clips,...(library.value.tracks||[])].find((a:any)=>a.id===id)
|
||||||
|
if(asset){selected.value={...asset,kind:route.query.clip?'clip':route.query.track?'track':'still'};if(route.query.use==='1'&&selected.value.kind!=='track')restore(selected.value);if(xaigen)toggleReveal(asset.id)}
|
||||||
|
if(route.query.task==='music')form.mode='music'
|
||||||
|
const poll=async()=>{try{await refresh();const health=await $fetch<any>('/api/health');healthState.value=health}catch(e:any){healthState.value=null;error.value=e.data?.statusMessage||e.message}if(!disposed)timer=setTimeout(poll,3000)};void poll()
|
||||||
|
})
|
||||||
onBeforeUnmount(()=>{disposed=true;clearTimeout(timer);clearInterval(clock)})
|
onBeforeUnmount(()=>{disposed=true;clearTimeout(timer);clearInterval(clock)})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
+30
-8
@@ -1,6 +1,26 @@
|
|||||||
# YuE2 music engine
|
# YuE2 music engine
|
||||||
|
|
||||||
`engine: "yue2"` is standalone [YuE2](https://github.com/multimodal-art-projection/YuE) (`m-a-p/YuE2-3B` + `m-a-p/YuE2-Vae`). It never submits a Comfy graph. `engine: "yue"` remains YuEGP in code. ACE / ACE 1.5 keep their Comfy workflows.
|
`engine: "yue2"` is the only music engine in the app. Standalone [YuE2](https://github.com/multimodal-art-projection/YuE) (`m-a-p/YuE2-3B` + `m-a-p/YuE2-Vae`). It never submits a Comfy graph. ACE / YuEGP are removed from the picker and generate API; leave ACE Comfy checkpoints on disk.
|
||||||
|
|
||||||
|
## Desktop agent at boot (one click)
|
||||||
|
|
||||||
|
Wrong agent on 8199 → `/yue2/status` returns `{"ok":false,"error":"not found"}` while Comfy images still work.
|
||||||
|
|
||||||
|
Fix once from this clone:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\scripts\install-comfy-host-agent-launchers.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
That rewrites Desktop, Start Menu, and **Windows Startup** shortcuts to this clone, and retargets the `AIGen Comfy Host Agent` scheduled task when it exists. Each launch **kills** any old `comfy-host-agent` / listener on 8199, then starts `scripts/comfy-host-agent.mjs` (imports `yue2-host.mjs`).
|
||||||
|
|
||||||
|
After install: double-click Desktop **Start Comfy Host Agent**, or reboot. Confirm:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET http://127.0.0.1:8199/yue2/status → 200 { configured, busy, backend: "yue2" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not guess which `node.exe` to kill — the launcher replaces it.
|
||||||
|
|
||||||
## What this host already has (5080 desktop)
|
## What this host already has (5080 desktop)
|
||||||
|
|
||||||
@@ -8,7 +28,7 @@ Ran on this machine:
|
|||||||
|
|
||||||
1. `scripts/setup-yue2.ps1` → checkout + venv + CUDA torch + weight download
|
1. `scripts/setup-yue2.ps1` → checkout + venv + CUDA torch + weight download
|
||||||
2. `scripts/cleanup-yue-v1.ps1` → named YuE v1 / YuEGP weight folders removed
|
2. `scripts/cleanup-yue-v1.ps1` → named YuE v1 / YuEGP weight folders removed
|
||||||
3. Host agent code mounts `POST/GET /yue2/jobs` (same pattern as `/yuegp/jobs`)
|
3. Host agent mounts `POST/GET /yue2/jobs`
|
||||||
|
|
||||||
Paths written:
|
Paths written:
|
||||||
|
|
||||||
@@ -23,7 +43,7 @@ Paths written:
|
|||||||
|
|
||||||
`aigen-ready.json` points `model` / `vae` at those local folders so Generate does not need a first-click HF pull. CUDA check: `torch 2.11.0+cu128` on `NVIDIA GeForce RTX 5080`.
|
`aigen-ready.json` points `model` / `vae` at those local folders so Generate does not need a first-click HF pull. CUDA check: `torch 2.11.0+cu128` on `NVIDIA GeForce RTX 5080`.
|
||||||
|
|
||||||
After deploying this branch, restart the **desktop host connector** once so it loads `yue2-host.mjs`. Do not restart Coolify from the agent; the user triggers the existing xAIGen-dev Coolify rebuild.
|
After deploying this branch, run the installer above (or the Desktop shortcut) once so 8199 serves YuE2. Do not restart Coolify from the agent; live apps that track `main` rebuild on push.
|
||||||
|
|
||||||
## Env vars
|
## Env vars
|
||||||
|
|
||||||
@@ -35,7 +55,7 @@ After deploying this branch, restart the **desktop host connector** once so it l
|
|||||||
| `YUE2_MODEL` | Local model dir or HF id |
|
| `YUE2_MODEL` | Local model dir or HF id |
|
||||||
| `YUE2_VAE` | Local VAE dir or HF id |
|
| `YUE2_VAE` | Local VAE dir or HF id |
|
||||||
|
|
||||||
App uses existing `COMFY_CONTROL_URL` + token. One GPU owner via the shared reservation (Comfy stopped while YuE2 runs). One song. Unload after decode. OOM is visible; no fallback to `yue`.
|
App uses existing `COMFY_CONTROL_URL` + token. One GPU owner via the shared reservation (Comfy stopped while YuE2 runs). One song. Unload after decode. OOM is visible; no ACE/YuEGP fallback.
|
||||||
|
|
||||||
## Call path
|
## Call path
|
||||||
|
|
||||||
@@ -60,19 +80,21 @@ Duration 30–150 s (default 60). One non-empty lyric section (same validation a
|
|||||||
- `C:\Users\ianjm\Development\YuEGP\inference\xcodec_mini_infer`
|
- `C:\Users\ianjm\Development\YuEGP\inference\xcodec_mini_infer`
|
||||||
- `C:\Users\ianjm\Development\YuEGP\aigen-jobs`
|
- `C:\Users\ianjm\Development\YuEGP\aigen-jobs`
|
||||||
|
|
||||||
HF cache YuE-s1 / YuE-s2 / xcodec entries were already absent (skipped). ACE checkpoints (`ace_step_v1_3.5b.safetensors`, `ace_step_1.5_turbo_aio.safetensors`) and other Comfy model trees were left alone. `scripts/yuegp-*` and `server/utils/yueGp.ts` remain in git.
|
HF cache YuE-s1 / YuE-s2 / xcodec entries were already absent (skipped). ACE checkpoints (`ace_step_v1_3.5b.safetensors`, `ace_step_1.5_turbo_aio.safetensors`) and other Comfy model trees were left alone on disk.
|
||||||
|
|
||||||
## Re-run setup (only if rebuilding the machine)
|
## Re-run setup (only if rebuilding the machine)
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.\scripts\setup-yue2.ps1
|
.\scripts\setup-yue2.ps1
|
||||||
.\scripts\cleanup-yue-v1.ps1
|
.\scripts\cleanup-yue-v1.ps1
|
||||||
|
.\scripts\install-comfy-host-agent-launchers.ps1
|
||||||
```
|
```
|
||||||
|
|
||||||
Setup reuses existing `models\YuE2-3B` / `YuE2-Vae` when present, then force-reinstalls CUDA torch after the editable package install.
|
Setup reuses existing `models\YuE2-3B` / `YuE2-Vae` when present, then force-reinstalls CUDA torch after the editable package install.
|
||||||
|
|
||||||
## Validation
|
## Validation
|
||||||
|
|
||||||
- JS: `node --test tests/yue2.test.mjs tests/yuegp.test.mjs`
|
- JS: `node --test tests/yue2.test.mjs`
|
||||||
- Python: `py -3 -m unittest tests.test_yue2_worker tests.test_yuegp_worker`
|
- Python: `py -3 -m unittest tests.test_yue2_worker`
|
||||||
- No full song was generated in this agent session.
|
- After agent restart: `GET /yue2/status` returns 200.
|
||||||
|
- Do not claim a song generated unless one ran after the agent restart.
|
||||||
|
|||||||
+1
-8190
File diff suppressed because it is too large
Load Diff
+24
-183
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-h-dvh min-w-0">
|
<div class="min-h-dvh min-w-0">
|
||||||
<AppHeader
|
<AppHeader
|
||||||
:subtitle="advanced ? headerSubtitle : 'Create music from text'"
|
:subtitle="advanced ? 'YuE2 · lyrics-to-song' : 'Create music from text'"
|
||||||
current="music"
|
current="music"
|
||||||
:queue-count="queueCount"
|
:queue-count="queueCount"
|
||||||
/>
|
/>
|
||||||
@@ -10,9 +10,8 @@
|
|||||||
<StudioKindCards
|
<StudioKindCards
|
||||||
:advanced="advanced"
|
:advanced="advanced"
|
||||||
current="music"
|
current="music"
|
||||||
:music-engine="engineFamily"
|
music-engine="yue2"
|
||||||
@select="goStudio"
|
@select="goStudio"
|
||||||
@music-engine="selectEngineFamily"
|
|
||||||
/>
|
/>
|
||||||
<div class="flex items-center justify-between gap-3"><span class="rounded-full border border-amber-300/60 px-4 py-2 text-sm text-amber-100">From text</span><label class="flex items-center gap-2 text-sm text-zinc-300"><input v-model="advanced" type="checkbox" class="accent-amber-400">Advanced mode</label></div>
|
<div class="flex items-center justify-between gap-3"><span class="rounded-full border border-amber-300/60 px-4 py-2 text-sm text-amber-100">From text</span><label class="flex items-center gap-2 text-sm text-zinc-300"><input v-model="advanced" type="checkbox" class="accent-amber-400">Advanced mode</label></div>
|
||||||
<div class="grid min-w-0 gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:items-stretch">
|
<div class="grid min-w-0 gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:items-stretch">
|
||||||
@@ -20,43 +19,15 @@
|
|||||||
<div>
|
<div>
|
||||||
<h2 class="font-display text-2xl font-bold">Music Generator</h2>
|
<h2 class="font-display text-2xl font-bold">Music Generator</h2>
|
||||||
<p class="mt-1 text-sm text-zinc-400">
|
<p class="mt-1 text-sm text-zinc-400">
|
||||||
Tags describe style and voice. Lyrics use structure chips. Instrumental skips the vocal line.
|
Tags describe style and voice. Lyrics use structure chips. Multiple sections are OK.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<GenerationSettings :advanced="advanced">
|
<GenerationSettings :advanced="advanced">
|
||||||
<label class="block space-y-2 text-sm"><span>Model</span><select :value="engineFamily" class="w-full rounded-xl border border-white/15 bg-zinc-900 p-3" @change="selectEngineFamily(($event.target as HTMLSelectElement).value as 'ace' | 'yue' | 'yue2')"><option value="ace">ACE-Step</option><option value="yue">YuE</option><option value="yue2">YuE2</option></select></label>
|
<p class="text-xs text-zinc-400">YuE2-3B · lyrics with section tags · plan → synthesize → decode · no score editor</p>
|
||||||
<label v-if="engineFamily === 'yue'" class="block space-y-2 text-sm">
|
|
||||||
<span>YuEGP profile</span>
|
|
||||||
<select v-model.number="yueProfile" class="w-full rounded-xl border border-white/15 bg-zinc-900 p-3">
|
|
||||||
<option :value="1">1 · Full quality · 16GB (default)</option>
|
|
||||||
<option :value="3">3 · Lower memory · Manual fallback</option>
|
|
||||||
</select>
|
|
||||||
<span class="block text-xs text-zinc-400">One lyric section. Compile off. Profile 3 is never selected automatically.</span>
|
|
||||||
</label>
|
|
||||||
<p v-else-if="engineFamily === 'yue2'" class="text-xs text-zinc-400">YuE2-3B · lyrics with section tags · plan → synthesize → decode · no score editor</p>
|
|
||||||
<div v-if="!advanced" class="space-y-4">
|
<div v-if="!advanced" class="space-y-4">
|
||||||
<label v-if="engineFamily === 'ace'" class="block text-sm">Steps<input v-model.number="steps" type="number" :min="stepsMin" :max="stepsMax" class="mt-2 w-full rounded-xl border border-white/15 bg-zinc-900 p-3"></label>
|
|
||||||
<label class="block text-sm">Seed<input v-model="seed" placeholder="random" class="mt-2 w-full rounded-xl border border-white/15 bg-zinc-900 p-3"></label>
|
<label class="block text-sm">Seed<input v-model="seed" placeholder="random" class="mt-2 w-full rounded-xl border border-white/15 bg-zinc-900 p-3"></label>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="engineFamily === 'ace'" class="flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3 text-sm">
|
|
||||||
<span class="min-w-0">
|
|
||||||
<span class="flex items-center gap-2">
|
|
||||||
<span class="block font-medium text-zinc-200">ACE-Step 1.5</span>
|
|
||||||
<InfoTip
|
|
||||||
aria-label="What ACE-Step 1.5 does"
|
|
||||||
:text="ace15Info"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<span class="mt-0.5 block text-xs text-zinc-500">Turbo AIO checkpoint. Different graph than v1, usually 8 steps.</span>
|
|
||||||
</span>
|
|
||||||
<label class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center" title="Toggle ACE-Step 1.5">
|
|
||||||
<input v-model="ace15" type="checkbox" class="peer sr-only" role="switch">
|
|
||||||
<span class="absolute inset-0 rounded-full bg-zinc-700 transition peer-checked:bg-amber-400" />
|
|
||||||
<span class="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition peer-checked:translate-x-5" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</GenerationSettings>
|
</GenerationSettings>
|
||||||
<p v-if="error" class="rounded-2xl border border-red-400/30 bg-red-400/10 px-4 py-3 text-sm text-red-200">{{ error }}</p>
|
<p v-if="error" class="rounded-2xl border border-red-400/30 bg-red-400/10 px-4 py-3 text-sm text-red-200">{{ error }}</p>
|
||||||
|
|
||||||
@@ -73,18 +44,13 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="mb-1 flex flex-wrap items-center justify-between gap-2">
|
<div class="mb-1 flex flex-wrap items-center justify-between gap-2">
|
||||||
<span class="text-sm font-medium text-zinc-300">Lyrics</span>
|
<span class="text-sm font-medium text-zinc-300">Lyrics</span>
|
||||||
<label class="flex cursor-pointer items-center gap-2 text-xs text-zinc-400">
|
|
||||||
<input v-model="instrumental" :disabled="engineFamily === 'yue' || engineFamily === 'yue2'" type="checkbox" class="accent-amber-400">
|
|
||||||
Instrumental
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-2 flex flex-wrap gap-1.5">
|
<div class="mb-2 flex flex-wrap gap-1.5">
|
||||||
<button
|
<button
|
||||||
v-for="chip in chips"
|
v-for="chip in chips"
|
||||||
:key="chip"
|
:key="chip"
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-full border border-white/10 px-2.5 py-1 text-[11px] text-zinc-300 hover:border-amber-300/50 hover:text-white disabled:opacity-40"
|
class="rounded-full border border-white/10 px-2.5 py-1 text-[11px] text-zinc-300 hover:border-amber-300/50 hover:text-white"
|
||||||
:disabled="instrumental"
|
|
||||||
@click="insertChip(chip)"
|
@click="insertChip(chip)"
|
||||||
>{{ chip }}</button>
|
>{{ chip }}</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -92,8 +58,7 @@
|
|||||||
ref="lyricsEl"
|
ref="lyricsEl"
|
||||||
v-model="lyrics"
|
v-model="lyrics"
|
||||||
rows="12"
|
rows="12"
|
||||||
class="max-h-[min(28rem,50vh)] w-full overflow-y-auto rounded-2xl border border-white/10 bg-zinc-950/70 px-4 py-3 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 [overflow-wrap:anywhere] focus:ring-2 disabled:opacity-50"
|
class="max-h-[min(28rem,50vh)] w-full overflow-y-auto rounded-2xl border border-white/10 bg-zinc-950/70 px-4 py-3 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 [overflow-wrap:anywhere] focus:ring-2"
|
||||||
:disabled="instrumental"
|
|
||||||
:placeholder="lyricsPlaceholder"
|
:placeholder="lyricsPlaceholder"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -109,19 +74,7 @@
|
|||||||
:max="durationMax"
|
:max="durationMax"
|
||||||
step="5"
|
step="5"
|
||||||
>
|
>
|
||||||
<span class="mt-1 block text-[11px] text-zinc-500">{{ engineFamily === 'yue' ? 'Approximate length · one lyric section' : engineFamily === 'yue2' ? 'Approximate length · multiple sections OK' : `${durationMin}–${durationMax} seconds` }}</span>
|
<span class="mt-1 block text-[11px] text-zinc-500">Approximate length · multiple sections OK</span>
|
||||||
</label>
|
|
||||||
<label v-if="advanced && engineFamily === 'ace'" class="block text-sm">
|
|
||||||
<span class="mb-1 block font-medium text-zinc-300">Steps · {{ steps }}</span>
|
|
||||||
<input
|
|
||||||
v-model.number="steps"
|
|
||||||
type="range"
|
|
||||||
class="aigen-slider w-full"
|
|
||||||
:min="stepsMin"
|
|
||||||
:max="stepsMax"
|
|
||||||
step="1"
|
|
||||||
>
|
|
||||||
<span class="mt-1 block text-[11px] text-zinc-500">{{ ace15 ? '8 is the usual 1.5 turbo range' : '30–50 is the usual range' }}</span>
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -209,7 +162,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<p v-if="busy && !queued && jobId" class="text-xs text-zinc-400">
|
<p v-if="busy && !queued && jobId" class="text-xs text-zinc-400">
|
||||||
{{ musicElapsed }} elapsed · {{ musicActivityText }}
|
{{ musicElapsed }} elapsed · {{ musicActivityText }}
|
||||||
<span v-if="(activeMusicEngine === 'yue' || activeMusicEngine === 'yue2') && !indeterminateMusic" class="mt-1 block">Progress is for the current stage.</span>
|
<span v-if="!indeterminateMusic" class="mt-1 block">Progress is for the current stage.</span>
|
||||||
<span v-if="indeterminateMusic" class="mt-1 block">Waiting for a progress report.</span>
|
<span v-if="indeterminateMusic" class="mt-1 block">Waiting for a progress report.</span>
|
||||||
</p>
|
</p>
|
||||||
<AudioPlayer
|
<AudioPlayer
|
||||||
@@ -236,18 +189,10 @@ const { advanced } = useStudioLayout()
|
|||||||
import {
|
import {
|
||||||
DEFAULT_MUSIC_LYRICS,
|
DEFAULT_MUSIC_LYRICS,
|
||||||
DEFAULT_MUSIC_TAGS,
|
DEFAULT_MUSIC_TAGS,
|
||||||
MUSIC_CFG_DEFAULT,
|
|
||||||
MUSIC_CFG_DEFAULT_15,
|
|
||||||
MUSIC_DURATION_DEFAULT,
|
MUSIC_DURATION_DEFAULT,
|
||||||
MUSIC_DURATION_MAX,
|
MUSIC_DURATION_MAX,
|
||||||
MUSIC_DURATION_MIN,
|
MUSIC_DURATION_MIN,
|
||||||
MUSIC_STEPS_DEFAULT,
|
|
||||||
MUSIC_STEPS_DEFAULT_15,
|
|
||||||
MUSIC_STEPS_MAX,
|
|
||||||
MUSIC_STEPS_MIN,
|
|
||||||
MUSIC_STRUCTURE_CHIPS,
|
MUSIC_STRUCTURE_CHIPS,
|
||||||
parseMusicEngine,
|
|
||||||
type MusicEngine,
|
|
||||||
yueLyricsProblem,
|
yueLyricsProblem,
|
||||||
} from '~/utils/music'
|
} from '~/utils/music'
|
||||||
import { ensureGenerationNotifyPermission, notifyGenerationComplete } from '~/utils/generationNotify'
|
import { ensureGenerationNotifyPermission, notifyGenerationComplete } from '~/utils/generationNotify'
|
||||||
@@ -259,25 +204,16 @@ const tagsPlaceholder = DEFAULT_MUSIC_TAGS
|
|||||||
const lyricsPlaceholder = DEFAULT_MUSIC_LYRICS
|
const lyricsPlaceholder = DEFAULT_MUSIC_LYRICS
|
||||||
const chips = MUSIC_STRUCTURE_CHIPS
|
const chips = MUSIC_STRUCTURE_CHIPS
|
||||||
const durationMin = MUSIC_DURATION_MIN
|
const durationMin = MUSIC_DURATION_MIN
|
||||||
const durationMax = computed(() => (engineFamily.value === 'yue' || engineFamily.value === 'yue2') ? 150 : MUSIC_DURATION_MAX)
|
const durationMax = MUSIC_DURATION_MAX
|
||||||
const stepsMin = MUSIC_STEPS_MIN
|
|
||||||
const stepsMax = MUSIC_STEPS_MAX
|
|
||||||
|
|
||||||
const ace15Info = 'Turns on ACE-Step 1.5 Turbo, the all-in-one checkpoint already on Beast. 1.5 uses a different Comfy graph than v1: a 1.5 text encoder (tags, BPM, key, duration) and EmptyAceStep1.5LatentAudio, usually with 8 sampler steps and CFG 1. Songs often come back faster and cleaner. Lyrics-strength from v1 does not apply. Leave this off to stay on ACE-Step v1 3.5B, which is the graph that already generated on this GPU.'
|
|
||||||
|
|
||||||
const tags = ref(DEFAULT_MUSIC_TAGS)
|
const tags = ref(DEFAULT_MUSIC_TAGS)
|
||||||
const lyrics = ref(DEFAULT_MUSIC_LYRICS)
|
const lyrics = ref(DEFAULT_MUSIC_LYRICS)
|
||||||
const instrumental = ref(false)
|
|
||||||
const duration = ref(MUSIC_DURATION_DEFAULT)
|
const duration = ref(MUSIC_DURATION_DEFAULT)
|
||||||
const steps = ref(MUSIC_STEPS_DEFAULT)
|
|
||||||
const seed = ref('')
|
const seed = ref('')
|
||||||
const name = ref('')
|
const name = ref('')
|
||||||
const folderId = ref('')
|
const folderId = ref('')
|
||||||
const folders = ref<LibraryFolder[]>([])
|
const folders = ref<LibraryFolder[]>([])
|
||||||
const lyricsEl = ref<HTMLTextAreaElement | null>(null)
|
const lyricsEl = ref<HTMLTextAreaElement | null>(null)
|
||||||
const ace15 = ref(false)
|
|
||||||
const engineFamily = ref<'ace' | 'yue' | 'yue2'>('ace')
|
|
||||||
const yueProfile = ref<1 | 3>(1)
|
|
||||||
const busy = ref(false)
|
const busy = ref(false)
|
||||||
const forceClearing = ref(false)
|
const forceClearing = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
@@ -285,10 +221,9 @@ const status = ref('')
|
|||||||
const progress = ref(0)
|
const progress = ref(0)
|
||||||
const musicClock = ref(Date.now())
|
const musicClock = ref(Date.now())
|
||||||
const musicStartedAt = ref(0)
|
const musicStartedAt = ref(0)
|
||||||
const activeMusicEngine = ref('')
|
|
||||||
const musicActivity = ref<{ checkedAt: number; running: boolean } | null>(null)
|
const musicActivity = ref<{ checkedAt: number; running: boolean } | null>(null)
|
||||||
let musicClockTimer: ReturnType<typeof setInterval> | null = null
|
let musicClockTimer: ReturnType<typeof setInterval> | null = null
|
||||||
const indeterminateMusic = computed(() => !queued.value && (activeMusicEngine.value === 'yue' || activeMusicEngine.value === 'yue2') && progress.value < 98 && !musicActivity.value?.running)
|
const indeterminateMusic = computed(() => !queued.value && progress.value < 98 && !musicActivity.value?.running)
|
||||||
const recentMusicCheck = computed(() => Boolean(musicActivity.value?.running && musicClock.value - musicActivity.value.checkedAt < 15000))
|
const recentMusicCheck = computed(() => Boolean(musicActivity.value?.running && musicClock.value - musicActivity.value.checkedAt < 15000))
|
||||||
const musicElapsed = computed(() => {
|
const musicElapsed = computed(() => {
|
||||||
const seconds = musicStartedAt.value ? Math.max(0, Math.floor((musicClock.value - musicStartedAt.value) / 1000)) : 0
|
const seconds = musicStartedAt.value ? Math.max(0, Math.floor((musicClock.value - musicStartedAt.value) / 1000)) : 0
|
||||||
@@ -318,64 +253,19 @@ let queuePoll: ReturnType<typeof setInterval> | null = null
|
|||||||
let recoverPoll: ReturnType<typeof setInterval> | null = null
|
let recoverPoll: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
const blocked = computed(() => Boolean(blockReason.value))
|
const blocked = computed(() => Boolean(blockReason.value))
|
||||||
const headerSubtitle = computed(() => {
|
|
||||||
if (engineFamily.value === 'yue2') return 'YuE2 · lyrics-to-song'
|
|
||||||
if (engineFamily.value === 'yue') return 'YuE · lyrics-to-song'
|
|
||||||
return ace15.value ? 'ACE-Step 1.5 · vocals' : 'ACE-Step · vocals'
|
|
||||||
})
|
|
||||||
const selectedEngine = computed<MusicEngine>(() => {
|
|
||||||
if (engineFamily.value === 'yue2') return 'yue2'
|
|
||||||
if (engineFamily.value === 'yue') return 'yue'
|
|
||||||
return ace15.value ? 'ace-step-1.5' : 'ace-step'
|
|
||||||
})
|
|
||||||
const blockReason = computed(() => {
|
const blockReason = computed(() => {
|
||||||
if (!folderId.value) return 'Choose a library folder before generating.'
|
if (!folderId.value) return 'Choose a library folder before generating.'
|
||||||
if ((engineFamily.value === 'yue' || engineFamily.value === 'yue2') && instrumental.value) return 'YuE requires lyrics. Use ACE for instrumental music.'
|
if (duration.value > 150) return 'YuE2 supports up to 150 seconds.'
|
||||||
if ((engineFamily.value === 'yue' || engineFamily.value === 'yue2') && duration.value > 150) return 'YuE supports up to 150 seconds per section.'
|
|
||||||
if (!tags.value.trim()) return 'Add genre and style tags.'
|
if (!tags.value.trim()) return 'Add genre and style tags.'
|
||||||
if (engineFamily.value === 'yue') { const problem = yueLyricsProblem(lyrics.value); if (problem) return problem }
|
const problem = yueLyricsProblem(lyrics.value)
|
||||||
if (engineFamily.value === 'yue2' && !lyrics.value.trim()) return 'Write lyrics for YuE2.'
|
if (problem) return problem
|
||||||
if (!instrumental.value && !lyrics.value.trim()) return 'Write lyrics, or turn on Instrumental.'
|
|
||||||
return ''
|
return ''
|
||||||
})
|
})
|
||||||
|
|
||||||
function selectEngineFamily(family: 'ace' | 'yue' | 'yue2') {
|
|
||||||
engineFamily.value = family
|
|
||||||
if (family === 'yue' || family === 'yue2') { instrumental.value = false; duration.value = Math.min(150, duration.value) }
|
|
||||||
if (family === 'yue' && lyrics.value === DEFAULT_MUSIC_LYRICS) lyrics.value = '[Verse 1]\n'
|
|
||||||
if (family === 'ace' && ace15.value && steps.value === MUSIC_STEPS_DEFAULT) {
|
|
||||||
steps.value = MUSIC_STEPS_DEFAULT_15
|
|
||||||
}
|
|
||||||
if (family === 'ace' && !ace15.value && steps.value === MUSIC_STEPS_DEFAULT_15) {
|
|
||||||
steps.value = MUSIC_STEPS_DEFAULT
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function goStudio(kind: 'video' | 'image') {
|
function goStudio(kind: 'video' | 'image') {
|
||||||
void navigateTo({ path: '/', query: { studio: kind } })
|
void navigateTo({ path: '/', query: { studio: kind } })
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyEngine(engine: MusicEngine) {
|
|
||||||
if (engine === 'yue2') {
|
|
||||||
engineFamily.value = 'yue2'
|
|
||||||
ace15.value = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (engine === 'yue') {
|
|
||||||
engineFamily.value = 'yue'
|
|
||||||
ace15.value = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
engineFamily.value = 'ace'
|
|
||||||
ace15.value = engine === 'ace-step-1.5'
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(ace15, (on) => {
|
|
||||||
if (engineFamily.value !== 'ace') return
|
|
||||||
if (on && (steps.value === MUSIC_STEPS_DEFAULT || steps.value >= 30)) steps.value = MUSIC_STEPS_DEFAULT_15
|
|
||||||
if (!on && steps.value === MUSIC_STEPS_DEFAULT_15) steps.value = MUSIC_STEPS_DEFAULT
|
|
||||||
})
|
|
||||||
|
|
||||||
function insertChip(chip: string) {
|
function insertChip(chip: string) {
|
||||||
const el = lyricsEl.value
|
const el = lyricsEl.value
|
||||||
const token = `${chip}\n`
|
const token = `${chip}\n`
|
||||||
@@ -461,11 +351,7 @@ async function attachLiveMusic(opts: {
|
|||||||
if (typeof opts.progress === 'number') progress.value = opts.progress
|
if (typeof opts.progress === 'number') progress.value = opts.progress
|
||||||
if (opts.message) status.value = opts.message
|
if (opts.message) status.value = opts.message
|
||||||
else if (!status.value || /queueing|waiting for a generate|waiting in the job queue|waiting for gpu/i.test(status.value)) {
|
else if (!status.value || /queueing|waiting for a generate|waiting in the job queue|waiting for gpu/i.test(status.value)) {
|
||||||
status.value = selectedEngine.value === 'yue'
|
status.value = 'YuE2 is generating your song'
|
||||||
? 'YuEGP is generating your song'
|
|
||||||
: selectedEngine.value === 'yue2'
|
|
||||||
? 'YuE2 is generating your song'
|
|
||||||
: 'Generating…'
|
|
||||||
}
|
}
|
||||||
if (jobId.value !== liveId) {
|
if (jobId.value !== liveId) {
|
||||||
musicActivity.value = null
|
musicActivity.value = null
|
||||||
@@ -508,11 +394,7 @@ async function refreshStudioQueue() {
|
|||||||
busy.value = true
|
busy.value = true
|
||||||
status.value = musicRow.status === 'held'
|
status.value = musicRow.status === 'held'
|
||||||
? 'Paused in the job queue'
|
? 'Paused in the job queue'
|
||||||
: (selectedEngine.value === 'yue'
|
: 'Waiting for the GPU to run YuE2'
|
||||||
? 'Waiting for the GPU to run YuEGP'
|
|
||||||
: selectedEngine.value === 'yue2'
|
|
||||||
? 'Waiting for the GPU to run YuE2'
|
|
||||||
: 'Waiting in the job queue…')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!studioJobId.value) return
|
if (!studioJobId.value) return
|
||||||
@@ -522,11 +404,7 @@ async function refreshStudioQueue() {
|
|||||||
queued.value = false
|
queued.value = false
|
||||||
}
|
}
|
||||||
if (jobId.value && /waiting in the job queue/i.test(status.value)) {
|
if (jobId.value && /waiting in the job queue/i.test(status.value)) {
|
||||||
status.value = selectedEngine.value === 'yue'
|
status.value = 'YuE2 is generating your song'
|
||||||
? 'YuEGP is generating your song'
|
|
||||||
: selectedEngine.value === 'yue2'
|
|
||||||
? 'YuE2 is generating your song'
|
|
||||||
: 'Generating…'
|
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -535,11 +413,7 @@ async function refreshStudioQueue() {
|
|||||||
busy.value = true
|
busy.value = true
|
||||||
status.value = row.status === 'held'
|
status.value = row.status === 'held'
|
||||||
? 'Paused in the job queue'
|
? 'Paused in the job queue'
|
||||||
: (selectedEngine.value === 'yue'
|
: 'Waiting for the GPU to run YuE2'
|
||||||
? 'Waiting for the GPU to run YuEGP'
|
|
||||||
: selectedEngine.value === 'yue2'
|
|
||||||
? 'Waiting for the GPU to run YuE2'
|
|
||||||
: 'Waiting in the job queue…')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (row.status === 'running' && row.liveJobId) {
|
if (row.status === 'running' && row.liveJobId) {
|
||||||
@@ -574,7 +448,6 @@ async function resumeActiveMusic() {
|
|||||||
|
|
||||||
function applyEvent(payload: Record<string, any>) {
|
function applyEvent(payload: Record<string, any>) {
|
||||||
if (typeof payload.elapsedMs === 'number') musicStartedAt.value = Date.now() - payload.elapsedMs
|
if (typeof payload.elapsedMs === 'number') musicStartedAt.value = Date.now() - payload.elapsedMs
|
||||||
if (payload.engine) activeMusicEngine.value = payload.engine
|
|
||||||
if (payload.musicActivity) musicActivity.value = payload.musicActivity
|
if (payload.musicActivity) musicActivity.value = payload.musicActivity
|
||||||
if (payload.message) status.value = payload.message
|
if (payload.message) status.value = payload.message
|
||||||
if (typeof payload.progress === 'number') progress.value = payload.progress
|
if (typeof payload.progress === 'number') progress.value = payload.progress
|
||||||
@@ -682,13 +555,7 @@ async function generate() {
|
|||||||
trackId.value = ''
|
trackId.value = ''
|
||||||
jobId.value = ''
|
jobId.value = ''
|
||||||
studioJobId.value = ''
|
studioJobId.value = ''
|
||||||
status.value = engineFamily.value === 'yue'
|
status.value = 'Queueing YuE2…'
|
||||||
? 'Queueing YuE…'
|
|
||||||
: engineFamily.value === 'yue2'
|
|
||||||
? 'Queueing YuE2…'
|
|
||||||
: ace15.value
|
|
||||||
? 'Queueing ACE-Step 1.5…'
|
|
||||||
: 'Queueing ACE-Step…'
|
|
||||||
try {
|
try {
|
||||||
const started = await $fetch<{ jobId: string; studioJobId?: string; queued?: boolean; name?: string }>(
|
const started = await $fetch<{ jobId: string; studioJobId?: string; queued?: boolean; name?: string }>(
|
||||||
'/api/generate/music',
|
'/api/generate/music',
|
||||||
@@ -698,14 +565,10 @@ async function generate() {
|
|||||||
tags: tags.value,
|
tags: tags.value,
|
||||||
lyrics: lyrics.value,
|
lyrics: lyrics.value,
|
||||||
duration: duration.value,
|
duration: duration.value,
|
||||||
steps: steps.value,
|
|
||||||
seed: seed.value || 'random',
|
seed: seed.value || 'random',
|
||||||
cfg: selectedEngine.value === 'ace-step-1.5' ? MUSIC_CFG_DEFAULT_15 : MUSIC_CFG_DEFAULT,
|
|
||||||
instrumental: instrumental.value,
|
|
||||||
folderId: folderId.value,
|
folderId: folderId.value,
|
||||||
name: name.value.trim(),
|
name: name.value.trim(),
|
||||||
engine: selectedEngine.value,
|
engine: 'yue2'
|
||||||
...(engineFamily.value === 'yue' ? { yueProfile: yueProfile.value } : {})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -731,10 +594,6 @@ async function generate() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(instrumental, (on) => {
|
|
||||||
if (on && !lyrics.value.trim()) lyrics.value = DEFAULT_MUSIC_LYRICS
|
|
||||||
})
|
|
||||||
|
|
||||||
function applyIncomingMusic() {
|
function applyIncomingMusic() {
|
||||||
try {
|
try {
|
||||||
const genRaw = sessionStorage.getItem(LOAD_GENERATION_PAYLOAD_KEY)
|
const genRaw = sessionStorage.getItem(LOAD_GENERATION_PAYLOAD_KEY)
|
||||||
@@ -745,14 +604,10 @@ function applyIncomingMusic() {
|
|||||||
const payload = loaded.payload
|
const payload = loaded.payload
|
||||||
if (typeof payload.prompt === 'string') tags.value = payload.prompt
|
if (typeof payload.prompt === 'string') tags.value = payload.prompt
|
||||||
if (typeof payload.lyrics === 'string') lyrics.value = payload.lyrics
|
if (typeof payload.lyrics === 'string') lyrics.value = payload.lyrics
|
||||||
instrumental.value = payload.instrumental === true
|
if (typeof payload.duration === 'number') duration.value = Math.min(MUSIC_DURATION_MAX, payload.duration)
|
||||||
if (typeof payload.duration === 'number') duration.value = payload.duration
|
|
||||||
if (typeof payload.steps === 'number') steps.value = payload.steps
|
|
||||||
if (payload.seed != null) seed.value = String(payload.seed)
|
if (payload.seed != null) seed.value = String(payload.seed)
|
||||||
if (typeof payload.name === 'string') name.value = payload.name
|
if (typeof payload.name === 'string') name.value = payload.name
|
||||||
if (payload.folderId && folders.value.some(folder => folder.id === payload.folderId)) folderId.value = payload.folderId
|
if (payload.folderId && folders.value.some(folder => folder.id === payload.folderId)) folderId.value = payload.folderId
|
||||||
if (payload.yueProfile === 1 || payload.yueProfile === 3) yueProfile.value = payload.yueProfile
|
|
||||||
if (payload.musicEngine || payload.engine) applyEngine(parseMusicEngine(payload.musicEngine || payload.engine))
|
|
||||||
status.value = 'Loaded from generation log'
|
status.value = 'Loaded from generation log'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -767,24 +622,16 @@ function applyIncomingMusic() {
|
|||||||
name?: string
|
name?: string
|
||||||
tags?: string
|
tags?: string
|
||||||
lyrics?: string
|
lyrics?: string
|
||||||
instrumental?: boolean
|
|
||||||
duration?: number
|
duration?: number
|
||||||
steps?: number
|
|
||||||
seed?: string
|
seed?: string
|
||||||
folderId?: string
|
folderId?: string
|
||||||
engine?: string
|
|
||||||
yueProfile?: 1 | 3
|
|
||||||
}
|
}
|
||||||
if (typeof loaded.tags === 'string') tags.value = loaded.tags
|
if (typeof loaded.tags === 'string') tags.value = loaded.tags
|
||||||
if (typeof loaded.lyrics === 'string') lyrics.value = loaded.lyrics
|
if (typeof loaded.lyrics === 'string') lyrics.value = loaded.lyrics
|
||||||
instrumental.value = loaded.instrumental === true
|
if (typeof loaded.duration === 'number') duration.value = Math.min(MUSIC_DURATION_MAX, loaded.duration)
|
||||||
if (typeof loaded.duration === 'number') duration.value = loaded.duration
|
|
||||||
if (typeof loaded.steps === 'number') steps.value = loaded.steps
|
|
||||||
if (typeof loaded.seed === 'string') seed.value = loaded.seed
|
if (typeof loaded.seed === 'string') seed.value = loaded.seed
|
||||||
if (typeof loaded.name === 'string') name.value = loaded.name
|
if (typeof loaded.name === 'string') name.value = loaded.name
|
||||||
if (loaded.folderId && folders.value.some(folder => folder.id === loaded.folderId)) folderId.value = loaded.folderId
|
if (loaded.folderId && folders.value.some(folder => folder.id === loaded.folderId)) folderId.value = loaded.folderId
|
||||||
if (loaded.engine) applyEngine(parseMusicEngine(loaded.engine))
|
|
||||||
yueProfile.value = loaded.yueProfile === 3 ? 3 : 1
|
|
||||||
status.value = 'Loaded from the queue'
|
status.value = 'Loaded from the queue'
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
@@ -800,12 +647,8 @@ function applyPreset() {
|
|||||||
const settings = preset.settings as MusicPresetSettings
|
const settings = preset.settings as MusicPresetSettings
|
||||||
if (typeof settings.tags === 'string') tags.value = settings.tags
|
if (typeof settings.tags === 'string') tags.value = settings.tags
|
||||||
if (typeof settings.lyrics === 'string') lyrics.value = settings.lyrics
|
if (typeof settings.lyrics === 'string') lyrics.value = settings.lyrics
|
||||||
instrumental.value = settings.instrumental === true
|
if (typeof settings.duration === 'number') duration.value = Math.min(MUSIC_DURATION_MAX, settings.duration)
|
||||||
if (typeof settings.duration === 'number') duration.value = settings.duration
|
|
||||||
if (typeof settings.steps === 'number') steps.value = settings.steps
|
|
||||||
if (typeof settings.seed === 'number') seed.value = String(settings.seed)
|
if (typeof settings.seed === 'number') seed.value = String(settings.seed)
|
||||||
if (settings.engine) applyEngine(settings.engine)
|
|
||||||
yueProfile.value = settings.yueProfile === 3 ? 3 : 1
|
|
||||||
name.value = preset.name
|
name.value = preset.name
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -813,14 +656,12 @@ onMounted(async () => {
|
|||||||
musicClockTimer = setInterval(() => { musicClock.value = Date.now() }, 1000)
|
musicClockTimer = setInterval(() => { musicClock.value = Date.now() }, 1000)
|
||||||
await loadLibrary()
|
await loadLibrary()
|
||||||
await loadMusicPresets()
|
await loadMusicPresets()
|
||||||
const route = useRoute()
|
|
||||||
const engineQ = String(route.query.engine || '').toLowerCase()
|
|
||||||
if (engineQ === 'yue' || engineQ === 'yue2' || engineQ === 'ace') selectEngineFamily(engineQ as 'ace' | 'yue' | 'yue2')
|
|
||||||
applyIncomingMusic()
|
applyIncomingMusic()
|
||||||
await resumeActiveMusic()
|
await resumeActiveMusic()
|
||||||
await refreshStudioQueue()
|
await refreshStudioQueue()
|
||||||
stopQueuePoll()
|
stopQueuePoll()
|
||||||
queuePoll = setInterval(() => { void refreshStudioQueue() }, 8000)
|
queuePoll = setInterval(() => { void refreshStudioQueue() }, 8000)
|
||||||
|
const route = useRoute()
|
||||||
const query = route.query
|
const query = route.query
|
||||||
if (typeof query.track === 'string' && query.track) {
|
if (typeof query.track === 'string' && query.track) {
|
||||||
audioUrl.value = `/api/library/tracks/${query.track}/audio`
|
audioUrl.value = `/api/library/tracks/${query.track}/audio`
|
||||||
@@ -834,7 +675,7 @@ onMounted(async () => {
|
|||||||
if (recovered?.trackId && /recovered/i.test(String(recovered.message || ''))) {
|
if (recovered?.trackId && /recovered/i.test(String(recovered.message || ''))) {
|
||||||
applyEvent({ ...recovered, type: 'complete' })
|
applyEvent({ ...recovered, type: 'complete' })
|
||||||
}
|
}
|
||||||
} catch { /* nothing waiting in ComfyUI */ }
|
} catch { /* nothing waiting */ }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -663,7 +663,7 @@ function progressLine(queue: Queue) {
|
|||||||
|
|
||||||
function jobLine(job: StudioJobRow) {
|
function jobLine(job: StudioJobRow) {
|
||||||
if (job.kind === 'music') {
|
if (job.kind === 'music') {
|
||||||
if (job.status === 'waiting') return job.cutIn ? 'Next after the current shot · ACE-Step' : 'Waiting · ACE-Step'
|
if (job.status === 'waiting') return job.cutIn ? 'Next after the current shot · YuE2' : 'Waiting · YuE2'
|
||||||
if (job.status === 'held') return 'Paused'
|
if (job.status === 'held') return 'Paused'
|
||||||
if (job.status === 'error') return job.lastError || 'Failed'
|
if (job.status === 'error') return job.lastError || 'Failed'
|
||||||
return 'Generating track'
|
return 'Generating track'
|
||||||
@@ -1237,7 +1237,7 @@ async function clearAll() {
|
|||||||
async function forceClearAll() {
|
async function forceClearAll() {
|
||||||
if (forceClearing.value) return
|
if (forceClearing.value) return
|
||||||
if (!confirm(
|
if (!confirm(
|
||||||
'Force clear cancels every running and waiting job (including stuck YuE/music), wipes shot batches, and kills Comfy if it is wedged. Saved library clips stay. Continue?'
|
'Force clear cancels every running and waiting job (including stuck YuE2/music), wipes shot batches, and kills Comfy if it is wedged. Saved library clips stay. Continue?'
|
||||||
)) return
|
)) return
|
||||||
forceClearing.value = true
|
forceClearing.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
|
|||||||
+8192
File diff suppressed because it is too large
Load Diff
+2
-3
@@ -1,7 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="config.public.studio === 'xaigen'" class="border-b border-amber-400/20 bg-zinc-950 px-6 py-3 text-sm text-amber-100">xAIGen 2.0 development preview · Image and MiniMax tools are available here. <NuxtLink to="/" class="underline">Open the existing studio for LTX and music</NuxtLink>.</div>
|
<div />
|
||||||
<Studio2Bench />
|
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const config = useRuntimeConfig()
|
await navigateTo('/', { replace: true })
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { purgeStudio2Files } from './studio2-purge.mjs'
|
|||||||
import { stableMemoryArgs } from './comfy-memory-policy.mjs'
|
import { stableMemoryArgs } from './comfy-memory-policy.mjs'
|
||||||
import { createGpuReservation } from './gpu-reservation.mjs'
|
import { createGpuReservation } from './gpu-reservation.mjs'
|
||||||
import { createGpuProxy } from './gpu-proxy.mjs'
|
import { createGpuProxy } from './gpu-proxy.mjs'
|
||||||
import { createYueGpHost } from './yuegp-host.mjs'
|
|
||||||
import { createYue2Host } from './yue2-host.mjs'
|
import { createYue2Host } from './yue2-host.mjs'
|
||||||
import http from 'node:http'
|
import http from 'node:http'
|
||||||
import net from 'node:net'
|
import net from 'node:net'
|
||||||
@@ -148,7 +147,7 @@ let proxyTarget = 0
|
|||||||
|
|
||||||
function ensureProxyListening() {
|
function ensureProxyListening() {
|
||||||
if (proxyServer) return
|
if (proxyServer) return
|
||||||
proxyServer = createGpuProxy({ target: () => proxyTarget, reservation: gpuReservation, authorized, markWork, externalBusy: () => yueGp.busy() || yue2.busy() || upscale.busy() })
|
proxyServer = createGpuProxy({ target: () => proxyTarget, reservation: gpuReservation, authorized, markWork, externalBusy: () => yue2.busy() || upscale.busy() })
|
||||||
proxyServer.on('error', (error) => {
|
proxyServer.on('error', (error) => {
|
||||||
console.log(JSON.stringify({ src: 'comfy-host-agent', event: 'proxy-error', error: String(error.message || error) }))
|
console.log(JSON.stringify({ src: 'comfy-host-agent', event: 'proxy-error', error: String(error.message || error) }))
|
||||||
})
|
})
|
||||||
@@ -820,7 +819,7 @@ function purgeDesktopFiles(body) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const gpuReservation = createGpuReservation({ idle: async () => {
|
const gpuReservation = createGpuReservation({ idle: async () => {
|
||||||
if (yueGp.busy() || yue2.busy() || upscale.busy()) return false
|
if (yue2.busy() || upscale.busy()) return false
|
||||||
if ((await trainingLock()).busy) return false
|
if ((await trainingLock()).busy) return false
|
||||||
const healthy = await syncProxy()
|
const healthy = await syncProxy()
|
||||||
if (healthy) {
|
if (healthy) {
|
||||||
@@ -831,33 +830,16 @@ const gpuReservation = createGpuReservation({ idle: async () => {
|
|||||||
return !(await processUp()) && !(await pythonMainUp().catch(() => true))
|
return !(await processUp()) && !(await pythonMainUp().catch(() => true))
|
||||||
} })
|
} })
|
||||||
|
|
||||||
const yueGp = createYueGpHost({
|
|
||||||
leaseValid: lease => gpuReservation.isOwner(lease),
|
|
||||||
prepare: async () => {
|
|
||||||
if ((await trainingLock()).busy) throw new Error('GPU is busy with training.')
|
|
||||||
if (yue2.busy()) throw new Error('YuE2 is using the GPU.')
|
|
||||||
const healthy = await syncProxy()
|
|
||||||
if (healthy) {
|
|
||||||
const queue = await fetchLocalQueue(healthy)
|
|
||||||
if (!queue.ok || queue.running || queue.pending) throw new Error('Comfy is busy; YuEGP cannot start.')
|
|
||||||
await stopComfyProcesses()
|
|
||||||
markAsleep()
|
|
||||||
}
|
|
||||||
if (await pythonMainUp()) throw new Error('Comfy has not stopped; retry after the GPU is free.')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const yue2 = createYue2Host({
|
const yue2 = createYue2Host({
|
||||||
leaseValid: lease => gpuReservation.isOwner(lease),
|
leaseValid: lease => gpuReservation.isOwner(lease),
|
||||||
prepare: async () => {
|
prepare: async () => {
|
||||||
if ((await trainingLock()).busy) throw new Error('GPU is busy with training.')
|
if ((await trainingLock()).busy) throw new Error('GPU is busy with training.')
|
||||||
if (yueGp.busy()) throw new Error('YuEGP is using the GPU.')
|
|
||||||
const healthy = await syncProxy()
|
const healthy = await syncProxy()
|
||||||
if (healthy) {
|
if (healthy) {
|
||||||
const queue = await fetchLocalQueue(healthy)
|
const queue = await fetchLocalQueue(healthy)
|
||||||
if (!queue.ok || queue.running || queue.pending) throw new Error('Comfy is busy; YuE2 cannot start.')
|
if (!queue.ok || queue.running || queue.pending) throw new Error('Comfy is busy; YuE2 cannot start.')
|
||||||
}
|
}
|
||||||
// Same gate as YuEGP: stop Comfy and refuse to launch while its Python still owns VRAM.
|
// Stop Comfy and refuse to launch while its Python still owns VRAM.
|
||||||
if (healthy || await processUp() || await pythonMainUp().catch(() => false)) {
|
if (healthy || await processUp() || await pythonMainUp().catch(() => false)) {
|
||||||
await stopComfyProcesses()
|
await stopComfyProcesses()
|
||||||
markAsleep()
|
markAsleep()
|
||||||
@@ -880,23 +862,7 @@ async function handleControl(req, res) {
|
|||||||
if (match && req.method === 'GET' && !match[2]) { const job = upscale.read(match[1]); return json(res, job ? 200 : 404, job || { error: 'Upscale job not found' }) }
|
if (match && req.method === 'GET' && !match[2]) { const job = upscale.read(match[1]); return json(res, job ? 200 : 404, job || { error: 'Upscale job not found' }) }
|
||||||
return json(res, 404, { error: 'Unknown upscale endpoint' })
|
return json(res, 404, { error: 'Unknown upscale endpoint' })
|
||||||
}
|
}
|
||||||
if (url.pathname.startsWith('/yuegp/')) {
|
|
||||||
const match = url.pathname.match(/^\/yuegp\/jobs\/([a-zA-Z0-9-]{12,80})(\/audio|\/cancel)?$/)
|
|
||||||
if (req.method === 'GET' && url.pathname === '/yuegp/status') return json(res, 200, { configured: yueGp.configured(), busy: yueGp.busy(), backend: 'yuegp' })
|
|
||||||
if (req.method === 'POST' && url.pathname === '/yuegp/jobs') return json(res, 200, await yueGp.start(await readJson(req), String(req.headers['x-aigen-gpu-lease'] || '')))
|
|
||||||
if (match && req.method === 'POST' && match[2] === '/cancel') return json(res, 200, await yueGp.cancel(match[1]))
|
|
||||||
if (match && req.method === 'GET' && match[2] === '/audio') {
|
|
||||||
const path = yueGp.audio(match[1])
|
|
||||||
return path ? streamFile(res, path) : json(res, 404, { error: 'Audio not ready' })
|
|
||||||
}
|
|
||||||
if (match && req.method === 'GET' && !match[2]) {
|
|
||||||
const job = yueGp.read(match[1])
|
|
||||||
return json(res, job ? 200 : 404, job || { error: 'YuEGP job not found' })
|
|
||||||
}
|
|
||||||
return json(res, 404, { error: 'Unknown YuEGP endpoint' })
|
|
||||||
}
|
|
||||||
if (url.pathname.startsWith('/yue2/')) {
|
if (url.pathname.startsWith('/yue2/')) {
|
||||||
if (yueGp.busy()) return json(res, 409, { message: 'YuEGP is using the GPU.' })
|
|
||||||
const match = url.pathname.match(/^\/yue2\/jobs\/([a-zA-Z0-9-]{12,80})(\/audio|\/cancel)?$/)
|
const match = url.pathname.match(/^\/yue2\/jobs\/([a-zA-Z0-9-]{12,80})(\/audio|\/cancel)?$/)
|
||||||
if (req.method === 'GET' && url.pathname === '/yue2/status') return json(res, 200, { configured: yue2.configured(), busy: yue2.busy(), backend: 'yue2' })
|
if (req.method === 'GET' && url.pathname === '/yue2/status') return json(res, 200, { configured: yue2.configured(), busy: yue2.busy(), backend: 'yue2' })
|
||||||
if (req.method === 'POST' && url.pathname === '/yue2/jobs') return json(res, 200, await yue2.start(await readJson(req), String(req.headers['x-aigen-gpu-lease'] || '')))
|
if (req.method === 'POST' && url.pathname === '/yue2/jobs') return json(res, 200, await yue2.start(await readJson(req), String(req.headers['x-aigen-gpu-lease'] || '')))
|
||||||
@@ -942,14 +908,12 @@ async function handleControl(req, res) {
|
|||||||
proxyPort,
|
proxyPort,
|
||||||
gpu: gpuReservation.availability(),
|
gpu: gpuReservation.availability(),
|
||||||
training: { busy: lastTraining.busy },
|
training: { busy: lastTraining.busy },
|
||||||
yuegp: { busy: yueGp.busy(), configured: yueGp.configured() },
|
|
||||||
yue2: { busy: yue2.busy(), configured: yue2.configured() },
|
yue2: { busy: yue2.busy(), configured: yue2.configured() },
|
||||||
upscale: { busy: upscale.busy(), engine: 'realesrgan-rife', local: true }
|
upscale: { busy: upscale.busy(), engine: 'realesrgan-rife', local: true }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (req.method === 'POST' && url.pathname === '/start') {
|
if (req.method === 'POST' && url.pathname === '/start') {
|
||||||
if (upscale.busy()) return json(res, 409, { message: 'Local upscale is using the GPU.' })
|
if (upscale.busy()) return json(res, 409, { message: 'Local upscale is using the GPU.' })
|
||||||
if (yueGp.busy()) return json(res, 409, { message: 'YuEGP is using the GPU.' })
|
|
||||||
if (yue2.busy()) return json(res, 409, { message: 'YuE2 is using the GPU.' })
|
if (yue2.busy()) return json(res, 409, { message: 'YuE2 is using the GPU.' })
|
||||||
const training = await trainingLock()
|
const training = await trainingLock()
|
||||||
if (training.busy) {
|
if (training.busy) {
|
||||||
@@ -1052,7 +1016,7 @@ const server = http.createServer(async (req, res) => {
|
|||||||
} else await handleControl(req, res)
|
} else await handleControl(req, res)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
req.resume()
|
req.resume()
|
||||||
if ((String(req.url || '').startsWith('/yuegp/') || String(req.url || '').startsWith('/yue2/') || String(req.url || '').startsWith('/upscale/')) && !res.headersSent) return json(res, error.statusCode || 400, { error: error.message || 'Music host request failed' })
|
if ((String(req.url || '').startsWith('/yue2/') || String(req.url || '').startsWith('/upscale/')) && !res.headersSent) return json(res, error.statusCode || 400, { error: error.message || 'Music host request failed' })
|
||||||
if (!res.headersSent) json(res, error.statusCode || 400, { ok: false, message: error.statusCode === 409 ? 'GPU is in use. Waiting for availability.' : 'GPU coordination request failed.' })
|
if (!res.headersSent) json(res, error.statusCode || 400, { ok: false, message: error.statusCode === 409 ? 'GPU is in use. Waiting for availability.' : 'GPU coordination request failed.' })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,26 +1,48 @@
|
|||||||
# Installs Desktop + Start Menu shortcuts that start the Comfy host agent
|
# Installs Desktop, Start Menu, and Windows Startup shortcuts that start THIS
|
||||||
# without Cursor. The scheduled task still starts the agent at logon.
|
# clone's Comfy host agent (YuE2) and replace any stale process on 8199.
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
$root = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
|
$root = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
|
||||||
$vbs = Join-Path $root 'scripts\launch-comfy-host-agent.vbs'
|
$vbs = Join-Path $root 'scripts\launch-comfy-host-agent.vbs'
|
||||||
|
$startPs1 = Join-Path $root 'scripts\start-comfy-host-agent.ps1'
|
||||||
if (-not (Test-Path $vbs)) { throw "Missing $vbs" }
|
if (-not (Test-Path $vbs)) { throw "Missing $vbs" }
|
||||||
|
if (-not (Test-Path $startPs1)) { throw "Missing $startPs1" }
|
||||||
|
|
||||||
$shell = New-Object -ComObject WScript.Shell
|
$shell = New-Object -ComObject WScript.Shell
|
||||||
$desktop = [Environment]::GetFolderPath('Desktop')
|
$desktop = [Environment]::GetFolderPath('Desktop')
|
||||||
$programs = Join-Path ([Environment]::GetFolderPath('StartMenu')) 'Programs\AIGen'
|
$programs = Join-Path ([Environment]::GetFolderPath('StartMenu')) 'Programs\AIGen'
|
||||||
|
$startup = [Environment]::GetFolderPath('Startup')
|
||||||
New-Item -ItemType Directory -Force -Path $programs | Out-Null
|
New-Item -ItemType Directory -Force -Path $programs | Out-Null
|
||||||
|
|
||||||
function Save-Shortcut([string]$dir) {
|
function Save-Shortcut([string]$dir, [string]$name = 'Start Comfy Host Agent.lnk') {
|
||||||
$path = Join-Path $dir 'Start Comfy Host Agent.lnk'
|
$path = Join-Path $dir $name
|
||||||
$lnk = $shell.CreateShortcut($path)
|
$lnk = $shell.CreateShortcut($path)
|
||||||
$lnk.TargetPath = (Join-Path $env:SystemRoot 'System32\wscript.exe')
|
$lnk.TargetPath = (Join-Path $env:SystemRoot 'System32\wscript.exe')
|
||||||
$lnk.Arguments = "//B `"$vbs`""
|
$lnk.Arguments = "//B `"$vbs`""
|
||||||
$lnk.WorkingDirectory = $root
|
$lnk.WorkingDirectory = $root
|
||||||
$lnk.WindowStyle = 7
|
$lnk.WindowStyle = 7
|
||||||
$lnk.Description = 'Start the AIGen Comfy host agent in the background'
|
$lnk.Description = 'Start THIS AIGen Comfy host agent (YuE2) and replace any stale agent on 8199'
|
||||||
$lnk.Save()
|
$lnk.Save()
|
||||||
$path
|
$path
|
||||||
}
|
}
|
||||||
|
|
||||||
Save-Shortcut $desktop
|
$saved = @(
|
||||||
Save-Shortcut $programs
|
(Save-Shortcut $desktop),
|
||||||
|
(Save-Shortcut $programs),
|
||||||
|
(Save-Shortcut $startup 'AIGen Comfy Host Agent.lnk')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Point the scheduled task at this clone when it already exists.
|
||||||
|
$taskName = 'AIGen Comfy Host Agent'
|
||||||
|
$existing = schtasks /Query /TN $taskName 2>$null
|
||||||
|
if ($LASTEXITCODE -eq 0) {
|
||||||
|
$hidden = Join-Path $root 'scripts\start-comfy-host-agent-hidden.vbs'
|
||||||
|
$tr = "wscript.exe //B `"$hidden`""
|
||||||
|
schtasks /Change /TN $taskName /TR $tr | Out-Null
|
||||||
|
Write-Host "Updated scheduled task '$taskName' -> $hidden"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Installed launchers for $root"
|
||||||
|
$saved | ForEach-Object { Write-Host " $_" }
|
||||||
|
Write-Host ''
|
||||||
|
Write-Host 'One click: double-click Desktop "Start Comfy Host Agent", or log off/on so Startup runs it.'
|
||||||
|
Write-Host 'That replaces any old node on 8199 with this clone (YuE2 /yue2/status).'
|
||||||
|
|||||||
@@ -1,35 +1,14 @@
|
|||||||
' Start the Comfy host agent with no window. Safe to run when Cursor is closed.
|
' Start THIS clone's Comfy host agent (YuE2). Replaces any stale agent on 8199.
|
||||||
' Uses the "AIGen Comfy Host Agent" scheduled task when it exists.
|
' Safe to run at logon / from Startup / Desktop shortcut when Cursor is closed.
|
||||||
Option Explicit
|
Option Explicit
|
||||||
Dim sh, fso, root, node, agent, rc
|
Dim sh, fso, root, ps1
|
||||||
Set sh = CreateObject("WScript.Shell")
|
Set sh = CreateObject("WScript.Shell")
|
||||||
Set fso = CreateObject("Scripting.FileSystemObject")
|
Set fso = CreateObject("Scripting.FileSystemObject")
|
||||||
|
|
||||||
If AgentRunning() Then WScript.Quit 0
|
|
||||||
|
|
||||||
On Error Resume Next
|
|
||||||
rc = sh.Run("schtasks /run /tn ""AIGen Comfy Host Agent""", 0, True)
|
|
||||||
On Error GoTo 0
|
|
||||||
WScript.Sleep 1200
|
|
||||||
If AgentRunning() Then WScript.Quit 0
|
|
||||||
|
|
||||||
root = fso.GetParentFolderName(WScript.ScriptFullName)
|
root = fso.GetParentFolderName(WScript.ScriptFullName)
|
||||||
root = fso.GetParentFolderName(root)
|
root = fso.GetParentFolderName(root)
|
||||||
node = """C:\Program Files\nodejs\node.exe"""
|
ps1 = root & "\scripts\start-comfy-host-agent.ps1"
|
||||||
agent = """" & root & "\scripts\comfy-host-agent.mjs"""
|
If Not fso.FileExists(ps1) Then WScript.Quit 1
|
||||||
If Not fso.FileExists(Replace(node, """", "")) Then WScript.Quit 1
|
|
||||||
sh.CurrentDirectory = root
|
|
||||||
sh.Run node & " " & agent, 0, False
|
|
||||||
|
|
||||||
Function AgentRunning()
|
sh.CurrentDirectory = root
|
||||||
Dim svc, query, item
|
sh.Run "powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File """ & ps1 & """", 0, False
|
||||||
AgentRunning = False
|
|
||||||
Set svc = GetObject("winmgmts:\\.\root\cimv2")
|
|
||||||
Set query = svc.ExecQuery("SELECT CommandLine FROM Win32_Process WHERE Name='node.exe'")
|
|
||||||
For Each item In query
|
|
||||||
If InStr(LCase("" & item.CommandLine), "comfy-host-agent") > 0 Then
|
|
||||||
AgentRunning = True
|
|
||||||
Exit Function
|
|
||||||
End If
|
|
||||||
Next
|
|
||||||
End Function
|
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
' Launch the Comfy host agent with no console window.
|
' Launch the Comfy host agent with no console window (Task Scheduler wait mode).
|
||||||
' Window style 0 = hidden. Wait = True so Task Scheduler keeps the task marked Running.
|
' Replaces any stale agent on 8199, then runs THIS clone's agent until it exits.
|
||||||
Option Explicit
|
Option Explicit
|
||||||
Dim sh, root, node, agent
|
Dim sh, fso, root, ps1
|
||||||
Set sh = CreateObject("WScript.Shell")
|
Set sh = CreateObject("WScript.Shell")
|
||||||
root = CreateObject("Scripting.FileSystemObject").GetParentFolderName(WScript.ScriptFullName)
|
Set fso = CreateObject("Scripting.FileSystemObject")
|
||||||
root = CreateObject("Scripting.FileSystemObject").GetParentFolderName(root)
|
|
||||||
node = """C:\Program Files\nodejs\node.exe"""
|
root = fso.GetParentFolderName(WScript.ScriptFullName)
|
||||||
agent = """" & root & "\scripts\comfy-host-agent.mjs"""
|
root = fso.GetParentFolderName(root)
|
||||||
|
ps1 = root & "\scripts\start-comfy-host-agent.ps1"
|
||||||
|
If Not fso.FileExists(ps1) Then WScript.Quit 1
|
||||||
|
|
||||||
sh.CurrentDirectory = root
|
sh.CurrentDirectory = root
|
||||||
sh.Run node & " " & agent, 0, True
|
' Wait=True so Task Scheduler keeps the task marked Running while the agent lives.
|
||||||
|
sh.Run "powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File """ & ps1 & """ -Wait", 0, True
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Replace any stale Comfy host agent, then start THIS clone's agent on 8199.
|
||||||
|
# Called by launch-comfy-host-agent.vbs (Startup / Desktop / Start Menu).
|
||||||
|
# -Wait: run in-foreground for Task Scheduler (keeps task marked Running).
|
||||||
|
param([switch]$Wait)
|
||||||
|
$ErrorActionPreference = 'SilentlyContinue'
|
||||||
|
$root = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
|
||||||
|
$node = 'C:\Program Files\nodejs\node.exe'
|
||||||
|
$agent = Join-Path $root 'scripts\comfy-host-agent.mjs'
|
||||||
|
if (-not (Test-Path $node)) { throw "Missing $node" }
|
||||||
|
if (-not (Test-Path $agent)) { throw "Missing $agent" }
|
||||||
|
|
||||||
|
Get-CimInstance Win32_Process -Filter "Name='node.exe'" |
|
||||||
|
Where-Object { $_.CommandLine -match 'comfy-host-agent' } |
|
||||||
|
ForEach-Object { Stop-Process -Id $_.ProcessId -Force }
|
||||||
|
|
||||||
|
Get-NetTCPConnection -LocalPort 8199 -State Listen -ErrorAction SilentlyContinue |
|
||||||
|
ForEach-Object { Stop-Process -Id $_.OwningProcess -Force }
|
||||||
|
|
||||||
|
Start-Sleep -Milliseconds 400
|
||||||
|
|
||||||
|
if ($Wait) {
|
||||||
|
Set-Location $root
|
||||||
|
& $node $agent
|
||||||
|
exit $LASTEXITCODE
|
||||||
|
}
|
||||||
|
|
||||||
|
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||||
|
$psi.FileName = $node
|
||||||
|
$psi.Arguments = "`"$agent`""
|
||||||
|
$psi.WorkingDirectory = $root
|
||||||
|
$psi.UseShellExecute = $false
|
||||||
|
$psi.CreateNoWindow = $true
|
||||||
|
$psi.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden
|
||||||
|
[void][System.Diagnostics.Process]::Start($psi)
|
||||||
@@ -1,16 +1,7 @@
|
|||||||
import { yueGpConfigured } from '~/server/utils/yueGp'
|
|
||||||
import { yue2Configured } from '~/server/utils/yue2'
|
import { yue2Configured } from '~/server/utils/yue2'
|
||||||
import { addStudioJob, kickStudioQueue, listStudioJobs, videoJobsBusy } from '~/server/utils/studioQueue'
|
import { addStudioJob, kickStudioQueue, listStudioJobs, videoJobsBusy } from '~/server/utils/studioQueue'
|
||||||
import { comfyConfigured } from '~/server/utils/comfy'
|
|
||||||
import {
|
import {
|
||||||
clampLyricsStrength,
|
|
||||||
clampMusicCfg,
|
|
||||||
clampMusicDuration,
|
clampMusicDuration,
|
||||||
clampMusicSteps,
|
|
||||||
MUSIC_CFG_DEFAULT,
|
|
||||||
MUSIC_CFG_DEFAULT_15,
|
|
||||||
MUSIC_STEPS_DEFAULT,
|
|
||||||
MUSIC_STEPS_DEFAULT_15,
|
|
||||||
parseMusicEngine,
|
parseMusicEngine,
|
||||||
parseMusicSeed,
|
parseMusicSeed,
|
||||||
sanitizeMusicLyrics,
|
sanitizeMusicLyrics,
|
||||||
@@ -23,37 +14,28 @@ export default defineEventHandler(async (event) => {
|
|||||||
tags?: unknown
|
tags?: unknown
|
||||||
lyrics?: unknown
|
lyrics?: unknown
|
||||||
duration?: unknown
|
duration?: unknown
|
||||||
steps?: unknown
|
|
||||||
seed?: unknown
|
seed?: unknown
|
||||||
cfg?: unknown
|
|
||||||
lyricsStrength?: unknown
|
|
||||||
instrumental?: unknown
|
|
||||||
folderId?: unknown
|
folderId?: unknown
|
||||||
name?: unknown
|
name?: unknown
|
||||||
yueProfile?: unknown
|
|
||||||
engine?: unknown
|
engine?: unknown
|
||||||
sampler_name?: unknown
|
|
||||||
scheduler?: unknown
|
|
||||||
}>(event).catch(() => ({} as Record<string, unknown>))
|
}>(event).catch(() => ({} as Record<string, unknown>))
|
||||||
|
|
||||||
const engine = parseMusicEngine(body.engine)
|
const engine = parseMusicEngine(body.engine)
|
||||||
if (engine === 'yue' && !yueGpConfigured()) throw createError({ statusCode: 503, statusMessage: 'YuEGP host is not configured. Set COMFY_CONTROL_URL.' })
|
if (engine !== 'yue2') {
|
||||||
if (engine === 'yue2' && !yue2Configured()) throw createError({ statusCode: 503, statusMessage: 'YuE2 host is not configured. Set COMFY_CONTROL_URL.' })
|
throw createError({ statusCode: 400, statusMessage: 'Only YuE2 is supported for music generation.' })
|
||||||
if (engine !== 'yue' && engine !== 'yue2' && !comfyConfigured()) {
|
}
|
||||||
throw createError({
|
if (!yue2Configured()) {
|
||||||
statusCode: 503,
|
throw createError({ statusCode: 503, statusMessage: 'YuE2 host is not configured. Set COMFY_CONTROL_URL.' })
|
||||||
statusMessage: 'Beast ComfyUI is not configured. Set COMFY_HOST.'
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const instrumental = body.instrumental === true || body.instrumental === 'true'
|
|
||||||
const tags = sanitizeMusicTags(body.tags)
|
const tags = sanitizeMusicTags(body.tags)
|
||||||
const lyrics = sanitizeMusicLyrics(body.lyrics, instrumental)
|
const lyrics = sanitizeMusicLyrics(body.lyrics, false)
|
||||||
if (!tags) {
|
if (!tags) {
|
||||||
throw createError({ statusCode: 400, statusMessage: 'Genre and style tags are required' })
|
throw createError({ statusCode: 400, statusMessage: 'Genre and style tags are required' })
|
||||||
}
|
}
|
||||||
if (!instrumental && !lyrics) {
|
const lyricsProblem = yueLyricsProblem(lyrics)
|
||||||
throw createError({ statusCode: 400, statusMessage: 'Lyrics are required unless Instrumental is on' })
|
if (lyricsProblem) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: lyricsProblem })
|
||||||
}
|
}
|
||||||
|
|
||||||
const ownerKey = libraryOwnerKey(event)
|
const ownerKey = libraryOwnerKey(event)
|
||||||
@@ -68,34 +50,12 @@ export default defineEventHandler(async (event) => {
|
|||||||
const destFolder = library.folders.find(folder => folder.id === folderId)
|
const destFolder = library.folders.find(folder => folder.id === folderId)
|
||||||
const folderLocked = Boolean(destFolder?.protected && !destFolder.unlocked)
|
const folderLocked = Boolean(destFolder?.protected && !destFolder.unlocked)
|
||||||
|
|
||||||
if ((engine === 'yue' || engine === 'yue2') && instrumental) throw createError({ statusCode: 400, statusMessage: 'YuE currently requires lyrics. Use ACE for instrumental music.' })
|
|
||||||
const yueProfile = body.yueProfile ?? 1
|
|
||||||
if (engine === 'yue' && yueProfile !== 1 && yueProfile !== 3) throw createError({ statusCode: 400, statusMessage: 'Choose YuEGP profile 1 or manual fallback 3.' })
|
|
||||||
if (engine === 'yue' && !instrumental) {
|
|
||||||
const problem = yueLyricsProblem(lyrics)
|
|
||||||
if (problem) throw createError({ statusCode: 400, statusMessage: problem })
|
|
||||||
}
|
|
||||||
if (engine === 'yue2' && !instrumental && !lyrics) {
|
|
||||||
throw createError({ statusCode: 400, statusMessage: 'YuE2 needs lyrics.' })
|
|
||||||
}
|
|
||||||
const duration = clampMusicDuration(body.duration)
|
const duration = clampMusicDuration(body.duration)
|
||||||
if ((engine === 'yue' || engine === 'yue2') && duration > 150) throw createError({ statusCode: 400, statusMessage: 'YuE supports up to 150 seconds per section.' })
|
|
||||||
const steps = clampMusicSteps(
|
|
||||||
body.steps,
|
|
||||||
engine === 'ace-step-1.5' ? MUSIC_STEPS_DEFAULT_15 : MUSIC_STEPS_DEFAULT
|
|
||||||
)
|
|
||||||
const cfg = clampMusicCfg(
|
|
||||||
body.cfg,
|
|
||||||
engine === 'ace-step-1.5' ? MUSIC_CFG_DEFAULT_15 : MUSIC_CFG_DEFAULT
|
|
||||||
)
|
|
||||||
const lyricsStrength = clampLyricsStrength(body.lyricsStrength)
|
|
||||||
const seed = parseMusicSeed(body.seed)
|
const seed = parseMusicSeed(body.seed)
|
||||||
const clipName = allocateOutputName(ownerKey, folderId, String(body.name || '').trim(), {
|
const clipName = allocateOutputName(ownerKey, folderId, String(body.name || '').trim(), {
|
||||||
engine: engine === 'ace-step-1.5' ? 'ace15' : engine === 'yue' ? 'yue' : engine === 'yue2' ? 'yue2' : 'ace'
|
engine: 'yue2'
|
||||||
})
|
})
|
||||||
|
|
||||||
const samplerName = String(body.sampler_name || 'euler')
|
|
||||||
const scheduler = String(body.scheduler || 'simple')
|
|
||||||
const familyId = crypto.randomUUID()
|
const familyId = crypto.randomUUID()
|
||||||
const studio = await addStudioJob({
|
const studio = await addStudioJob({
|
||||||
ownerKey,
|
ownerKey,
|
||||||
@@ -108,15 +68,15 @@ export default defineEventHandler(async (event) => {
|
|||||||
aspect: 'audio',
|
aspect: 'audio',
|
||||||
width: 0,
|
width: 0,
|
||||||
height: 0,
|
height: 0,
|
||||||
steps,
|
steps: 0,
|
||||||
turbo: false,
|
turbo: false,
|
||||||
seed,
|
seed,
|
||||||
cfg,
|
cfg: 0,
|
||||||
fps: 0,
|
fps: 0,
|
||||||
samplerName,
|
samplerName: 'euler',
|
||||||
scheduler,
|
scheduler: 'simple',
|
||||||
duration,
|
duration,
|
||||||
sound: !instrumental,
|
sound: true,
|
||||||
workflow: 'v1',
|
workflow: 'v1',
|
||||||
useIdentityRefs: false,
|
useIdentityRefs: false,
|
||||||
hideThumbnail: false,
|
hideThumbnail: false,
|
||||||
@@ -125,10 +85,9 @@ export default defineEventHandler(async (event) => {
|
|||||||
extensions: [],
|
extensions: [],
|
||||||
queueAutoRun: false,
|
queueAutoRun: false,
|
||||||
lyrics,
|
lyrics,
|
||||||
instrumental,
|
instrumental: false,
|
||||||
lyricsStrength,
|
lyricsStrength: 0,
|
||||||
musicEngine: engine,
|
musicEngine: 'yue2'
|
||||||
...(engine === 'yue' ? { yueProfile: yueProfile as 1 | 3 } : {})
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
await kickStudioQueue()
|
await kickStudioQueue()
|
||||||
@@ -145,11 +104,9 @@ export default defineEventHandler(async (event) => {
|
|||||||
queued: !liveJobId,
|
queued: !liveJobId,
|
||||||
seed,
|
seed,
|
||||||
duration,
|
duration,
|
||||||
steps,
|
|
||||||
cfg,
|
|
||||||
folderId,
|
folderId,
|
||||||
folderLocked,
|
folderLocked,
|
||||||
engine,
|
engine: 'yue2' as const,
|
||||||
name: clipName
|
name: clipName
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -14,8 +14,6 @@ export default defineEventHandler(async (event) => {
|
|||||||
throw createError({ statusCode: e.statusCode || 400, statusMessage: e.message });
|
throw createError({ statusCode: e.statusCode || 400, statusMessage: e.message });
|
||||||
}
|
}
|
||||||
const video = ['video', 'extend'].includes(request.mode);
|
const video = ['video', 'extend'].includes(request.mode);
|
||||||
if (video && request.engine !== 'minimax')
|
|
||||||
throw createError({ statusCode: 400, statusMessage: 'Use the existing xAIGen studio for LTX. Studio 2 video currently supports MiniMax.' });
|
|
||||||
request.settings.loraStack = parsePostedLoraStack(request.settings.loraStack, video ? 'video' : 'image');
|
request.settings.loraStack = parsePostedLoraStack(request.settings.loraStack, video ? 'video' : 'image');
|
||||||
request.settings.loraStack = filterLoraStackForUse(request.settings.loraStack, request.engine, useRuntimeConfig().public.studio === 'xaigen');
|
request.settings.loraStack = filterLoraStackForUse(request.settings.loraStack, request.engine, useRuntimeConfig().public.studio === 'xaigen');
|
||||||
if (!video)
|
if (!video)
|
||||||
|
|||||||
@@ -5,5 +5,5 @@ export default defineEventHandler(event => {
|
|||||||
const { owner } = assertLibraryOwner(event);
|
const { owner } = assertLibraryOwner(event);
|
||||||
const library = publicLibrary(event);
|
const library = publicLibrary(event);
|
||||||
const catalog=studio2LoraCatalog(owner);
|
const catalog=studio2LoraCatalog(owner);
|
||||||
return {...library,loraAliases:catalog.aliases,loraTriggers:catalog.triggers, stills:library.stills.filter(s=>existsSync(stillPath(owner,s.id))), clips:library.clips.filter(c=>existsSync(clipVideoPath(owner,c.id)))};
|
return {...library,loraAliases:catalog.aliases,loraTriggers:catalog.triggers, stills:library.stills.filter(s=>existsSync(stillPath(owner,s.id))), clips:library.clips.filter(c=>existsSync(clipVideoPath(owner,c.id))), tracks:library.tracks || []};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -50,22 +50,24 @@ export default defineEventHandler(async (event) => {
|
|||||||
payload.prompt = text(body.tags, 2000)
|
payload.prompt = text(body.tags, 2000)
|
||||||
row.prompt = payload.prompt
|
row.prompt = payload.prompt
|
||||||
}
|
}
|
||||||
if (body.yueProfile != null && row.kind === 'music' && payload.musicEngine === 'yue') {
|
|
||||||
if (body.yueProfile !== 1 && body.yueProfile !== 3) throw createError({ statusCode: 400, statusMessage: 'Choose YuEGP profile 1 or 3.' })
|
|
||||||
payload.yueProfile = body.yueProfile
|
|
||||||
}
|
|
||||||
if (body.lyrics != null) payload.lyrics = text(body.lyrics)
|
if (body.lyrics != null) payload.lyrics = text(body.lyrics)
|
||||||
const instrumental = optionalBool(body.instrumental)
|
const instrumental = optionalBool(body.instrumental)
|
||||||
if (instrumental != null) payload.instrumental = instrumental
|
if (instrumental != null && row.kind === 'music') {
|
||||||
|
if (instrumental) throw createError({ statusCode: 400, statusMessage: 'YuE2 requires lyrics. Instrumental mode is not supported.' })
|
||||||
|
payload.instrumental = false
|
||||||
|
} else if (instrumental != null) {
|
||||||
|
payload.instrumental = instrumental
|
||||||
|
}
|
||||||
if (body.promptPre != null) payload.promptPre = text(body.promptPre)
|
if (body.promptPre != null) payload.promptPre = text(body.promptPre)
|
||||||
if (body.promptMid != null) payload.promptMid = text(body.promptMid)
|
if (body.promptMid != null) payload.promptMid = text(body.promptMid)
|
||||||
if (body.promptPost != null) payload.promptPost = text(body.promptPost)
|
if (body.promptPost != null) payload.promptPost = text(body.promptPost)
|
||||||
if (body.negative != null) payload.negative = text(body.negative, 2000)
|
if (body.negative != null) payload.negative = text(body.negative, 2000)
|
||||||
const duration = optionalNumber(body.duration)
|
const duration = optionalNumber(body.duration)
|
||||||
if (duration != null) {
|
if (duration != null) {
|
||||||
if (row.kind === 'music' && payload.musicEngine === 'yue') {
|
if (row.kind === 'music') {
|
||||||
if (!Number.isInteger(duration) || duration < 30 || duration > 150) throw createError({ statusCode: 400, statusMessage: 'YuEGP duration must be 30–150 seconds.' })
|
if (!Number.isInteger(duration) || duration < 30 || duration > 150) throw createError({ statusCode: 400, statusMessage: 'YuE2 duration must be 30–150 seconds.' })
|
||||||
payload.duration = duration
|
payload.duration = duration
|
||||||
|
payload.musicEngine = 'yue2'
|
||||||
} else payload.duration = Math.min(120, Math.max(0.5, duration))
|
} else payload.duration = Math.min(120, Math.max(0.5, duration))
|
||||||
}
|
}
|
||||||
const steps = optionalNumber(body.steps)
|
const steps = optionalNumber(body.steps)
|
||||||
|
|||||||
@@ -1,4 +1,2 @@
|
|||||||
import { resumeYueGpJobs } from '~/server/utils/yueGp'
|
// YuEGP resume removed — music is YuE2-only. scripts/yuegp-* remain on disk unused.
|
||||||
|
export default defineNitroPlugin(() => {})
|
||||||
// Restore live music IDs before queue repair examines their durable studio rows.
|
|
||||||
export default defineNitroPlugin(() => { resumeYueGpJobs() })
|
|
||||||
|
|||||||
@@ -166,17 +166,12 @@ function sanitizeImageV2Settings(raw: unknown): ImageV2PresetSettings {
|
|||||||
|
|
||||||
function sanitizeMusicSettings(raw: unknown): MusicPresetSettings {
|
function sanitizeMusicSettings(raw: unknown): MusicPresetSettings {
|
||||||
const rec = raw && typeof raw === 'object' ? raw as Record<string, unknown> : {}
|
const rec = raw && typeof raw === 'object' ? raw as Record<string, unknown> : {}
|
||||||
const instrumental = rec.instrumental === true
|
|
||||||
return {
|
return {
|
||||||
tags: sanitizeMusicTags(rec.tags),
|
tags: sanitizeMusicTags(rec.tags),
|
||||||
lyrics: sanitizeMusicLyrics(rec.lyrics, instrumental),
|
lyrics: sanitizeMusicLyrics(rec.lyrics, false),
|
||||||
instrumental,
|
|
||||||
duration: clampMusicDuration(rec.duration),
|
duration: clampMusicDuration(rec.duration),
|
||||||
steps: clampMusicSteps(rec.steps),
|
|
||||||
seed: typeof rec.seed === 'number' && Number.isFinite(rec.seed) ? Math.max(0, Math.floor(rec.seed)) : undefined,
|
seed: typeof rec.seed === 'number' && Number.isFinite(rec.seed) ? Math.max(0, Math.floor(rec.seed)) : undefined,
|
||||||
cfg: rec.cfg == null ? undefined : clampMusicCfg(rec.cfg),
|
engine: 'yue2'
|
||||||
engine: parseMusicEngine(rec.engine),
|
|
||||||
...(parseMusicEngine(rec.engine) === 'yue' ? { yueProfile: rec.yueProfile === 3 ? 3 as const : 1 as const } : {})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-410
@@ -1,13 +1,4 @@
|
|||||||
import { startYueGpJob } from './yueGp'
|
|
||||||
import { startYue2Job } from './yue2'
|
import { startYue2Job } from './yue2'
|
||||||
import { createJob, emitJob, type Job } from '~/server/utils/jobs'
|
|
||||||
import { extractAudio, fetchHistory, fetchHistoryAll, findHistoryAudio, purgeComfyArtifacts, queuePrompt } from '~/server/utils/comfy'
|
|
||||||
import { comfyWsUrl, comfyFetch } from '~/server/utils/comfy'
|
|
||||||
import { ensureComfyReady } from '~/server/utils/comfyLifecycle'
|
|
||||||
import { downloadComfyAudio, saveTrack } from '~/server/utils/library'
|
|
||||||
import { buildMusicWorkflow, musicFilenamePrefix, assertMusicEngineNodes } from '~/server/utils/musicWorkflow'
|
|
||||||
import { NODE_LABELS } from '~/server/utils/workflow'
|
|
||||||
import { musicExtension } from '~/utils/music'
|
|
||||||
import type { MusicEngine } from '~/utils/music'
|
import type { MusicEngine } from '~/utils/music'
|
||||||
|
|
||||||
export type MusicJobParams = {
|
export type MusicJobParams = {
|
||||||
@@ -23,413 +14,22 @@ export type MusicJobParams = {
|
|||||||
lyricsStrength: number
|
lyricsStrength: number
|
||||||
instrumental: boolean
|
instrumental: boolean
|
||||||
folderLocked?: boolean
|
folderLocked?: boolean
|
||||||
yueProfile?: 1 | 3
|
|
||||||
engine?: MusicEngine
|
engine?: MusicEngine
|
||||||
samplerName?: string
|
samplerName?: string
|
||||||
scheduler?: string
|
scheduler?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function sleep(ms: number) {
|
/** Music generation is YuE2-only. ACE Comfy and YuEGP paths are removed. */
|
||||||
return new Promise(resolve => setTimeout(resolve, ms))
|
|
||||||
}
|
|
||||||
|
|
||||||
function classifyError(message: string) {
|
|
||||||
const lower = message.toLowerCase()
|
|
||||||
if (lower.includes('out of memory') || lower.includes('exceed allowed memory') || (lower.includes('cuda') && lower.includes('alloc')) || lower.includes('vram')) {
|
|
||||||
return 'ComfyUI ran out of VRAM.'
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
async function settleMusicStudio(job: Job) {
|
|
||||||
try {
|
|
||||||
const { onLiveVideoSettled } = await import('~/server/utils/studioQueue')
|
|
||||||
await onLiveVideoSettled(job)
|
|
||||||
} catch {
|
|
||||||
/* queue kick is best-effort */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function nodeLabel(node: string) {
|
|
||||||
if (!node) return ''
|
|
||||||
return NODE_LABELS[node] || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForSocket(job: Job, ms = 4000) {
|
|
||||||
const started = Date.now()
|
|
||||||
while (Date.now() - started < ms) {
|
|
||||||
if (job.socketReady) return
|
|
||||||
await sleep(100)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function watchMusicJob(job: Job): Promise<void> {
|
|
||||||
job.socketReady = false
|
|
||||||
job.saving = false
|
|
||||||
const ws = new WebSocket(comfyWsUrl(job.clientId))
|
|
||||||
let settled = false
|
|
||||||
let finishing = false
|
|
||||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
|
||||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
let localProgress = 0
|
|
||||||
const startedAt = Date.now()
|
|
||||||
let lastActivity = Date.now()
|
|
||||||
const IDLE_MS = 30 * 60 * 1000
|
|
||||||
const ABSOLUTE_MS = 3 * 60 * 60 * 1000
|
|
||||||
|
|
||||||
const markActivity = () => { lastActivity = Date.now() }
|
|
||||||
|
|
||||||
const emitLocal = (event: Parameters<typeof emitJob>[1], samplePct?: number) => {
|
|
||||||
markActivity()
|
|
||||||
if (typeof event.progress === 'number') localProgress = event.progress
|
|
||||||
if (event.message && typeof samplePct === 'number') {
|
|
||||||
event.message = `${event.message} (${samplePct}%)`
|
|
||||||
}
|
|
||||||
emitJob(job, event)
|
|
||||||
}
|
|
||||||
|
|
||||||
const cleanup = () => {
|
|
||||||
if (timeout) clearTimeout(timeout)
|
|
||||||
if (pollTimer) clearInterval(pollTimer)
|
|
||||||
timeout = null
|
|
||||||
pollTimer = null
|
|
||||||
try { ws.close() } catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
const armIdle = () => {
|
|
||||||
if (timeout) clearTimeout(timeout)
|
|
||||||
if (settled) return
|
|
||||||
const absLeft = ABSOLUTE_MS - (Date.now() - startedAt)
|
|
||||||
if (absLeft <= 0) {
|
|
||||||
void fail('Timed out waiting for ComfyUI (3 hours).')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const idleLeft = IDLE_MS - (Date.now() - lastActivity)
|
|
||||||
timeout = setTimeout(() => {
|
|
||||||
if (settled || finishing) return
|
|
||||||
if (Date.now() - lastActivity >= IDLE_MS) {
|
|
||||||
void fail('Timed out waiting for ComfyUI progress (30 minutes with no updates).')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
armIdle()
|
|
||||||
}, Math.max(1000, Math.min(idleLeft, absLeft)))
|
|
||||||
}
|
|
||||||
|
|
||||||
const fail = async (error: string) => {
|
|
||||||
if (settled || finishing) return
|
|
||||||
settled = true
|
|
||||||
cleanup()
|
|
||||||
job.status = job.status === 'cancelled' ? 'cancelled' : 'error'
|
|
||||||
job.error = classifyError(error)
|
|
||||||
emitJob(job, { type: 'error', error: job.error, message: job.error })
|
|
||||||
await settleMusicStudio(job)
|
|
||||||
resolve()
|
|
||||||
}
|
|
||||||
|
|
||||||
const adoptPromptId = (id: unknown) => {
|
|
||||||
const promptId = String(id || '').trim()
|
|
||||||
if (promptId && !job.promptId) job.promptId = promptId
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadJobAudio = async () => {
|
|
||||||
if (job.promptId) {
|
|
||||||
const scoped = await fetchHistory(job.promptId)
|
|
||||||
const fromPrompt = extractAudio(scoped, job.promptId)
|
|
||||||
if (fromPrompt) return fromPrompt
|
|
||||||
}
|
|
||||||
const found = findHistoryAudio(await fetchHistoryAll(), {
|
|
||||||
promptId: job.promptId,
|
|
||||||
tags: job.library?.tags,
|
|
||||||
since: job.startedAt - 60_000
|
|
||||||
})
|
|
||||||
if (found) {
|
|
||||||
job.promptId = found.promptId
|
|
||||||
return found.audio
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const succeed = async () => {
|
|
||||||
if (settled || finishing) return false
|
|
||||||
finishing = true
|
|
||||||
job.saving = true
|
|
||||||
try {
|
|
||||||
const audio = await loadJobAudio()
|
|
||||||
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
|
|
||||||
await purgeComfyArtifacts({
|
|
||||||
audio,
|
|
||||||
promptId: job.promptId
|
|
||||||
})
|
|
||||||
}
|
|
||||||
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 })
|
|
||||||
}
|
|
||||||
await settleMusicStudio(job)
|
|
||||||
job.saving = false
|
|
||||||
resolve()
|
|
||||||
return true
|
|
||||||
} catch (error) {
|
|
||||||
finishing = false
|
|
||||||
job.saving = false
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const pollHistory = async () => {
|
|
||||||
if (settled || finishing) return
|
|
||||||
if (job.promptId) {
|
|
||||||
try {
|
|
||||||
const response = await comfyFetch('/queue', { signal: AbortSignal.timeout(2500) })
|
|
||||||
if (response.ok) {
|
|
||||||
const queue = await response.json() as { queue_running?: unknown[][] }
|
|
||||||
job.musicActivity = {
|
|
||||||
checkedAt: Date.now(),
|
|
||||||
running: Boolean(queue.queue_running?.some(row => row[1] === job.promptId))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch { /* Keep the last confirmation timestamp so the UI can show stale checks. */ }
|
|
||||||
}
|
|
||||||
if (settled || finishing) return
|
|
||||||
try {
|
|
||||||
const history = await fetchHistoryAll()
|
|
||||||
const found = findHistoryAudio(history, {
|
|
||||||
promptId: job.promptId,
|
|
||||||
tags: job.library?.tags,
|
|
||||||
since: job.startedAt - 60_000
|
|
||||||
})
|
|
||||||
if (found) {
|
|
||||||
job.promptId = found.promptId
|
|
||||||
await succeed()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const entry = (job.promptId ? history?.[job.promptId] : undefined) as {
|
|
||||||
status?: {
|
|
||||||
status_str?: string
|
|
||||||
completed?: boolean
|
|
||||||
messages?: Array<[string, Record<string, unknown>]>
|
|
||||||
}
|
|
||||||
} | undefined
|
|
||||||
const status = entry?.status?.status_str
|
|
||||||
if (status === 'error') {
|
|
||||||
const err = entry?.status?.messages?.find(([type]) => type === 'execution_error')?.[1]
|
|
||||||
const detail = String(err?.exception_message || err?.message || '').trim()
|
|
||||||
await fail(detail || 'ComfyUI reported an execution error')
|
|
||||||
} else if (status === 'interrupted') await fail('Job interrupted.')
|
|
||||||
} catch {
|
|
||||||
// history can miss while the graph is still running
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
armIdle()
|
|
||||||
pollTimer = setInterval(() => { void pollHistory() }, 4000)
|
|
||||||
|
|
||||||
ws.addEventListener('open', () => {
|
|
||||||
job.socketReady = true
|
|
||||||
emitLocal({ type: 'status', message: 'Connected to ComfyUI', progress: Math.max(localProgress, 4) })
|
|
||||||
})
|
|
||||||
ws.addEventListener('error', () => {
|
|
||||||
job.socketReady = false
|
|
||||||
})
|
|
||||||
ws.addEventListener('message', (event) => {
|
|
||||||
let payload: { type?: string; data?: Record<string, any> }
|
|
||||||
try {
|
|
||||||
payload = JSON.parse(String(event.data))
|
|
||||||
} catch {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const type = String(payload.type || '')
|
|
||||||
const data = payload.data || {}
|
|
||||||
adoptPromptId(data.prompt_id)
|
|
||||||
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) 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) {
|
export async function startMusicJob(params: MusicJobParams) {
|
||||||
if (params.engine === 'yue') return startYueGpJob(params)
|
if (params.instrumental) {
|
||||||
if (params.engine === 'yue2') return startYue2Job(params)
|
throw new Error('YuE2 requires lyrics. Instrumental mode is not supported.')
|
||||||
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
|
if (!String(params.lyrics || '').trim()) {
|
||||||
job.status = 'queued'
|
throw new Error('YuE2 needs lyrics.')
|
||||||
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 = 'running'
|
|
||||||
const engineLabel = params.engine === 'ace-step-1.5' ? 'ACE-Step 1.5' : 'ACE-Step'
|
|
||||||
emitJob(job, { type: 'status', message: `Queueing ${engineLabel}...`, progress: 6 })
|
|
||||||
await waitForSocket(job, 4000)
|
|
||||||
await assertMusicEngineNodes(params.engine)
|
|
||||||
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
|
|
||||||
})
|
|
||||||
try {
|
|
||||||
const queued = await queuePrompt(graph, job.clientId, { timeoutMs: 25_000 })
|
|
||||||
if (queued?.prompt_id) job.promptId = queued.prompt_id
|
|
||||||
const runningMessage = 'Job queued on ComfyUI'
|
|
||||||
emitJob(job, { type: 'status', message: runningMessage, progress: 8 })
|
|
||||||
} catch (queueError: unknown) {
|
|
||||||
const statusCode = Number((queueError as { statusCode?: number; status?: number })?.statusCode
|
|
||||||
|| (queueError as { status?: number })?.status || 0)
|
|
||||||
if (statusCode >= 400) throw queueError
|
|
||||||
emitJob(job, {
|
|
||||||
type: 'status',
|
|
||||||
message: 'Waiting for ComfyUI to pick up the job...',
|
|
||||||
progress: 8
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
await done
|
return startYue2Job({
|
||||||
} catch (error) {
|
...params,
|
||||||
const message = error instanceof Error ? error.message : String(error)
|
engine: 'yue2',
|
||||||
if (job.status !== 'cancelled' && job.status !== 'complete') {
|
instrumental: false
|
||||||
job.status = 'error'
|
})
|
||||||
job.error = classifyError(message)
|
|
||||||
emitJob(job, { type: 'error', error: job.error, message: job.error })
|
|
||||||
await settleMusicStudio(job)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
return job
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,8 +74,6 @@ async function prepareGraph(r: any) {
|
|||||||
|
|
||||||
let graph: any;
|
let graph: any;
|
||||||
if (['video', 'extend'].includes(q.mode)) {
|
if (['video', 'extend'].includes(q.mode)) {
|
||||||
if (q.engine !== 'minimax')
|
|
||||||
throw new Error('Studio 2 native identity video currently requires MiniMax. Use the existing xAIGen studio for LTX.');
|
|
||||||
let start = a;
|
let start = a;
|
||||||
if (q.startClipId) {
|
if (q.startClipId) {
|
||||||
const dir = join(studio2Root(), r.id);
|
const dir = join(studio2Root(), r.id);
|
||||||
@@ -86,6 +84,37 @@ async function prepareGraph(r: any) {
|
|||||||
start = await upload(r, 'start', readFileSync(dest));
|
start = await upload(r, 'start', readFileSync(dest));
|
||||||
unlinkSync(dest);
|
unlinkSync(dest);
|
||||||
}
|
}
|
||||||
|
if (q.engine === 'ltx') {
|
||||||
|
const { buildWorkflow } = await import('../workflow');
|
||||||
|
const { frameLength } = await import('../videoChain');
|
||||||
|
const { ltxWorkflowEnabled, LTX_DISABLED_MESSAGE } = await import('~/utils/videoModels');
|
||||||
|
if (!ltxWorkflowEnabled()) throw new Error(LTX_DISABLED_MESSAGE);
|
||||||
|
if (!start) throw new Error('Choose a start still for LTX video.');
|
||||||
|
const fps = s.fps || 24;
|
||||||
|
graph = buildWorkflow({
|
||||||
|
workflow: 'ltx',
|
||||||
|
imageName: start,
|
||||||
|
prompt: q.compiledPrompt,
|
||||||
|
width: s.width,
|
||||||
|
height: s.height,
|
||||||
|
length: frameLength(s.duration || 5, fps),
|
||||||
|
fps,
|
||||||
|
steps: s.steps,
|
||||||
|
cfg: s.cfg,
|
||||||
|
seed: s.seed,
|
||||||
|
samplerName: 'euler',
|
||||||
|
scheduler: 'simple',
|
||||||
|
turbo: false,
|
||||||
|
duration: s.duration || 5,
|
||||||
|
filenamePrefix: prefix + '/video',
|
||||||
|
loraStack: s.loraStack,
|
||||||
|
useIdentityRefs: false
|
||||||
|
});
|
||||||
|
await ensureComfyLoraNames('video');
|
||||||
|
r.graphId = 'workflow_ltx_video.json';
|
||||||
|
} else {
|
||||||
|
if (q.engine !== 'minimax')
|
||||||
|
throw new Error('Studio 2 video supports MiniMax H3 and LTX (xAIGen).');
|
||||||
const end = await load(q.endStillId, 'end'), guides = [];
|
const end = await load(q.endStillId, 'end'), guides = [];
|
||||||
for (const [i, g] of q.guides.entries())
|
for (const [i, g] of q.guides.entries())
|
||||||
guides.push({ image: await load(g.stillId, `guide${i}`), frame: g.frame });
|
guides.push({ image: await load(g.stillId, `guide${i}`), frame: g.frame });
|
||||||
@@ -95,6 +124,7 @@ async function prepareGraph(r: any) {
|
|||||||
applyUserLoraToGraph(graph, s.loraStack);
|
applyUserLoraToGraph(graph, s.loraStack);
|
||||||
r.graphId = 'studio2_minimax_native.json';
|
r.graphId = 'studio2_minimax_native.json';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
else {
|
else {
|
||||||
const mode = identityPlan?.mode ?? (q.mode === 'iterate' ? (a ? 'edit' : 'generate') : q.mode);
|
const mode = identityPlan?.mode ?? (q.mode === 'iterate' ? (a ? 'edit' : 'generate') : q.mode);
|
||||||
const found = q.engine === 'krea' ? await resolveKreaGenerateAssets() : null;
|
const found = q.engine === 'krea' ? await resolveKreaGenerateAssets() : null;
|
||||||
|
|||||||
@@ -923,7 +923,7 @@ function repairStaleJobs(jobs: StudioJob[]) {
|
|||||||
job.updatedAt = Date.now()
|
job.updatedAt = Date.now()
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (!job.payload.upscale && job.payload.musicEngine !== 'yue' && job.payload.musicEngine !== 'yue2' && job.status === 'error' && isTransientComfyError(job.lastError)) {
|
if (!job.payload.upscale && job.kind !== 'music' && job.status === 'error' && isTransientComfyError(job.lastError)) {
|
||||||
job.status = 'waiting'
|
job.status = 'waiting'
|
||||||
job.liveJobId = undefined
|
job.liveJobId = undefined
|
||||||
job.lastError = undefined
|
job.lastError = undefined
|
||||||
@@ -932,7 +932,7 @@ function repairStaleJobs(jobs: StudioJob[]) {
|
|||||||
job.updatedAt = Date.now()
|
job.updatedAt = Date.now()
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (!job.payload.upscale && job.payload.musicEngine !== 'yue' && job.payload.musicEngine !== 'yue2' && job.status === 'held' && isTransientComfyError(job.lastError)) {
|
if (!job.payload.upscale && job.kind !== 'music' && job.status === 'held' && isTransientComfyError(job.lastError)) {
|
||||||
job.status = 'waiting'
|
job.status = 'waiting'
|
||||||
job.liveJobId = undefined
|
job.liveJobId = undefined
|
||||||
job.lastError = undefined
|
job.lastError = undefined
|
||||||
@@ -1407,8 +1407,7 @@ async function startStudioMusicJob(item: StudioJob) {
|
|||||||
lyricsStrength: payload.instrumental ? 0 : (payload.lyricsStrength ?? 0.9),
|
lyricsStrength: payload.instrumental ? 0 : (payload.lyricsStrength ?? 0.9),
|
||||||
instrumental: payload.instrumental === true,
|
instrumental: payload.instrumental === true,
|
||||||
folderLocked: payload.folderLocked,
|
folderLocked: payload.folderLocked,
|
||||||
engine: payload.musicEngine || 'ace-step',
|
engine: 'yue2',
|
||||||
yueProfile: payload.yueProfile ?? 1,
|
|
||||||
samplerName: payload.samplerName || 'euler',
|
samplerName: payload.samplerName || 'euler',
|
||||||
scheduler: payload.scheduler || 'simple'
|
scheduler: payload.scheduler || 'simple'
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
export function folderAssets(library, jobs, folderId) {
|
export function folderAssets(library, jobs, folderId) {
|
||||||
const metadata=new Map(jobs.flatMap(j=>j.outputs || []).map(a=>[a.id,a.studio2]))
|
const metadata=new Map(jobs.flatMap(j=>j.outputs || []).map(a=>[a.id,a.studio2]))
|
||||||
return ['still','clip'].flatMap(kind=>(library[kind==='still'?'stills':'clips'] || []).filter(a=>a.id && a.folderId===folderId && a.hasVideo!==false && a.available!==false).map(a=>({...a,kind,studio2:a.studio2 || metadata.get(a.id)}))).sort((a,b)=>(a.createdAt || a.studio2?.savedAt || 0)-(b.createdAt || b.studio2?.savedAt || 0))
|
const kinds=[['still','stills'],['clip','clips'],['track','tracks']]
|
||||||
|
return kinds.flatMap(([kind,key])=>(library[key] || []).filter(a=>a.id && a.folderId===folderId && a.hasVideo!==false && a.available!==false).map(a=>({...a,kind,studio2:a.studio2 || metadata.get(a.id)}))).sort((a,b)=>(a.createdAt || a.studio2?.savedAt || 0)-(b.createdAt || b.studio2?.savedAt || 0))
|
||||||
}
|
}
|
||||||
export function newestOutput(jobs, seen, folderId) {
|
export function newestOutput(jobs, seen, folderId) {
|
||||||
return jobs.filter(j=>j.request.folderId===folderId).flatMap(j=>j.outputs || []).filter(a=>!seen.has(a.id)).sort((a,b)=>(a.studio2?.savedAt || 0)-(b.studio2?.savedAt || 0)).at(-1) || null
|
return jobs.filter(j=>j.request.folderId===folderId).flatMap(j=>j.outputs || []).filter(a=>!seen.has(a.id)).sort((a,b)=>(a.studio2?.savedAt || 0)-(b.studio2?.savedAt || 0)).at(-1) || null
|
||||||
}
|
}
|
||||||
export function reconcileSelection(selected, assets) { return assets.find(a=>a.id===selected?.id) || assets.at(-1) || null }
|
export function reconcileSelection(selected, assets) { return assets.find(a=>a.id===selected?.id) || assets.at(-1) || null }
|
||||||
export function activeJobs(jobs) { return jobs.filter(j=>['waking','submitting','rendering','saving'].includes(j.state)) }
|
export function activeJobs(jobs) { return jobs.filter(j=>['waking','submitting','rendering','saving'].includes(j.state)) }
|
||||||
export function previewHidden(asset, xaigen, hideInput, hideOutput) { return !!xaigen && (asset?.role==='input' ? !!hideInput : !!hideOutput) }
|
export function previewHidden(asset, revealed, xaigen) {
|
||||||
|
if (!xaigen || !asset?.id) return false
|
||||||
|
return !revealed?.has?.(asset.id)
|
||||||
|
}
|
||||||
export function selectResult(state, asset) { return {...state,selected:asset} }
|
export function selectResult(state, asset) { return {...state,selected:asset} }
|
||||||
export function assetLabel(asset) { return asset?.name || asset?.originalName || asset?.filename || (asset ? (asset.kind==='clip'?'Clip ':'Still ')+asset.id.slice(0,8) : 'None') }
|
export function assetLabel(asset) {
|
||||||
|
if (!asset) return 'None'
|
||||||
|
if (asset.name || asset.originalName || asset.filename) return asset.name || asset.originalName || asset.filename
|
||||||
|
if (asset.kind==='track') return 'Track '+asset.id.slice(0,8)
|
||||||
|
if (asset.kind==='clip') return 'Clip '+asset.id.slice(0,8)
|
||||||
|
return 'Still '+asset.id.slice(0,8)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import {readFileSync} from 'node:fs'
|
import {readFileSync} from 'node:fs'
|
||||||
import * as Vue from 'vue'
|
|
||||||
import {compile} from '@vue/compiler-dom'
|
|
||||||
import {renderToString} from '@vue/server-renderer'
|
|
||||||
import ts from 'typescript'
|
import ts from 'typescript'
|
||||||
import {folderAssets,reconcileSelection,activeJobs,previewHidden} from '../shared/studio2/viewer.mjs'
|
import {folderAssets,reconcileSelection,activeJobs,previewHidden} from '../shared/studio2/viewer.mjs'
|
||||||
import {useAsHero,useAsSource,restoreAll} from '../shared/studio2/input.mjs'
|
import {useAsHero,useAsSource,restoreAll} from '../shared/studio2/input.mjs'
|
||||||
@@ -24,14 +21,15 @@ test('bench includes only active execution phases and clear finished retains int
|
|||||||
assert.equal(fn({}).cleared,1);assert.equal(rows[0].dismissed,true);assert.equal(rows[0].cleanupPending.length,1);assert.equal(rows[1].dismissed,undefined)
|
assert.equal(fn({}).cleared,1);assert.equal(rows[0].dismissed,true);assert.equal(rows[0].cleanupPending.length,1);assert.equal(rows[1].dismissed,undefined)
|
||||||
})
|
})
|
||||||
test('hidden xAIGen canvas and family render no image or video elements',async()=>{
|
test('hidden xAIGen canvas and family render no image or video elements',async()=>{
|
||||||
|
const revealed=new Set()
|
||||||
|
assert.equal(previewHidden({id:'a',role:'input'},revealed,true),true)
|
||||||
|
assert.equal(previewHidden({id:'a',role:'input'},new Set(['a']),true),false)
|
||||||
|
assert.equal(previewHidden({id:'a'},revealed,false),false)
|
||||||
const src=readFileSync(new URL('../components/studio2/Bench.vue',import.meta.url),'utf8')
|
const src=readFileSync(new URL('../components/studio2/Bench.vue',import.meta.url),'utf8')
|
||||||
const stage=src.slice(src.indexOf(' <div class="flex min-h-80'),src.indexOf(' <div v-if="selected"'))
|
assert.match(src,/aria-label="Current family outputs"/)
|
||||||
const strip=src.split('\n').find(line=>line.includes('aria-label="Current family outputs"'))
|
assert.match(src,/preview hidden/)
|
||||||
const render=new Function('Vue',compile('<section>'+stage+strip+'</section>',{mode:'function',prefixIdentifiers:true}).code)(Vue)
|
assert.doesNotMatch(src,/Hide input stills/)
|
||||||
const state={selected:{id:'a',kind:'still'},hideOutput:true,xaigen:true,family:[{id:'a',kind:'still'}],form:{hideInput:true},previewHidden,stillUrl:id=>'/image/'+id,selectOutput(){}}
|
assert.doesNotMatch(src,/Hide output preview/)
|
||||||
const hidden=await renderToString(Vue.createSSRApp({render,data:()=>state}));assert.doesNotMatch(hidden,/<img|<video/);assert.match(hidden,/preview hidden/)
|
|
||||||
const visible=await renderToString(Vue.createSSRApp({render,data:()=>({...state,xaigen:false})}));assert.match(visible,/<img/)
|
|
||||||
assert.equal(previewHidden({role:'input'},true,true,false),true)
|
|
||||||
})
|
})
|
||||||
test('cached LoRAs survive sleeping GPU and failed explicit refresh',async()=>{
|
test('cached LoRAs survive sleeping GPU and failed explicit refresh',async()=>{
|
||||||
const cache={image:['Klein/portrait.safetensors'],video:['motion.safetensors']};let queried=0
|
const cache={image:['Klein/portrait.safetensors'],video:['motion.safetensors']};let queried=0
|
||||||
|
|||||||
+4
-3
@@ -91,9 +91,10 @@ test('music workflow refuses yue and yue2 while ACE graphs still build', () => {
|
|||||||
assert.equal(existsSync(new URL('../server/assets/workflow_yue.json', import.meta.url)), false)
|
assert.equal(existsSync(new URL('../server/assets/workflow_yue.json', import.meta.url)), false)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('musicChain routes yue2 to startYue2Job and leaves yue on YuEGP', () => {
|
test('musicChain is YuE2-only', () => {
|
||||||
const source = readFileSync(new URL('../server/utils/musicChain.ts', import.meta.url), 'utf8')
|
const source = readFileSync(new URL('../server/utils/musicChain.ts', import.meta.url), 'utf8')
|
||||||
assert.match(source, /engine === 'yue'\) return startYueGpJob/)
|
assert.match(source, /startYue2Job/)
|
||||||
assert.match(source, /engine === 'yue2'\) return startYue2Job/)
|
|
||||||
assert.match(source, /from '\.\/yue2'/)
|
assert.match(source, /from '\.\/yue2'/)
|
||||||
|
assert.doesNotMatch(source, /startYueGpJob/)
|
||||||
|
assert.doesNotMatch(source, /buildMusicWorkflow/)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,14 +5,10 @@ import type { ImageV2PresetSettings } from '~/utils/imageV2'
|
|||||||
export type GenerationPresetKind = 'video' | 'image' | 'imagev2' | 'music'
|
export type GenerationPresetKind = 'video' | 'image' | 'imagev2' | 'music'
|
||||||
|
|
||||||
export type MusicPresetSettings = {
|
export type MusicPresetSettings = {
|
||||||
yueProfile?: 1 | 3
|
|
||||||
tags?: string
|
tags?: string
|
||||||
lyrics?: string
|
lyrics?: string
|
||||||
instrumental?: boolean
|
|
||||||
duration?: number
|
duration?: number
|
||||||
steps?: number
|
|
||||||
seed?: number
|
seed?: number
|
||||||
cfg?: number
|
|
||||||
engine?: import('~/utils/music').MusicEngine
|
engine?: import('~/utils/music').MusicEngine
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -299,9 +299,18 @@ export function trackSettingsRows(track: {
|
|||||||
}) {
|
}) {
|
||||||
const rows: SettingsRow[] = []
|
const rows: SettingsRow[] = []
|
||||||
if (track.engine) {
|
if (track.engine) {
|
||||||
|
const engine = String(track.engine)
|
||||||
rows.push({
|
rows.push({
|
||||||
label: 'Engine',
|
label: 'Engine',
|
||||||
value: track.engine === 'ace-step-1.5' ? 'ACE-Step 1.5' : track.engine === 'yue' ? 'YuE' : 'ACE-Step'
|
value: engine === 'yue2' || engine === 'yue-2'
|
||||||
|
? 'YuE2'
|
||||||
|
: engine === 'ace-step-1.5'
|
||||||
|
? 'ACE-Step 1.5 (legacy)'
|
||||||
|
: engine === 'yue'
|
||||||
|
? 'YuE (legacy)'
|
||||||
|
: engine.startsWith('ace')
|
||||||
|
? 'ACE-Step (legacy)'
|
||||||
|
: engine
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (track.tags?.trim()) rows.push({ label: 'Tags', value: track.tags.trim() })
|
if (track.tags?.trim()) rows.push({ label: 'Tags', value: track.tags.trim() })
|
||||||
|
|||||||
+10
-20
@@ -1,5 +1,5 @@
|
|||||||
export const MUSIC_DURATION_MIN = 30
|
export const MUSIC_DURATION_MIN = 30
|
||||||
export const MUSIC_DURATION_MAX = 180
|
export const MUSIC_DURATION_MAX = 150
|
||||||
export const MUSIC_DURATION_DEFAULT = 60
|
export const MUSIC_DURATION_DEFAULT = 60
|
||||||
export const MUSIC_STEPS_MIN = 8
|
export const MUSIC_STEPS_MIN = 8
|
||||||
export const MUSIC_STEPS_MAX = 100
|
export const MUSIC_STEPS_MAX = 100
|
||||||
@@ -33,27 +33,18 @@ export const DEFAULT_MUSIC_LYRICS = `[Verse 1]
|
|||||||
[Outro]
|
[Outro]
|
||||||
`
|
`
|
||||||
|
|
||||||
export type MusicEngine = 'ace-step' | 'ace-step-1.5' | 'yue' | 'yue2'
|
export type MusicEngine = 'yue2'
|
||||||
|
|
||||||
export function parseMusicEngine(raw: unknown): MusicEngine {
|
export function parseMusicEngine(raw: unknown): MusicEngine {
|
||||||
const value = String(raw || '').trim().toLowerCase()
|
const value = String(raw || '').trim().toLowerCase()
|
||||||
if (value === 'ace-step-1.5' || value === 'ace15' || value === 'v1.5') return 'ace-step-1.5'
|
if (value === 'yue2' || value === 'yue-2' || !value) return 'yue2'
|
||||||
if (value === 'yue2' || value === 'yue-2') return 'yue2'
|
// Legacy ACE / YuEGP values map to YuE2 — the only supported music engine.
|
||||||
if (value === 'yue' || value === 'yue-s1' || value === 'lyrics2song') return 'yue'
|
return 'yue2'
|
||||||
return 'ace-step'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isAceMusicEngine(engine: MusicEngine | string | undefined) {
|
|
||||||
const value = parseMusicEngine(engine)
|
|
||||||
return value !== 'yue' && value !== 'yue2'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function musicEngineLabel(engine: MusicEngine | string | undefined) {
|
export function musicEngineLabel(engine: MusicEngine | string | undefined) {
|
||||||
const value = parseMusicEngine(engine)
|
void engine
|
||||||
if (value === 'ace-step-1.5') return 'ACE-Step 1.5'
|
return 'YuE2'
|
||||||
if (value === 'yue2') return 'YuE2'
|
|
||||||
if (value === 'yue') return 'YuE'
|
|
||||||
return 'ACE-Step'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseMusicBpm(tags: unknown, fallback = 120) {
|
export function parseMusicBpm(tags: unknown, fallback = 120) {
|
||||||
@@ -97,18 +88,17 @@ export function sanitizeMusicLyrics(raw: unknown, instrumental = false) {
|
|||||||
return String(raw || '').replace(/\r\n/g, '\n').trim().slice(0, MUSIC_LYRICS_MAX)
|
return String(raw || '').replace(/\r\n/g, '\n').trim().slice(0, MUSIC_LYRICS_MAX)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** YuE splits on [Section] headers. Returns how many sections are present. */
|
/** YuE2 splits on [Section] headers. Returns how many section tags are present. */
|
||||||
export function countYueLyricSections(lyrics: unknown) {
|
export function countYueLyricSections(lyrics: unknown) {
|
||||||
const text = String(lyrics || '')
|
const text = String(lyrics || '')
|
||||||
const matches = text.match(/\[[^\]]+\]/g)
|
const matches = text.match(/\[[^\]]+\]/g)
|
||||||
return matches ? matches.length : 0
|
return matches ? matches.length : 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** YuE2 allows multiple lyric sections; only empty lyrics are rejected. */
|
||||||
export function yueLyricsProblem(lyrics: unknown): string | null {
|
export function yueLyricsProblem(lyrics: unknown): string | null {
|
||||||
const text = String(lyrics || '').trim()
|
const text = String(lyrics || '').trim()
|
||||||
if (!text) return 'YuE needs lyrics with section tags like [Verse 1] and [Chorus].'
|
if (!text) return 'YuE2 needs lyrics. Use section tags like [Verse 1] and [Chorus] when you want structure.'
|
||||||
const sections = [...text.matchAll(/\[([^\]]+)\]\s*([^\[]*)/gs)].filter(match => match[2].trim())
|
|
||||||
if (sections.length !== 1) return 'YuEGP needs exactly one non-empty lyric section. Combine the lyrics under one heading.'
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user