Compare commits

...
49 Commits
Author SHA1 Message Date
TowstyandCursor 2ebc3066e6 Reject instrumental music edits and force YuE2 on queued track patches.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 18:20:11 -05:00
TowstyandCursor c868605d8a Drop ACE and YuEGP defaults from studio music queue and library labels.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 18:19:46 -05:00
TowstyandCursor 1db62e9187 Make Studio 2 the home bench with YuE2 music, LTX, and per-preview hide.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 18:19:34 -05:00
TowstyandCursor a216afc4ce Remove YuEGP handlers from the Comfy host agent.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 18:17:59 -05:00
TowstyandCursor cce1faa9b1 Route all music jobs through YuE2 only.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 18:16:45 -05:00
TowstyandCursor c50bde723f Accept only YuE2 in the music generate API.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 18:16:45 -05:00
TowstyandCursor 17cc07e3bc Accept only YuE2 as the music engine type.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 18:16:44 -05:00
TowstyandCursor df885d5732 Replace stale host agent on boot and document one-click Startup install.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 18:15:19 -05:00
TowstyandCursor 840440d7ef Remove YuE2 process memory fraction cap on the 5080.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 20:14:44 -05:00
TowstyandCursor ee84a99035 Tighten YuE2 VRAM use on the 5080 and empty GPU before launch.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 20:01:06 -05:00
TowstyandCursor 62671d05da Allow multi-section YuE2 lyrics and force non-flash attention on Windows.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 19:28:43 -05:00
TowstyandCursor fae3b23e37 Finish YuE2 host setup, weight download paths, and v1 cleanup.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 18:46:35 -05:00
TowstyandCursor d1ff05089f Document YuE2 install, weight cleanup, and regression tests.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 18:46:35 -05:00
TowstyandCursor 71a61b1765 Add YuE2 to the music engine picker and generate API.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 18:46:35 -05:00
TowstyandCursor ec5791c595 Mount YuE2 on the host agent and share GPU ownership.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 18:46:35 -05:00
TowstyandCursor 5f68205f01 Route engine yue2 through a standalone job helper.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 18:45:57 -05:00
TowstyandCursor 85ef087364 Add YuE2 host, worker, and setup scripts.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 18:45:57 -05:00
Towsty 064f1db933 Read production LoRA names in Studio 2 2026-09-12 21:05:11 -05:00
Towsty fb87a50a39 Show each LoRA dropdown label once 2026-09-12 20:45:19 -05:00
Towsty 401e6c26a2 Remember Studio 2 LoRA stacks for each engine 2026-09-12 20:17:58 -05:00
Towsty 6a6931f04e Filter LoRA picker options by engine tokens 2026-09-12 20:17:58 -05:00
Towsty e190496eb4 Restore Klein empty-latent reference sampling 2026-09-12 12:18:14 -05:00
Towsty 7bb4b16c1e Sample Klein hero identity from image latents 2026-09-12 11:46:56 -05:00
Towsty 3da3656140 Show pending Studio 2 jobs and freeze finished timers 2026-09-12 10:39:33 -05:00
Towsty 3e90d2ff45 Show clear GPU status and move upscale progress into the queue 2026-09-12 10:17:03 -05:00
Towsty a9551911f2 Encode Unicode video titles in playback response headers 2026-09-12 10:17:02 -05:00
TowstyandCursor d8f42ecc68 Tighten needsFit to exact canvas size, guard against empty renders
needsFit only compared aspect ratio, so a same-aspect-but-larger still
(e.g. a 4032x2268 phone photo against a 1344x768 canvas) would skip
fitting and hand MiniMaxH3AddGuide a still that does not match the
job's own latent size. Require an exact width+height match instead;
anything else (including unreadable dimensions) still fits.

Also guard the video save path: an empty Comfy download, an empty
stitched assembly, or a stitched result with no readable duration now
fails the job with a clear error instead of silently saving/attaching
a broken zero-length clip.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-11 23:11:02 -05:00
TowstyandCursor 65c69b779a Stop resampling the extend start frame every hop
upload() ran fitStill (ffmpeg scale+pad) on every image except hero,
including the start/handoff still. Four chained extends resampled the
anchor frame four times and destroyed identity even with the hero
reference and lock text intact.

- upload(): only fit when the still's aspect truly differs from the
  job canvas (needsFit); never fit hero or start regardless.
- Track handoffSource (anchor|extract) and whether the start frame was
  fitted; surface both in job metadata and Details.
- extendSelected(): re-binding an already-selected clip on task switch
  must not stomp the user's overridden locks/settings back to the
  clip's saved values (regression introduced by the last hotfix push).
- Add tests/studio2-extend-fit.test.mjs: two extends at identical
  width/height reach Comfy with the anchor PNG byte-identical, no
  ffmpeg fit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-11 21:12:24 -05:00
towsty c6451b10ce FIX EFFING EXTEND FILE 2026-09-12 00:30:50 +00:00
towsty 937e4d120c Upload files to "components/studio2"
FIX EFFING EXTEND BUTTON
2026-09-12 00:30:19 +00:00
Towsty ec0b6f10ab Bind the selected output when restoring complete input settings 2026-09-11 19:06:17 -05:00
Towsty ad135ae19c Simplify Studio 2 sources and persist private previews and LoRA lists 2026-09-11 19:04:03 -05:00
Towsty 86ffa9ba04 Compile image style chips and restore complete input metadata 2026-09-11 19:04:03 -05:00
Towsty b5a4882a9c Keep Studio 2 family tiles tied to existing library files 2026-09-11 19:04:03 -05:00
Towsty b1ffcd6975 Retry confirmed Studio 2 host cleanup after failures 2026-09-11 18:43:25 -05:00
Towsty 28b919ecee Sample Klein edits from source and bind Studio 2 extensions correctly 2026-09-11 12:23:34 -05:00
Towsty 8fe69272c3 Use desktop width and protect the Studio 2 prompt column 2026-09-11 09:33:05 -05:00
Towsty c719e12709 Select and confirm uploaded Studio 2 source and hero stills 2026-09-11 09:28:00 -05:00
Towsty bf58732690 Separate Studio 2 source and canvas and apply size and seed choices 2026-09-11 07:07:26 -05:00
Towsty 09c96c0ae7 Fit Studio 2 inputs and report live generation progress 2026-09-10 23:48:25 -05:00
Towsty 5c69650ffa Restore Studio 2 controls and automatic extension binding 2026-09-10 23:48:25 -05:00
Towsty 3b071436a3 Model camera beats and independent hero identity locks 2026-09-10 23:48:25 -05:00
Towsty f312cff477 Return preview users to Studio 2 after login 2026-09-10 21:33:41 -05:00
Towsty fd9522ae19 Enable the isolated xAIGen Studio 2 preview 2026-09-10 21:31:43 -05:00
Towsty 8c464f283d Add the Studio 2 bench and restore saved prompt sections 2026-09-10 20:56:37 -05:00
Towsty 70c17695e4 Restrict Studio 2 host cleanup to exact preview paths 2026-09-10 20:56:37 -05:00
Towsty 70b6b70fcd Queue durable Studio 2 jobs with sticky identity and saved metadata 2026-09-10 20:56:37 -05:00
Towsty bd82f5bd5f Add native H3 identity graph and Studio 2 prompt contracts 2026-09-10 20:13:53 -05:00
Towsty d4f2b86587 Document isolated Studio 2 PR preview settings 2026-09-10 20:10:16 -05:00
98 changed files with 11909 additions and 8989 deletions
+17
View File
@@ -51,3 +51,20 @@ IMAGE_COMFY_PORT=8198
# Optional override if image Comfy on Beast is not the video instance
# IMAGE_COMFY_PRIMARY_HOST=http://192.168.77.7:8198
IMAGE_COMFY_LAUNCH_CMD=
# Studio 2 PR previews: production apps remain on main. Use the PR preview URL.
# Preview must NOT use production LIBRARY_DIR read-write.
# LIBRARY_DIR=/data/library-preview
# Alternative ephemeral preview storage: LIBRARY_DIR=/tmp/library-preview
# COMFY_FILENAME_PREFIX=video/MiniMax_H3_preview
# COMFY_AUDIO_PREFIX=audio/AIGen_preview
# BACKUP_STUDIO_URL=
# NUXT_SESSION_SECRET=<generate a new preview-only secret>
# Same Beast Comfy 8198 / control 8199; one shared GPU, distinct purge prefix.
# Open /studio-2. Do not create a long-lived aigen-next Coolify app.
# Studio 2 cleanup requires this branch's /studio2/purge host endpoint and its
# scripts/studio2-purge.mjs + shared/studio2/contracts.mjs dependencies.
# Update/reload the host agent only when existing production tests are idle.
# Older agents are supported for generation; cleanup reports "Left on host"
# rather than falling back to the legacy purge endpoint.
+8 -2
View File
@@ -31,7 +31,7 @@
>
<span class="block leading-none" aria-hidden="true">⋮⋮</span>
</button>
<span class="min-w-0 truncate text-zinc-200">{{ labelFor(item.name) }}</span>
<span class="min-w-0"><span class="block truncate text-zinc-200">{{ labelFor(item.name) }}</span><span v-if="filenameSubtitle" class="block truncate text-[11px] text-zinc-500" :title="item.name">{{ item.name }}</span></span>
<LoraTriggerTip
:text="triggerFor(item.name)"
:aria-label="'Activation for ' + labelFor(item.name)"
@@ -97,6 +97,7 @@
v-for="name in availableNames"
:key="name"
:value="name"
:title="name"
>{{ labelFor(name) }}</option>
</select>
</div>
@@ -112,6 +113,8 @@ import {
loraFriendlyName,
loraIdentityKey,
loraMatchesImageEngine,
loraMatchesUse,
type LoraUse,
loraTriggerFor,
makeLoraStackItem,
type LoraStackItem
@@ -119,6 +122,7 @@ import {
const props = withDefaults(defineProps<{
names: string[]
filenameSubtitle?: boolean
label?: string
emptyLabel?: string
addLabel?: string
@@ -129,8 +133,10 @@ const props = withDefaults(defineProps<{
triggers?: Record<string, string>
aliases?: Record<string, string>
engine?: 'flux' | 'krea' | ''
use?: LoraUse
}>(), {
label: 'LoRA',
filenameSubtitle: false,
emptyLabel: 'None',
addLabel: 'Add LoRA',
missingLabel: 'Wake GPU to load LoRAs',
@@ -157,7 +163,7 @@ const availableNames = computed(() => {
const engine = props.engine === 'flux' || props.engine === 'krea' ? props.engine : null
return props.names
.filter(name => !taken.has(loraIdentityKey(name)))
.filter(name => !engine || loraMatchesImageEngine(name, engine))
.filter(name => props.use ? loraMatchesUse(name, props.use) : !engine || loraMatchesImageEngine(name, engine))
.slice()
.sort((a, b) => labelFor(a).localeCompare(labelFor(b), undefined, { sensitivity: 'base' }))
})
+5 -33
View File
@@ -1,7 +1,7 @@
<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">
<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>
<div v-else class="mb-4 grid grid-cols-3 gap-3">
<div
@@ -89,7 +89,7 @@
:class="current === 'music' ? 'border-amber-300 bg-amber-400/10 text-amber-50' : 'border-white/10 text-zinc-400'"
>
<NuxtLink
to="/music"
to="/music?engine=yue2"
class="block w-full px-4 pb-3 pt-4 text-left transition"
:class="current === 'music' ? '' : 'hover:text-zinc-200'"
>
@@ -99,26 +99,7 @@
</svg>
<span class="mt-3 block font-display text-lg font-bold text-zinc-100">Music</span>
</NuxtLink>
<div class="grid grid-cols-2 border-t border-white/10">
<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="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">Lyrics</span>
</button>
</div>
<p class="border-t border-white/10 px-4 py-2 text-xs text-zinc-400">YuE2 · 3B lyrics-to-song</p>
</div>
</div>
</template>
@@ -131,20 +112,19 @@ const props = withDefaults(defineProps<{
videoEngine?: 'minimax' | 'ltx'
videoStart?: 'still' | 'text'
imageEngine?: 'flux' | 'krea'
musicEngine?: 'ace' | 'yue'
musicEngine?: 'yue2'
}>(), {
ltxEnabled: false,
videoEngine: 'minimax',
videoStart: 'still',
imageEngine: 'flux',
musicEngine: 'ace'
musicEngine: 'yue2'
})
const emit = defineEmits<{
select: [kind: 'video' | 'image']
'video-engine': [engine: 'minimax' | 'ltx']
'image-engine': [engine: 'flux' | 'krea']
'music-engine': [engine: 'ace' | 'yue']
}>()
function engineBtnClass(active: boolean) {
@@ -161,12 +141,4 @@ function pickImage(engine: 'flux' | 'krea') {
emit('image-engine', engine)
if (props.current !== 'image') emit('select', 'image')
}
function pickMusic(engine: 'ace' | 'yue') {
if (props.current === 'music') {
emit('music-engine', engine)
return
}
void navigateTo({ path: '/music', query: { engine } })
}
</script>
+3 -3
View File
@@ -1,8 +1,8 @@
<template>
<div v-if="clipId" class="space-y-2">
<button type="button" class="rounded-2xl border border-amber-300/40 px-4 py-2 text-sm text-amber-100 disabled:opacity-50" :disabled="busy || loading || !state" @click="openDialog">Upscale</button>
<NuxtLink v-if="state?.source.upscaledFromClipId" :to="`/?clip=${state.source.upscaledFromClipId}`" class="block text-xs text-amber-200 underline">Upscaled copy · Open original</NuxtLink>
<div v-if="state?.job" class="max-w-sm text-xs text-zinc-400" aria-live="polite">
<NuxtLink v-if="state?.source.upscaledFromClipId && !queueOnly" :to="`/?clip=${state.source.upscaledFromClipId}`" class="block text-xs text-amber-200 underline">Upscaled copy · Open original</NuxtLink>
<div v-if="state?.job && !queueOnly" class="max-w-sm text-xs text-zinc-400" aria-live="polite">
<span class="capitalize">Upscale {{ state.job.status }}</span>
<span v-if="state.job.width"> · {{ state.job.width }} × {{ state.job.height }}</span>
<span v-if="state.job.elapsedMs"> · {{ Math.round(state.job.elapsedMs / 1000) }}s</span>
@@ -36,7 +36,7 @@
</template>
<script setup lang="ts">
import { upscaleOptions, upscaleDimensions, upscaleName } from '~/shared/video-upscale.mjs'
const props = defineProps<{ clipId: string; generationBusy?: boolean }>()
const props = defineProps<{ clipId: string; generationBusy?: boolean; queueOnly?: boolean }>()
const emit = defineEmits<{ complete: [] }>()
const dialog = ref<HTMLDialogElement>(), dialogOpen = ref(false), state = ref<any>(null), error = ref(''), loading = ref(true), submitting = ref(false)
const form = reactive(upscaleOptions())
+8
View File
@@ -0,0 +1,8 @@
<template>
<div class="w-36 shrink-0 space-y-2"><span class="text-xs text-zinc-400">{{ label }}</span><button class="flex h-24 w-full items-center justify-center overflow-hidden rounded-xl border border-white/15 bg-black" @click="$emit('pick')"><span v-if="asset && hidden" class="p-2 text-xs text-zinc-400">Still loaded · preview hidden</span><img v-else-if="asset" :src="asset.kind==='clip'?`/api/library/clips/${asset.id}/thumb`:`/api/library/stills/${asset.id}`" class="h-full w-full object-contain" :alt="label"><span v-else class="text-xs text-zinc-400">Choose {{ label.toLowerCase() }}</span></button><p v-if="asset" class="truncate text-xs" :title="assetLabel(asset)">{{ assetLabel(asset) }}</p><label v-if="upload" class="block cursor-pointer text-xs text-amber-200">Upload {{ label.toLowerCase() }}<input type="file" accept="image/*" class="sr-only" :disabled="disabled" @change="$emit('upload',$event)"></label></div>
</template>
<script setup lang="ts">
import {assetLabel} from '~/shared/studio2/viewer.mjs'
defineProps<{label:string;asset?:any;hidden?:boolean;upload?:boolean;disabled?:boolean}>()
defineEmits(['pick','upload'])
</script>
+391
View File
@@ -0,0 +1,391 @@
<template>
<main class="w-full min-w-0 space-y-5 p-4 md:p-6 xl:p-8">
<header class="flex flex-wrap items-end justify-between gap-4">
<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">
<label class="text-sm text-zinc-300">Folder
<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">
<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>
</header>
<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">
<section class="min-w-0 space-y-4">
<div class="relative flex min-h-80 items-center justify-center overflow-hidden rounded-3xl border border-white/10 bg-black">
<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" />
<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>
</div>
<div v-if="selected" class="flex flex-wrap items-start gap-3">
<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>
<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>
<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 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>
<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>
<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&#10;&#10;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="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>
<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)" />
<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>
</div>
<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 type="button" class="float-right outline-none ring-amber-300/40 focus-visible:ring-2" @click="settingsOpen=false;detailsOpen=false">Close</button>
<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>
</div>
<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 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>
</main>
</template>
<script setup lang="ts">
import {createLoraMemory,loraUse} from '~/shared/studio2/lora-memory.mjs'
import {filterLorasForStudio,filterLorasForUse} from '~/utils/loras'
import {compilePrompt,sectionsFrom} from '~/shared/studio2/contracts.mjs'
import {aspectOptions,resolveSize,bindClip,changeSource,restoreAll,useAsHero,useAsSource} from '~/shared/studio2/input.mjs'
import {folderAssets,newestOutput,assetLabel,reconcileSelection,previewHidden} from '~/shared/studio2/viewer.mjs'
import {queueJobs} from '~/shared/studio2/queue-view.mjs'
import {gpuStatus} from '~/shared/studio2/status.mjs'
import {applyUploadedStill} from '~/shared/studio2/upload.mjs'
import {parseBatch,stylePrompt} from '~/shared/studio2/styles.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 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 library=ref<any>({folders:[],stills:[],clips:[],tracks:[]}),jobs=ref<any[]>([]),selected=ref<any>(null)
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)
const currentLoraUse=computed(()=>loraUse(form))
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'})
const route=useRoute()
const tasks=['generate','edit','compose','refine','video','extend','music']
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 folderJobs=computed(()=>jobs.value.filter(j=>j.request.folderId===form.folderId))
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 pickerAssets=computed(()=>picker.value==='startClipId'?clips.value.map((a:any)=>({...a,kind:'clip'})):stills.value.map((a:any)=>({...a,kind:'still'})))
const inputStill=computed(()=>stills.value.find((s:any)=>s.id===form.imageAId))
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 sourceSize=computed(()=>measured[sourceAsset.value?.id] || sourceAsset.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)})
function chooseTask(task:string){
if(task==='music'){form.mode='music';return}
if(task==='extend'){
form.mode='extend'
form.engine=xaigen && form.engine==='ltx'?'ltx':'minimax'
const clip=selected.value?.kind==='clip'?selected.value:clips.value.find((c:any)=>c.id===form.startClipId)
if(clip?.id){
try{Object.assign(form,bindClip(form,clip,xaigen,loraMemory))}
catch(e:any){error.value=e?.message||'Could not copy clip settings. Previous clip is still bound.';form.startClipId=clip.id}
}
return
}
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 selectOutput(item:any){selected.value=item;if(form.mode==='extend' && item?.kind==='clip')chooseClip(item.id)}
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)}
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=''}
async function clearFinished(){await $fetch('/api/studio-2/clear-finished',{method:'POST'});await refresh()}
async function deleteOutput(){
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 upload(event:Event,target:'source'|'hero'){
const input=event.target as HTMLInputElement,file=input.files?.[0];if(!file)return
uploading.value=true;uploadMessage.value='';error.value=''
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'}.`}
catch(e:any){error.value=e.data?.statusMessage||e.message}
finally{uploading.value=false;input.value=''}
}
async function refresh(){
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
studioQueue.value=Array.isArray(queue)?queue:(queue?.jobs||[])
const fresh=newestOutput(nextJobs,seenOutputs,form.folderId)
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 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)
}
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)})
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()
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)})
</script>
+25
View File
@@ -0,0 +1,25 @@
<template>
<dl class="space-y-3 text-sm">
<div v-for="row in rows" :key="row.label"><dt class="font-semibold">{{ row.label }}</dt><dd class="break-words whitespace-pre-wrap text-zinc-400">{{ row.value ?? 'Not recorded' }}</dd></div>
</dl>
</template>
<script setup lang="ts">
const props=defineProps<{metadata?:any}>()
const rows=computed(()=>{const m=props.metadata||{},s=m.settings||{};const time=(v:any)=>v?new Date(v).toLocaleString():null;return [
{label:'Kind / mode',value:[m.kind,m.mode].filter(Boolean).join(' / ')},
{label:'Engine',value:m.engine},{label:'Graph ID',value:m.graphId},
{label:'Sample latent',value:m.sampleLatent},{label:'Sample denoise',value:m.sampleDenoise},
{label:'Source still',value:m.sourceStillId || m.imageAId || 'None'},
{label:'Hero reference attached',value:m.heroReferenceAttached==null?'Not recorded':m.heroReferenceAttached?'Yes':'No'},
{label:'Seed',value:s.seed},{label:'Steps',value:s.steps},{label:'CFG',value:s.cfg},
{label:'Size',value:s.width&&s.height?`${s.width} × ${s.height}`:null},{label:'Duration',value:s.duration!=null?`${s.duration}s`:null},
{label:'LoRAs',value:s.loraStack?.length?s.loraStack.map((l:any)=>`${l.name} · ${l.strengthModel}`).join('\n'):'None'},
{label:'Hero still',value:m.identityStillId||'None'},{label:'Face lock',value:m.lockFace!==false?'On':'Off'},{label:'Outfit lock',value:m.lockOutfit!==false?'On':'Off'},
{label:'Aspect',value:s.aspect||'auto'},{label:'FPS',value:s.fps||24},{label:'Hide input',value:m.hideInput?'On':'Off'},
{label:'Start frame',value:m.startFrameSource?JSON.stringify(m.startFrameSource):'None'},
{label:'Handoff source',value:m.handoffSource || 'None'},{label:'Handoff fitted (resampled)',value:m.fitted==null?'Not recorded':m.fitted?'Yes':'No'},
{label:'End still',value:m.endStillId||'None'},{label:'Guide stills',value:m.guides?.length?JSON.stringify(m.guides):'None'},
{label:'Compiled prompt',value:m.compiledPrompt},{label:'Queued',value:time(m.queuedAt)},{label:'Started',value:time(m.startedAt)},{label:'Saved',value:time(m.savedAt)},
{label:'GPU seconds',value:m.gpuSeconds},{label:'Wall seconds',value:m.wallTime},{label:'Comfy prompt ID',value:m.promptId},{label:'Host cleanup',value:m.purgeResult}
]})
</script>
+9
View File
@@ -0,0 +1,9 @@
<template>
<div role="status" aria-live="polite" class="flex min-w-0 flex-wrap items-center gap-3">
<span class="inline-flex shrink-0 items-center gap-2 rounded-full border px-4 py-2 text-base font-semibold" :class="status.tone==='ready'?'border-emerald-400/50 bg-emerald-400/15 text-emerald-200':status.tone==='busy'?'border-amber-300/50 bg-amber-300/15 text-amber-200':'border-zinc-600 bg-zinc-800 text-zinc-200'"><span class="h-2.5 w-2.5 rounded-full bg-current" :class="{'animate-pulse':status.tone==='busy'}" />GPU · {{ status.label }}</span>
<span class="max-w-2xl truncate text-sm text-zinc-300" :title="status.detail">{{ status.detail }}</span>
</div>
</template>
<script setup lang="ts">
defineProps<{status:{label:string;tone:string;detail:string}}>()
</script>
+16
View File
@@ -0,0 +1,16 @@
<template>
<div class="space-y-2 rounded-xl border border-white/10 p-3 text-sm" aria-live="polite">
<div class="flex justify-between gap-3"><button class="text-left" @click="$emit('select')"><strong>{{ job.request.promptSections.action.slice(0,70) }}</strong><span class="block capitalize text-amber-200">{{ job.state==='waiting'?'queued':job.state }} · Shot {{ shot }}/{{ total }}</span></button><button v-if="!terminal" @click="$emit('cancel')">Cancel</button></div>
<p>{{ elapsed }} {{ job.startedAt?'elapsed':'waiting' }} · {{ job.request.settings.duration }}s target<span v-if="job.state==='rendering' && job.progress"> · Step {{ job.progress.step }}/{{ job.progress.maxStep }} ({{ job.progress.percent }}%)</span></p>
<progress v-if="job.state==='rendering'" class="h-2 w-full accent-amber-300" :value="job.progress?.percent" max="100" />
<p v-if="job.error" role="alert" class="text-red-300">{{ job.error }}</p>
</div>
</template>
<script setup lang="ts">
import {elapsedSeconds} from '~/shared/studio2/queue-view.mjs'
const props=defineProps<{job:any;now:number}>();defineEmits(['select','cancel'])
const terminal=computed(()=>['complete','failed','cancelled'].includes(props.job.state))
const total=computed(()=>props.job.prompts?.length || (props.job.request.batch?.length || 0)+1)
const shot=computed(()=>Math.min(total.value,(props.job.index || 0)+1))
const elapsed=computed(()=>{const seconds=elapsedSeconds(props.job,props.now);return `${Math.floor(seconds/60)}m ${seconds%60}s`})
</script>
+22
View File
@@ -0,0 +1,22 @@
<template>
<div class="space-y-4">
<fieldset class="space-y-3"><legend class="font-semibold">Camera beats · one continuous take</legend>
<div class="flex flex-wrap gap-2"><button v-for="(beat,i) in model.cameraBeats" :key="i" type="button" class="rounded-lg border px-3 py-2" :class="active===i?'border-amber-300 text-amber-200':'border-white/20'" @click="active=i">{{ i+1 }} · {{ beat.moves.join(' + ') }} · {{ beat.durationSec == null ? 'to end' : `${beat.durationSec}s` }}</button></div>
<div v-if="current" class="space-y-3 rounded-xl border border-white/15 p-3">
<div class="flex flex-wrap gap-2"><button v-for="move in cameraMoves" :key="move" type="button" :aria-pressed="current.moves.includes(move)" class="rounded-full border px-3 py-1 text-sm" :class="current.moves.includes(move)?'border-amber-300 bg-amber-300/10 text-amber-200':'border-white/15'" @click="model.cameraBeats[active]=toggleMove(current,move)">{{ move.replaceAll('-',' ') }}</button></div>
<div class="flex flex-wrap gap-3"><label>Amplitude<select v-model="current.amplitude" class="block rounded bg-zinc-800 p-2"><option v-for="v in ['subtle','moderate','large']" :key="v">{{ v }}</option></select></label><label>Speed<select v-model="current.speed" class="block rounded bg-zinc-800 p-2"><option v-for="v in ['slow','medium','fast']" :key="v">{{ v }}</option></select></label><label>Duration<select v-model="current.durationSec" class="block rounded bg-zinc-800 p-2"><option :value="null">To end</option><option v-for="n in 30" :key="n" :value="n/2">{{ n/2 }}s</option></select></label><button @click="model.cameraBeats.splice(active,1);active=Math.max(0,active-1)">Remove beat</button></div>
</div>
<div class="flex gap-4 text-sm text-amber-200"><button @click="addBeat">+ Camera beat</button><button @click="model.cameraBeats=[];model.camera='';active=0">Clear camera</button></div>
</fieldset>
<fieldset class="space-y-2"><legend class="font-semibold">Soundscape</legend><div class="flex flex-wrap gap-2"><button v-for="sound in soundscapeOptions" :key="sound" :aria-pressed="model.soundscapeChips.includes(sound)" class="rounded-full border px-3 py-1 text-sm" :class="model.soundscapeChips.includes(sound)?'border-amber-300 text-amber-200':'border-white/15'" @click="toggleSound(sound)">{{ sound }}</button></div><label class="block text-sm">Dynamics <select v-model="model.soundscapeLevel" class="rounded bg-zinc-800 p-2"><option>normal</option><option>crush</option></select></label></fieldset>
<fieldset class="space-y-2"><legend class="font-semibold">Dialogue</legend><div class="flex flex-wrap gap-2"><button v-for="mode in ['none','spoken to camera','spoken off-screen','whisper']" :key="mode" :aria-pressed="model.dialogue.mode===mode" class="rounded-full border px-3 py-1 text-sm" :class="model.dialogue.mode===mode?'border-amber-300 text-amber-200':'border-white/15'" @click="model.dialogue.mode=mode">{{ mode }}</button></div><template v-if="model.dialogue.mode!=='none'"><div class="flex gap-3"><label>Language<input v-model="model.dialogue.language" class="block w-full rounded bg-zinc-800 p-2"></label><label>Speaker<input v-model="model.dialogue.speaker" class="block w-20 rounded bg-zinc-800 p-2"></label></div><label class="block">Line<textarea v-model="model.dialogue.line" class="mt-1 w-full rounded-xl bg-zinc-900 p-3" /></label></template></fieldset>
<fieldset><legend class="mb-2 font-semibold">Music</legend><div class="flex gap-2"><button v-for="v in ['N/A','none','low underscore']" :key="v" :aria-pressed="model.musicChip===v" class="rounded-full border px-3 py-1 text-sm" :class="model.musicChip===v?'border-amber-300 text-amber-200':'border-white/15'" @click="model.musicChip=v;model.music=''">{{ v }}</button></div></fieldset>
</div>
</template>
<script setup lang="ts">
import {cameraMoves,soundscapeOptions,normalizeBeat,toggleMove} from '~/shared/studio2/prompt.mjs'
const model=defineModel<any>({required:true}),active=ref(0)
const current=computed(()=>model.value.cameraBeats[active.value])
function addBeat(){const last=model.value.cameraBeats.at(-1);if(last?.durationSec==null&&last)last.durationSec=2;model.value.cameraBeats.push(normalizeBeat());active.value=model.value.cameraBeats.length-1}
function toggleSound(sound:string){const chips=model.value.soundscapeChips;model.value.soundscapeChips=chips.includes(sound)?chips.filter((s:string)=>s!==sound):[...chips,sound]}
</script>
+10
View File
@@ -0,0 +1,10 @@
<template>
<section class="space-y-3" aria-label="Image style">
<p class="text-xs text-zinc-400">Click to include · right-click to exclude. Gold = positive, red = negative.</p>
<div v-for="(chips,group) in styleGroups" :key="group"><h3 class="mb-1 text-sm text-zinc-400">{{ group }}</h3><div class="flex flex-wrap gap-2"><button v-for="chip in chips" :key="chip" type="button" class="rounded-full border px-3 py-1 text-sm" :class="model.positive?.includes(chip)?'border-amber-300 bg-amber-300/15 text-amber-200':model.negative?.includes(chip)?'border-red-400 bg-red-400/15 text-red-200':'border-white/15'" :aria-pressed="model.positive?.includes(chip)||model.negative?.includes(chip)" @click="model=toggleStyle(model,chip)" @contextmenu.prevent="model=toggleStyle(model,chip,true)">{{ model.negative?.includes(chip)?'− ':model.positive?.includes(chip)?'+ ':'' }}{{ chip }}</button></div></div>
</section>
</template>
<script setup lang="ts">
import {styleGroups,toggleStyle} from '~/shared/studio2/styles.mjs'
const model=defineModel<any>({default:()=>({positive:[],negative:[]})})
</script>
+11
View File
@@ -0,0 +1,11 @@
# Production LoRA names in Studio 2
LoRA keys are user library data, not Git branch files. Separate dev storage does not contain production aliases automatically.
The dev host runs `xaigen-dev-lora-catalog.path`, watching the production user's catalog directory. Its oneshot service runs `server/utils/studio2/export-lora-catalog.py SOURCE_CATALOG DESTINATION` on changes. The installed helper is `/usr/local/lib/xaigen-dev/export-lora-catalog.py`.
The destination is `studio2/shared-lora-catalog.cache` inside the existing dev library volume. It contains **only** `loraAliases` and `loraTriggers`. No production catalog, media, passwords, or tokens are mounted into dev. The exporter writes atomically and skips unchanged content.
Both Studio 2 library and LoRA endpoints read this file on each request, normalize identity keys, and prefer production values over local duplicates. Unmapped dev entries remain available. If the export is unavailable, local settings remain usable. `STUDIO2_LORA_CATALOG_PATH` optionally overrides the export path.
The existing library poll refreshes labels and triggers without waking Comfy or restarting a generation. A one-time settings API import seeds local fallback labels and triggers. Main and Studio 1 code are unchanged.
+100
View File
@@ -0,0 +1,100 @@
# YuE2 music engine
`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)
Ran on this machine:
1. `scripts/setup-yue2.ps1` → checkout + venv + CUDA torch + weight download
2. `scripts/cleanup-yue-v1.ps1` → named YuE v1 / YuEGP weight folders removed
3. Host agent mounts `POST/GET /yue2/jobs`
Paths written:
| Item | Path |
| --- | --- |
| `YUE2_ROOT` | `C:\Users\ianjm\Development\YuE2` |
| Python | `C:\Users\ianjm\Development\YuE2\.venv\Scripts\python.exe` |
| Model | `C:\Users\ianjm\Development\YuE2\models\YuE2-3B` (~7.3 GB) |
| VAE | `C:\Users\ianjm\Development\YuE2\models\YuE2-Vae` (~531 MB) |
| Ready file | `C:\Users\ianjm\Development\YuE2\aigen-ready.json` |
| Jobs dir default | `C:\Users\ianjm\Development\YuE2\aigen-jobs` |
`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, 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
| Variable | Purpose |
| --- | --- |
| `YUE2_ROOT` | Standalone YuE checkout |
| `YUE2_PYTHON` | Isolated worker Python |
| `YUE2_JOBS_DIR` | Per-job logs / request / WAV |
| `YUE2_MODEL` | Local model 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 ACE/YuEGP fallback.
## Call path
```json
{"engine":"yue2","duration":60,"seed":42,"tags":"pop, warm vocals","lyrics":"[Verse 1]\nYour lyrics here","folderId":"<library-folder>","name":"My song"}
```
Picker → `POST /api/generate/music` → `startYue2Job` → `POST <COMFY_CONTROL_URL>/yue2/jobs` → `yue2-worker.py` (`plan` → `generate_semantic` → `synthesize` → `decode`) → library WAV.
Duration 30–150 s (default 60). One non-empty lyric section (same validation as Yue).
## Old YuE / YuEGP data deleted on this host
`scripts/cleanup-yue-v1.ps1` logged deletions to `scripts/cleanup-yue-v1.log`. Deleted:
- `...\ComfyUI-Shared\models\yue\YuE-s1-7B-anneal-en-cot`
- `...\ComfyUI-Shared\models\yue\YuE-s2-1B-general`
- `...\ComfyUI-Shared\models\yue\xcodec_mini_infer`
- `...\ComfyUI-Shared\models\yue\ckpt_00360000.pth`
- `...\ComfyUI-Shared\models\yue\decoder_131000.pth`
- `...\ComfyUI-Shared\models\yue\decoder_151000.pth`
- `C:\Users\ianjm\Development\YuEGP\inference\xcodec_mini_infer`
- `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 on disk.
## Re-run setup (only if rebuilding the machine)
```powershell
.\scripts\setup-yue2.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.
## Validation
- JS: `node --test tests/yue2.test.mjs`
- Python: `py -3 -m unittest tests.test_yue2_worker`
- After agent restart: `GET /yue2/status` returns 200.
- Do not claim a song generated unless one ran after the agent restart.
+1 -1
View File
@@ -53,7 +53,7 @@ export default defineNuxtConfig({
authMode: process.env.NUXT_AUTH_MODE || process.env.AUTH_MODE || '',
authUsername: process.env.AUTH_USERNAME || '',
authPassword: process.env.AUTH_PASSWORD || '',
backupStudioUrl: process.env.BACKUP_STUDIO_URL || (xaigenStudio ? '' : 'https://xaigen.carrgarage.com'),
backupStudioUrl: process.env.BACKUP_STUDIO_URL ?? (xaigenStudio ? '' : 'https://xaigen.carrgarage.com'),
backupStudioToken: process.env.BACKUP_STUDIO_TOKEN || 'carr-aigen-xaigen-backup',
backupIngestToken: process.env.BACKUP_INGEST_TOKEN || 'carr-aigen-xaigen-backup',
purgeComfyOutputs: process.env.PURGE_COMFY_OUTPUTS !== 'false',
+1 -8190
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -1074,6 +1074,11 @@ async function onMoveFocused(event: Event) {
}
function openInStudio(entry: LibraryEntry) {
const part = focusedParts.value.find(part => part.id === focusedPartId.value) || focusedParts.value[0]
if ((part as any)?.studio2) {
navigateTo({ path: '/studio-2', query: { [entry.kind === 'clip' ? 'clip' : 'still']: part!.id } })
return
}
if (entry.kind === 'clip' && entry.group) {
navigateTo({ path: '/', query: { clip: focusedPartId.value || entry.group.head.id } })
return
@@ -1088,6 +1093,11 @@ function openInStudio(entry: LibraryEntry) {
}
function useInStudio(entry: LibraryEntry) {
const part = focusedParts.value.find(part => part.id === focusedPartId.value) || focusedParts.value[0]
if ((part as any)?.studio2) {
navigateTo({ path: '/studio-2', query: { [entry.kind === 'clip' ? 'clip' : 'still']: part!.id, use: '1' } })
return
}
if (entry.kind === 'clip' && entry.group) {
navigateTo({ path: '/', query: { clip: focusedPartId.value || entry.group.head.id, use: '1' } })
return
+2 -1
View File
@@ -39,6 +39,7 @@
<script setup lang="ts">
const config = useRuntimeConfig()
const route = useRoute()
const instanceName = ref(String(config.public.instanceName || 'AIGen'))
const authMode = ref(String(config.public.authMode || ''))
const username = ref('')
@@ -57,7 +58,7 @@ async function submit() {
error.value = ''
try {
await $fetch('/api/auth/login', { method: 'POST', body: { username: username.value, password: password.value } })
await navigateTo('/')
await navigateTo(route.query.next === '/studio-2' ? '/studio-2' : '/')
} catch (err: any) {
error.value = err?.data?.statusMessage || err?.statusMessage || 'Sign in failed'
} finally {
+24 -164
View File
@@ -1,7 +1,7 @@
<template>
<div class="min-h-dvh min-w-0">
<AppHeader
:subtitle="advanced ? headerSubtitle : 'Create music from text'"
:subtitle="advanced ? 'YuE2 · lyrics-to-song' : 'Create music from text'"
current="music"
:queue-count="queueCount"
/>
@@ -10,9 +10,8 @@
<StudioKindCards
:advanced="advanced"
current="music"
:music-engine="engineFamily"
music-engine="yue2"
@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="grid min-w-0 gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:items-stretch">
@@ -20,42 +19,15 @@
<div>
<h2 class="font-display text-2xl font-bold">Music Generator</h2>
<p class="mt-1 text-sm text-zinc-400">
Tags describe style and voice. Lyrics use structure chips. Instrumental skips the vocal line.
Tags describe style and voice. Lyrics use structure chips. Multiple sections are OK.
</p>
</div>
<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')"><option value="ace">ACE-Step</option><option value="yue">YuE</option></select></label>
<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 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">
<label v-if="engineFamily !== 'yue'" 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>
</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>
<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>
@@ -72,18 +44,13 @@
<div>
<div class="mb-1 flex flex-wrap items-center justify-between gap-2">
<span class="text-sm font-medium text-zinc-300">Lyrics</span>
<label class="flex cursor-pointer items-center gap-2 text-xs text-zinc-400">
<input v-model="instrumental" :disabled="engineFamily === 'yue'" type="checkbox" class="accent-amber-400">
Instrumental
</label>
</div>
<div class="mb-2 flex flex-wrap gap-1.5">
<button
v-for="chip in chips"
:key="chip"
type="button"
class="rounded-full border border-white/10 px-2.5 py-1 text-[11px] text-zinc-300 hover:border-amber-300/50 hover:text-white disabled:opacity-40"
:disabled="instrumental"
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"
@click="insertChip(chip)"
>{{ chip }}</button>
</div>
@@ -91,8 +58,7 @@
ref="lyricsEl"
v-model="lyrics"
rows="12"
class="max-h-[min(28rem,50vh)] w-full overflow-y-auto rounded-2xl border border-white/10 bg-zinc-950/70 px-4 py-3 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 [overflow-wrap:anywhere] focus:ring-2 disabled:opacity-50"
:disabled="instrumental"
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"
:placeholder="lyricsPlaceholder"
/>
</div>
@@ -108,19 +74,7 @@
:max="durationMax"
step="5"
>
<span class="mt-1 block text-[11px] text-zinc-500">{{ engineFamily === 'yue' ? 'Approximate length · one lyric section' : `${durationMin}–${durationMax} seconds` }}</span>
</label>
<label v-if="advanced && engineFamily !== 'yue'" 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>
<span class="mt-1 block text-[11px] text-zinc-500">Approximate length · multiple sections OK</span>
</label>
</div>
@@ -208,7 +162,7 @@
</div>
<p v-if="busy && !queued && jobId" class="text-xs text-zinc-400">
{{ musicElapsed }} elapsed · {{ musicActivityText }}
<span v-if="activeMusicEngine === 'yue' && !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>
</p>
<AudioPlayer
@@ -235,18 +189,10 @@ const { advanced } = useStudioLayout()
import {
DEFAULT_MUSIC_LYRICS,
DEFAULT_MUSIC_TAGS,
MUSIC_CFG_DEFAULT,
MUSIC_CFG_DEFAULT_15,
MUSIC_DURATION_DEFAULT,
MUSIC_DURATION_MAX,
MUSIC_DURATION_MIN,
MUSIC_STEPS_DEFAULT,
MUSIC_STEPS_DEFAULT_15,
MUSIC_STEPS_MAX,
MUSIC_STEPS_MIN,
MUSIC_STRUCTURE_CHIPS,
parseMusicEngine,
type MusicEngine,
yueLyricsProblem,
} from '~/utils/music'
import { ensureGenerationNotifyPermission, notifyGenerationComplete } from '~/utils/generationNotify'
@@ -258,25 +204,16 @@ const tagsPlaceholder = DEFAULT_MUSIC_TAGS
const lyricsPlaceholder = DEFAULT_MUSIC_LYRICS
const chips = MUSIC_STRUCTURE_CHIPS
const durationMin = MUSIC_DURATION_MIN
const durationMax = computed(() => engineFamily.value === 'yue' ? 150 : 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 durationMax = MUSIC_DURATION_MAX
const tags = ref(DEFAULT_MUSIC_TAGS)
const lyrics = ref(DEFAULT_MUSIC_LYRICS)
const instrumental = ref(false)
const duration = ref(MUSIC_DURATION_DEFAULT)
const steps = ref(MUSIC_STEPS_DEFAULT)
const seed = ref('')
const name = ref('')
const folderId = ref('')
const folders = ref<LibraryFolder[]>([])
const lyricsEl = ref<HTMLTextAreaElement | null>(null)
const ace15 = ref(false)
const engineFamily = ref<'ace' | 'yue'>('ace')
const yueProfile = ref<1 | 3>(1)
const busy = ref(false)
const forceClearing = ref(false)
const error = ref('')
@@ -284,10 +221,9 @@ const status = ref('')
const progress = ref(0)
const musicClock = ref(Date.now())
const musicStartedAt = ref(0)
const activeMusicEngine = ref('')
const musicActivity = ref<{ checkedAt: number; running: boolean } | null>(null)
let musicClockTimer: ReturnType<typeof setInterval> | null = null
const indeterminateMusic = computed(() => !queued.value && activeMusicEngine.value === 'yue' && 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 musicElapsed = computed(() => {
const seconds = musicStartedAt.value ? Math.max(0, Math.floor((musicClock.value - musicStartedAt.value) / 1000)) : 0
@@ -317,56 +253,19 @@ let queuePoll: ReturnType<typeof setInterval> | null = null
let recoverPoll: ReturnType<typeof setInterval> | null = null
const blocked = computed(() => Boolean(blockReason.value))
const headerSubtitle = computed(() => {
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 === 'yue') return 'yue'
return ace15.value ? 'ace-step-1.5' : 'ace-step'
})
const blockReason = computed(() => {
if (!folderId.value) return 'Choose a library folder before generating.'
if (engineFamily.value === 'yue' && instrumental.value) return 'YuEGP requires lyrics. Use ACE for instrumental music.'
if (engineFamily.value === 'yue' && duration.value > 150) return 'YuEGP supports up to 150 seconds per section.'
if (duration.value > 150) return 'YuE2 supports up to 150 seconds.'
if (!tags.value.trim()) return 'Add genre and style tags.'
if (engineFamily.value === 'yue') { const problem = yueLyricsProblem(lyrics.value); if (problem) return problem }
if (!instrumental.value && !lyrics.value.trim()) return 'Write lyrics, or turn on Instrumental.'
const problem = yueLyricsProblem(lyrics.value)
if (problem) return problem
return ''
})
function selectEngineFamily(family: 'ace' | 'yue') {
engineFamily.value = family
if (family === 'yue') { 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') {
void navigateTo({ path: '/', query: { studio: kind } })
}
function applyEngine(engine: MusicEngine) {
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) {
const el = lyricsEl.value
const token = `${chip}\n`
@@ -452,9 +351,7 @@ async function attachLiveMusic(opts: {
if (typeof opts.progress === 'number') progress.value = opts.progress
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)) {
status.value = selectedEngine.value === 'yue'
? 'YuEGP is generating your song'
: 'Generating…'
status.value = 'YuE2 is generating your song'
}
if (jobId.value !== liveId) {
musicActivity.value = null
@@ -497,9 +394,7 @@ async function refreshStudioQueue() {
busy.value = true
status.value = musicRow.status === 'held'
? 'Paused in the job queue'
: (selectedEngine.value === 'yue'
? 'Waiting for the GPU to run YuEGP'
: 'Waiting in the job queue…')
: 'Waiting for the GPU to run YuE2'
}
if (!studioJobId.value) return
@@ -509,9 +404,7 @@ async function refreshStudioQueue() {
queued.value = false
}
if (jobId.value && /waiting in the job queue/i.test(status.value)) {
status.value = selectedEngine.value === 'yue'
? 'YuEGP is generating your song'
: 'Generating…'
status.value = 'YuE2 is generating your song'
}
return
}
@@ -520,9 +413,7 @@ async function refreshStudioQueue() {
busy.value = true
status.value = row.status === 'held'
? 'Paused in the job queue'
: (selectedEngine.value === 'yue'
? 'Waiting for the GPU to run YuEGP'
: 'Waiting in the job queue…')
: 'Waiting for the GPU to run YuE2'
return
}
if (row.status === 'running' && row.liveJobId) {
@@ -557,7 +448,6 @@ async function resumeActiveMusic() {
function applyEvent(payload: Record<string, any>) {
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.message) status.value = payload.message
if (typeof payload.progress === 'number') progress.value = payload.progress
@@ -665,11 +555,7 @@ async function generate() {
trackId.value = ''
jobId.value = ''
studioJobId.value = ''
status.value = engineFamily.value === 'yue'
? 'Queueing YuE…'
: ace15.value
? 'Queueing ACE-Step 1.5…'
: 'Queueing ACE-Step…'
status.value = 'Queueing YuE2…'
try {
const started = await $fetch<{ jobId: string; studioJobId?: string; queued?: boolean; name?: string }>(
'/api/generate/music',
@@ -679,14 +565,10 @@ async function generate() {
tags: tags.value,
lyrics: lyrics.value,
duration: duration.value,
steps: steps.value,
seed: seed.value || 'random',
cfg: selectedEngine.value === 'ace-step-1.5' ? MUSIC_CFG_DEFAULT_15 : MUSIC_CFG_DEFAULT,
instrumental: instrumental.value,
folderId: folderId.value,
name: name.value.trim(),
engine: selectedEngine.value,
...(engineFamily.value === 'yue' ? { yueProfile: yueProfile.value } : {})
engine: 'yue2'
}
}
)
@@ -712,10 +594,6 @@ async function generate() {
}
}
watch(instrumental, (on) => {
if (on && !lyrics.value.trim()) lyrics.value = DEFAULT_MUSIC_LYRICS
})
function applyIncomingMusic() {
try {
const genRaw = sessionStorage.getItem(LOAD_GENERATION_PAYLOAD_KEY)
@@ -726,14 +604,10 @@ function applyIncomingMusic() {
const payload = loaded.payload
if (typeof payload.prompt === 'string') tags.value = payload.prompt
if (typeof payload.lyrics === 'string') lyrics.value = payload.lyrics
instrumental.value = payload.instrumental === true
if (typeof payload.duration === 'number') duration.value = payload.duration
if (typeof payload.steps === 'number') steps.value = payload.steps
if (typeof payload.duration === 'number') duration.value = Math.min(MUSIC_DURATION_MAX, payload.duration)
if (payload.seed != null) seed.value = String(payload.seed)
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.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'
return
}
@@ -748,24 +622,16 @@ function applyIncomingMusic() {
name?: string
tags?: string
lyrics?: string
instrumental?: boolean
duration?: number
steps?: number
seed?: string
folderId?: string
engine?: string
yueProfile?: 1 | 3
}
if (typeof loaded.tags === 'string') tags.value = loaded.tags
if (typeof loaded.lyrics === 'string') lyrics.value = loaded.lyrics
instrumental.value = loaded.instrumental === true
if (typeof loaded.duration === 'number') duration.value = loaded.duration
if (typeof loaded.steps === 'number') steps.value = loaded.steps
if (typeof loaded.duration === 'number') duration.value = Math.min(MUSIC_DURATION_MAX, loaded.duration)
if (typeof loaded.seed === 'string') seed.value = loaded.seed
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.engine) applyEngine(parseMusicEngine(loaded.engine))
yueProfile.value = loaded.yueProfile === 3 ? 3 : 1
status.value = 'Loaded from the queue'
} catch { /* ignore */ }
}
@@ -781,12 +647,8 @@ function applyPreset() {
const settings = preset.settings as MusicPresetSettings
if (typeof settings.tags === 'string') tags.value = settings.tags
if (typeof settings.lyrics === 'string') lyrics.value = settings.lyrics
instrumental.value = settings.instrumental === true
if (typeof settings.duration === 'number') duration.value = settings.duration
if (typeof settings.steps === 'number') steps.value = settings.steps
if (typeof settings.duration === 'number') duration.value = Math.min(MUSIC_DURATION_MAX, settings.duration)
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
}
@@ -794,14 +656,12 @@ onMounted(async () => {
musicClockTimer = setInterval(() => { musicClock.value = Date.now() }, 1000)
await loadLibrary()
await loadMusicPresets()
const route = useRoute()
const engineQ = String(route.query.engine || '').toLowerCase()
if (engineQ === 'yue' || engineQ === 'ace') selectEngineFamily(engineQ)
applyIncomingMusic()
await resumeActiveMusic()
await refreshStudioQueue()
stopQueuePoll()
queuePoll = setInterval(() => { void refreshStudioQueue() }, 8000)
const route = useRoute()
const query = route.query
if (typeof query.track === 'string' && query.track) {
audioUrl.value = `/api/library/tracks/${query.track}/audio`
@@ -815,7 +675,7 @@ onMounted(async () => {
if (recovered?.trackId && /recovered/i.test(String(recovered.message || ''))) {
applyEvent({ ...recovered, type: 'complete' })
}
} catch { /* nothing waiting in ComfyUI */ }
} catch { /* nothing waiting */ }
}
})
+2 -2
View File
@@ -663,7 +663,7 @@ function progressLine(queue: Queue) {
function jobLine(job: StudioJobRow) {
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 === 'error') return job.lastError || 'Failed'
return 'Generating track'
@@ -1237,7 +1237,7 @@ async function clearAll() {
async function forceClearAll() {
if (forceClearing.value) return
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
forceClearing.value = true
error.value = ''
+8192
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
<template>
<div />
</template>
<script setup lang="ts">
await navigateTo('/', { replace: true })
</script>
+66
View File
@@ -0,0 +1,66 @@
# Delete named YuE v1 / YuEGP weight folders only. Never recurse-wipe Comfy models\ or custom_nodes\.
[CmdletBinding()]
param(
[string]$YueGpRoot = 'C:\Users\ianjm\Development\YuEGP',
[string]$YueModels = '',
[string]$LogPath = ''
)
$ErrorActionPreference = 'Continue'
if (-not $YueModels) {
$YueModels = Join-Path $env:LOCALAPPDATA 'Comfy-Desktop\ComfyUI-Shared\models\yue'
}
if (-not $LogPath) {
$LogPath = Join-Path $PSScriptRoot 'cleanup-yue-v1.log'
}
function Log([string]$Message) {
$line = "$(Get-Date -Format o) $Message"
Add-Content -LiteralPath $LogPath -Value $line
Write-Host $line
}
function RemoveNamed([string]$Path) {
if (-not $Path) { return }
if (!(Test-Path -LiteralPath $Path)) {
Log "SKIP missing $Path"
return
}
try {
Remove-Item -LiteralPath $Path -Recurse -Force
Log "DELETED $Path"
} catch {
Log "FAIL $Path :: $($_.Exception.Message)"
}
}
Log "START cleanup-yue-v1 YueGpRoot=$YueGpRoot YueModels=$YueModels"
# Stage 1 / Stage 2 + codec under the shared yue models dir (named folders only)
RemoveNamed (Join-Path $YueModels 'YuE-s1-7B-anneal-en-cot')
RemoveNamed (Join-Path $YueModels 'YuE-s2-1B-general')
RemoveNamed (Join-Path $YueModels 'xcodec_mini_infer')
# Loose codec files that live beside the stage folders in this install
foreach ($name in @('ckpt_00360000.pth', 'decoder_131000.pth', 'decoder_151000.pth')) {
RemoveNamed (Join-Path $YueModels $name)
}
# YuEGP checkout codec copy + job dumps
RemoveNamed (Join-Path $YueGpRoot 'inference\xcodec_mini_infer')
RemoveNamed (Join-Path $YueGpRoot 'aigen-jobs')
# Hugging Face cache dirs that are clearly YuE v1 / old codec
$Hf = Join-Path $env:USERPROFILE '.cache\huggingface\hub'
RemoveNamed (Join-Path $Hf 'models--m-a-p--YuE-s1-7B-anneal-en-cot')
RemoveNamed (Join-Path $Hf 'models--m-a-p--YuE-s2-1B-general')
RemoveNamed (Join-Path $Hf 'models--m-a-p--xcodec_mini_infer')
# Leftover Comfy YuE Stage A/B weight copies by name only (no blanket models\ purge)
$ComfyModels = Join-Path $env:LOCALAPPDATA 'Comfy-Desktop\ComfyUI-Shared\models'
if (Test-Path -LiteralPath $ComfyModels) {
Get-ChildItem -LiteralPath $ComfyModels -Recurse -ErrorAction SilentlyContinue |
Where-Object { $_.Name -match '^(YUE_Stage_|YuE-s1-|YuE-s2-|yue_stage)' } |
ForEach-Object { RemoveNamed $_.FullName }
} else {
Log "SKIP missing $ComfyModels"
}
Log "DONE cleanup-yue-v1 log=$LogPath"
Write-Host "Left in place: ACE / H3 / Klein / Krea / LTX / upscale / LoRAs / LIBRARY_DIR / yuegp code paths."
+27 -17
View File
@@ -1,8 +1,9 @@
import { createUpscaleHost } from './upscale-host.mjs'
import { purgeStudio2Files } from './studio2-purge.mjs'
import { stableMemoryArgs } from './comfy-memory-policy.mjs'
import { createGpuReservation } from './gpu-reservation.mjs'
import { createGpuProxy } from './gpu-proxy.mjs'
import { createYueGpHost } from './yuegp-host.mjs'
import { createYue2Host } from './yue2-host.mjs'
import http from 'node:http'
import net from 'node:net'
import { execFile, spawn } from 'node:child_process'
@@ -146,7 +147,7 @@ let proxyTarget = 0
function ensureProxyListening() {
if (proxyServer) return
proxyServer = createGpuProxy({ target: () => proxyTarget, reservation: gpuReservation, authorized, markWork, externalBusy: () => yueGp.busy() || upscale.busy() })
proxyServer = createGpuProxy({ target: () => proxyTarget, reservation: gpuReservation, authorized, markWork, externalBusy: () => yue2.busy() || upscale.busy() })
proxyServer.on('error', (error) => {
console.log(JSON.stringify({ src: 'comfy-host-agent', event: 'proxy-error', error: String(error.message || error) }))
})
@@ -818,7 +819,7 @@ function purgeDesktopFiles(body) {
}
const gpuReservation = createGpuReservation({ idle: async () => {
if (yueGp.busy() || upscale.busy()) return false
if (yue2.busy() || upscale.busy()) return false
if ((await trainingLock()).busy) return false
const healthy = await syncProxy()
if (healthy) {
@@ -829,14 +830,17 @@ const gpuReservation = createGpuReservation({ idle: async () => {
return !(await processUp()) && !(await pythonMainUp().catch(() => true))
} })
const yueGp = createYueGpHost({
const yue2 = createYue2Host({
leaseValid: lease => gpuReservation.isOwner(lease),
prepare: async () => {
if ((await trainingLock()).busy) throw new Error('GPU is busy with training.')
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.')
if (!queue.ok || queue.running || queue.pending) throw new Error('Comfy is busy; YuE2 cannot start.')
}
// Stop Comfy and refuse to launch while its Python still owns VRAM.
if (healthy || await processUp() || await pythonMainUp().catch(() => false)) {
await stopComfyProcesses()
markAsleep()
}
@@ -858,20 +862,20 @@ 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' }) }
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 (url.pathname.startsWith('/yue2/')) {
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 === 'POST' && url.pathname === '/yue2/jobs') return json(res, 200, await yue2.start(await readJson(req), String(req.headers['x-aigen-gpu-lease'] || '')))
if (match && req.method === 'POST' && match[2] === '/cancel') return json(res, 200, await yue2.cancel(match[1]))
if (match && req.method === 'GET' && match[2] === '/audio') {
const path = yueGp.audio(match[1])
const path = yue2.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' })
const job = yue2.read(match[1])
return json(res, job ? 200 : 404, job || { error: 'YuE2 job not found' })
}
return json(res, 404, { error: 'Unknown YuEGP endpoint' })
return json(res, 404, { error: 'Unknown YuE2 endpoint' })
}
if (req.method === 'POST' && url.pathname.startsWith('/gpu/')) {
const body = await readJson(req)
@@ -904,13 +908,13 @@ async function handleControl(req, res) {
proxyPort,
gpu: gpuReservation.availability(),
training: { busy: lastTraining.busy },
yuegp: { busy: yueGp.busy(), configured: yueGp.configured() },
yue2: { busy: yue2.busy(), configured: yue2.configured() },
upscale: { busy: upscale.busy(), engine: 'realesrgan-rife', local: true }
})
}
if (req.method === 'POST' && url.pathname === '/start') {
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.' })
const training = await trainingLock()
if (training.busy) {
return json(res, 409, {
@@ -989,6 +993,12 @@ async function handleControl(req, res) {
markWork()
return streamFile(res, path)
}
if (req.method === 'POST' && url.pathname === '/studio2/purge') {
if (!gpuReservation.isOwner(String(req.headers['x-aigen-gpu-lease'] || ''))) return json(res, 409, { ok: false, error: 'GPU lease required' })
const body = await readJson(req)
try { return json(res, 200, purgeStudio2Files(body, rootsForType)) }
catch (error) { return json(res, 400, { ok: false, error: String(error.message || error) }) }
}
if (req.method === 'POST' && url.pathname === '/purge') {
const body = await readJson(req)
return json(res, 200, purgeDesktopFiles(body))
@@ -1006,7 +1016,7 @@ const server = http.createServer(async (req, res) => {
} else await handleControl(req, res)
} catch (error) {
req.resume()
if ((String(req.url || '').startsWith('/yuegp/') || String(req.url || '').startsWith('/upscale/')) && !res.headersSent) return json(res, error.statusCode || 400, { error: error.message || 'YuEGP 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.' })
}
})
+1 -1
View File
@@ -55,7 +55,7 @@ export function createGpuProxy({ target, reservation, authorized = () => true, m
try {
if (req.method === 'GET' || req.method === 'HEAD') await forward()
else {
if (externalBusy()) { req.resume(); return reply(res, 409, { error: { message: 'YuEGP is using the GPU.' }, code: 'GPU_BUSY' }) }
if (externalBusy()) { req.resume(); return reply(res, 409, { error: { message: 'A music or upscale job is using the GPU.' }, code: 'GPU_BUSY' }) }
if (!authorized(req)) { req.resume(); return reply(res, 401, { error: 'Unauthorized' }) }
await reservation.permit(String(req.headers['x-aigen-gpu-lease'] || ''), async () => { markWork(); await forward() })
}
+29 -7
View File
@@ -1,26 +1,48 @@
# Installs Desktop + Start Menu shortcuts that start the Comfy host agent
# without Cursor. The scheduled task still starts the agent at logon.
# Installs Desktop, Start Menu, and Windows Startup shortcuts that start THIS
# clone's Comfy host agent (YuE2) and replace any stale process on 8199.
$ErrorActionPreference = 'Stop'
$root = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
$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 $startPs1)) { throw "Missing $startPs1" }
$shell = New-Object -ComObject WScript.Shell
$desktop = [Environment]::GetFolderPath('Desktop')
$programs = Join-Path ([Environment]::GetFolderPath('StartMenu')) 'Programs\AIGen'
$startup = [Environment]::GetFolderPath('Startup')
New-Item -ItemType Directory -Force -Path $programs | Out-Null
function Save-Shortcut([string]$dir) {
$path = Join-Path $dir 'Start Comfy Host Agent.lnk'
function Save-Shortcut([string]$dir, [string]$name = 'Start Comfy Host Agent.lnk') {
$path = Join-Path $dir $name
$lnk = $shell.CreateShortcut($path)
$lnk.TargetPath = (Join-Path $env:SystemRoot 'System32\wscript.exe')
$lnk.Arguments = "//B `"$vbs`""
$lnk.WorkingDirectory = $root
$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()
$path
}
Save-Shortcut $desktop
Save-Shortcut $programs
$saved = @(
(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).'
+7 -28
View File
@@ -1,35 +1,14 @@
' Start the Comfy host agent with no window. Safe to run when Cursor is closed.
' Uses the "AIGen Comfy Host Agent" scheduled task when it exists.
' Start THIS clone's Comfy host agent (YuE2). Replaces any stale agent on 8199.
' Safe to run at logon / from Startup / Desktop shortcut when Cursor is closed.
Option Explicit
Dim sh, fso, root, node, agent, rc
Dim sh, fso, root, ps1
Set sh = CreateObject("WScript.Shell")
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(root)
node = """C:\Program Files\nodejs\node.exe"""
agent = """" & root & "\scripts\comfy-host-agent.mjs"""
If Not fso.FileExists(Replace(node, """", "")) Then WScript.Quit 1
sh.CurrentDirectory = root
sh.Run node & " " & agent, 0, False
ps1 = root & "\scripts\start-comfy-host-agent.ps1"
If Not fso.FileExists(ps1) Then WScript.Quit 1
Function AgentRunning()
Dim svc, query, item
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
sh.CurrentDirectory = root
sh.Run "powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File """ & ps1 & """", 0, False
+66
View File
@@ -0,0 +1,66 @@
# Standalone YuE2 only. Never install into Comfy's Python environment.
# Downloads YuE2-3B + YuE2-Vae into YUE2_ROOT\models so the UI does not hit HF on first Generate.
[CmdletBinding()]
param(
[string]$Root = 'C:\Users\ianjm\Development\YuE2',
[string]$Uv = 'uv',
[string]$Models = ''
)
$ErrorActionPreference = 'Stop'
$Root = [IO.Path]::GetFullPath($Root)
if (-not $Models) { $Models = Join-Path $Root 'models' }
$Models = [IO.Path]::GetFullPath($Models)
function Run([string]$Exe, [string[]]$Arguments) {
& $Exe @Arguments
if ($LASTEXITCODE -ne 0) { throw "$Exe failed ($LASTEXITCODE)" }
}
Write-Host "YuE2 setup root=$Root models=$Models"
if (!(Test-Path -LiteralPath $Root)) {
Run git @('clone', 'https://github.com/multimodal-art-projection/YuE.git', $Root)
}
Run $Uv @('venv', '--allow-existing', '--python', '3.12', (Join-Path $Root '.venv'))
$python = Join-Path $Root '.venv\Scripts\python.exe'
# Install the package first, then force CUDA torch back on (editable deps can pull CPU wheels).
Run $Uv @('pip', 'install', '--python', $python, '-e', $Root)
Run $Uv @('pip', 'install', '--python', $python, 'soundfile', 'psutil', 'huggingface_hub', 'hf_xet')
Run $Uv @('pip', 'install', '--python', $python, 'torch', 'torchaudio', '--reinstall', '--index-url', 'https://download.pytorch.org/whl/cu128')
New-Item -ItemType Directory -Force -Path $Models | Out-Null
$modelDir = Join-Path $Models 'YuE2-3B'
$vaeDir = Join-Path $Models 'YuE2-Vae'
if (!(Test-Path -LiteralPath (Join-Path $modelDir 'config.json')) -and !(Test-Path -LiteralPath (Join-Path $modelDir 'model.safetensors.index.json'))) {
Write-Host "Downloading m-a-p/YuE2-3B → $modelDir"
Run $python @(
'-c',
'from huggingface_hub import snapshot_download; import sys; snapshot_download(sys.argv[1], local_dir=sys.argv[2])',
'm-a-p/YuE2-3B',
$modelDir
)
} else {
Write-Host "Reusing existing model at $modelDir"
}
if (!(Test-Path -LiteralPath (Join-Path $vaeDir 'config.json'))) {
Write-Host "Downloading m-a-p/YuE2-Vae → $vaeDir"
Run $python @(
'-c',
'from huggingface_hub import snapshot_download; import sys; snapshot_download(sys.argv[1], local_dir=sys.argv[2])',
'm-a-p/YuE2-Vae',
$vaeDir
)
} else {
Write-Host "Reusing existing VAE at $vaeDir"
}
Run $python @('-c', 'import torch; from yue2 import YuE2Pipeline; assert torch.cuda.is_available(); print(torch.__version__, torch.cuda.get_device_name(0))')
$ready = @{
model = $modelDir
vae = $vaeDir
modelId = 'm-a-p/YuE2-3B'
vaeId = 'm-a-p/YuE2-Vae'
modelsDir = $Models
python = $python
root = $Root
}
$ready | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $Root 'aigen-ready.json') -Encoding utf8
Write-Host "YuE2 ready at $Root"
Write-Host "Local model=$modelDir"
Write-Host "Local vae=$vaeDir"
Write-Host "Set YUE2_ROOT=$Root YUE2_PYTHON=$python YUE2_MODEL=$modelDir YUE2_VAE=$vaeDir if not using defaults. No Comfy environment was modified."
+12 -8
View File
@@ -1,11 +1,15 @@
' Launch the Comfy host agent with no console window.
' Window style 0 = hidden. Wait = True so Task Scheduler keeps the task marked Running.
' Launch the Comfy host agent with no console window (Task Scheduler wait mode).
' Replaces any stale agent on 8199, then runs THIS clone's agent until it exits.
Option Explicit
Dim sh, root, node, agent
Dim sh, fso, root, ps1
Set sh = CreateObject("WScript.Shell")
root = CreateObject("Scripting.FileSystemObject").GetParentFolderName(WScript.ScriptFullName)
root = CreateObject("Scripting.FileSystemObject").GetParentFolderName(root)
node = """C:\Program Files\nodejs\node.exe"""
agent = """" & root & "\scripts\comfy-host-agent.mjs"""
Set fso = CreateObject("Scripting.FileSystemObject")
root = fso.GetParentFolderName(WScript.ScriptFullName)
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.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
+34
View File
@@ -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)
+31
View File
@@ -0,0 +1,31 @@
import { resolve, relative, isAbsolute } from 'node:path'
import { existsSync, realpathSync, statSync, unlinkSync } from 'node:fs'
// Keep the host cleanup endpoint independently deployable from the web application.
function scopedFile(file, prefix) {
const path=[file.subfolder,file.filename].map(s=>String(s||'').replaceAll('\\','/')).filter(Boolean).join('/')
const p=String(prefix||'').replaceAll('\\','/').replace(/\/$/,'')
return !!p && !path.startsWith('/') && !path.split('/').some(s=>s==='..'||s==='.') && path.startsWith(p+'/')
}
/** Exact Studio 2 files only: no folder fallback, sibling deletion, or sweeping. */
export function purgeStudio2Files({prefix, files}, rootsForType) {
if (!Array.isArray(files) || !files.length || files.length>100 || !prefix) throw new Error('Invalid Studio 2 purge manifest')
const targets=[]
for (const file of files) {
if (!['input','output'].includes(file.type) || !scopedFile(file,prefix) || !String(file.subfolder).replaceAll('\\','/').startsWith(String(prefix).replace(/\/$/,'')+'/studio2/')) throw new Error('Purge file is outside the Studio 2 prefix')
const roots=rootsForType(file.type)
if(!roots.length)throw new Error('No media root is configured for cleanup')
for (const root of roots) {
const target=resolve(root,file.subfolder,file.filename),rel=relative(resolve(root),target)
if (!rel || rel.startsWith('..') || isAbsolute(rel)) throw new Error('Purge path escapes its media root')
if (!existsSync(target)) continue
const realRoot=realpathSync(root),realTarget=realpathSync(target),realRel=relative(realRoot,realTarget)
if (!realRel || realRel.startsWith('..') || isAbsolute(realRel) || !statSync(realTarget).isFile()) throw new Error('Purge target is not a media file inside its root')
const prefixRel=relative(resolve(realRoot,prefix),realTarget)
if(!prefixRel || prefixRel.startsWith('..') || isAbsolute(prefixRel))throw new Error('Purge target resolves outside the instance prefix')
targets.push(target)
}
}
for(const target of new Set(targets))unlinkSync(target)
return {ok:true,cleared:files.length,deleted:[...new Set(targets)]}
}
+134
View File
@@ -0,0 +1,134 @@
import { spawn } from 'node:child_process'
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, appendFileSync, readdirSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
export function validateYue2Request(body) {
const duration = body.duration ?? 60
if (!Number.isInteger(duration) || duration < 30 || duration > 150) throw new Error('YuE2 duration must be 30–150 seconds.')
if (!Number.isInteger(body.seed) || body.seed < 0 || body.seed > 2147483647) throw new Error('Invalid seed.')
if (!/^[a-zA-Z0-9-]{12,80}$/.test(body.id || '')) throw new Error('Invalid job ID.')
const tags = String(body.tags || '').trim()
const lyrics = String(body.lyrics || '').trim()
if (!tags || tags.length > 2000 || !lyrics || lyrics.length > 8000) throw new Error('Genre tags and non-empty lyrics are required.')
return { id: body.id, duration, seed: body.seed, tags, lyrics }
}
/** One isolated Python process per song. A process exit is the GPU release boundary. */
export function createYue2Host({ prepare, leaseValid, spawnProcess = spawn, root, python, dataDir } = {}) {
const repo = resolve(root || process.env.YUE2_ROOT || 'C:\\Users\\ianjm\\Development\\YuE2')
const executable = python || process.env.YUE2_PYTHON || join(repo, '.venv', 'Scripts', 'python.exe')
const data = resolve(dataDir || process.env.YUE2_JOBS_DIR || join(repo, 'aigen-jobs'))
const worker = fileURLToPath(new URL('./yue2-worker.py', import.meta.url))
let active = null
// A worker whose parent died exits within two seconds. Block new GPU owners
// across a host restart until that watchdog has had time to run.
const restartHoldUntil = existsSync(data) && readdirSync(data).some(id => {
try { return ['running', 'starting', 'cancelling'].includes(JSON.parse(readFileSync(join(data, id, 'status.json'), 'utf8')).status) }
catch { return false }
}) ? Date.now() + 10000 : 0
const dir = id => {
if (!/^[a-zA-Z0-9-]{12,80}$/.test(id || '')) throw new Error('Invalid job ID.')
return join(data, id)
}
const persist = job => {
const target = join(dir(job.id), 'status.json')
writeFileSync(target + '.tmp', JSON.stringify(job))
renameSync(target + '.tmp', target)
}
const read = id => {
if (active?.job.id === id) return { ...active.job }
const path = join(dir(id), 'status.json')
if (!existsSync(path)) return null
const job = JSON.parse(readFileSync(path, 'utf8'))
if (['running', 'starting', 'cancelling'].includes(job.status)) {
job.status = 'error'; job.error = 'YuE2 host restarted. Intermediate files are preserved.'
}
return job
}
return {
busy: () => Boolean(active) || Date.now() < restartHoldUntil,
configured: () => existsSync(executable) && existsSync(join(repo, 'aigen-ready.json')),
read,
audio(id) {
return read(id)?.status === 'complete' ? join(dir(id), 'audio.wav') : null
},
async start(body, lease) {
const request = validateYue2Request(body)
const previous = read(request.id)
if (previous) return previous // POST retries cannot generate the same song twice.
if (active || Date.now() < restartHoldUntil) throw Object.assign(new Error('YuE2 is already running or releasing a previous worker.'), { statusCode: 409 })
if (!existsSync(executable) || !existsSync(join(repo, 'aigen-ready.json'))) throw new Error('YuE2 is not installed. Run scripts/setup-yue2.ps1 on the GPU host.')
const job = { id: request.id, status: 'starting', message: 'Preparing GPU for YuE2', progress: 0, startedAt: Date.now(), checkedAt: Date.now() }
active = { job, child: null, cancelled: false }
const run = active
mkdirSync(dir(job.id), { recursive: true })
persist(job)
try {
await prepare()
if (run.cancelled || !leaseValid(lease)) throw new Error('YuE2 start cancelled or GPU reservation expired.')
const installation = JSON.parse(readFileSync(join(repo, 'aigen-ready.json'), 'utf8').replace(/^\uFEFF/, ''))
const localModel = process.env.YUE2_MODEL || installation.model || 'm-a-p/YuE2-3B'
const localVae = process.env.YUE2_VAE || installation.vae || 'm-a-p/YuE2-Vae'
writeFileSync(join(dir(job.id), 'request.json'), JSON.stringify({
...request,
model: localModel,
vae: localVae
}))
const child = spawnProcess(executable, ['-u', worker, '--root', repo, '--request', join(dir(job.id), 'request.json')],
{ cwd: repo, windowsHide: true, shell: false, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, PYTHONUTF8: '1', TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD: '1' } })
run.child = child
job.status = 'running'; job.message = 'Loading YuE2'; persist(job)
let tail = ''
let lines = ''
const log = chunk => { appendFileSync(join(dir(job.id), 'worker.log'), chunk); tail = (tail + chunk.toString()).slice(-6000) }
child.stderr.on('data', log)
child.stdout.on('data', chunk => {
log(chunk); lines += chunk.toString()
const parts = lines.split(/\r?\n/); lines = parts.pop().slice(-64000)
for (const line of parts) {
if (!line.startsWith('AIGEN_EVENT ')) continue
try {
const event = JSON.parse(line.slice(12))
for (const key of ['stage', 'message', 'progress', 'step', 'maxStep', 'error', 'duration']) if (event[key] !== undefined) job[key] = event[key]
job.checkedAt = Date.now(); persist(job)
} catch { /* Invalid log lines are not state transitions. */ }
}
})
const watchdog = setInterval(() => {
if (!leaseValid(lease)) { job.error = 'GPU reservation expired; YuE2 stopped.'; child.kill() }
}, 5000)
let finished = false
const finish = (code, error) => {
if (finished) return
finished = true
clearInterval(watchdog)
job.status = run.cancelled ? 'cancelled' : !error && code === 0 && existsSync(join(dir(job.id), 'audio.wav')) ? 'complete' : 'error'
if (job.status === 'error') job.error ||= error?.message || tail || `YuE2 exited with code ${code}`
job.message = job.status === 'complete' ? 'Audio ready' : job.status === 'cancelled' ? 'Cancelled' : job.error
job.checkedAt = Date.now(); persist(job)
if (active === run) active = null
}
child.once('error', error => finish(null, error))
child.once('close', code => finish(code))
return { ...job }
} catch (error) {
job.status = run.cancelled ? 'cancelled' : 'error'; job.error = error.message; persist(job)
if (active === run) active = null
throw error
}
},
async cancel(id) {
if (active?.job.id !== id) return read(id)
const run = active
run.cancelled = true
run.job.status = 'cancelling'
persist(run.job)
if (run.child) {
const child = run.child
await new Promise(resolve => { child.once('close', resolve); child.kill() })
}
return read(id)
}
}
}
+193
View File
@@ -0,0 +1,193 @@
"""Headless YuE2 adapter: plan → generate_semantic → synthesize → decode.
No Comfy imports. No score editor, covers, or auto-fallback to YuEGP / ACE.
Stdout AIGEN_EVENT lines are consumed by the device-local host agent.
"""
import argparse
import gc
import json
import os
from pathlib import Path
import sys
import time
import threading
# Must be set before the first torch import on this 16GB Windows host.
os.environ.setdefault('PYTORCH_CUDA_ALLOC_CONF', 'expandable_segments:True')
def emit(**event):
print('AIGEN_EVENT ' + json.dumps(event), flush=True)
def normalize_lyrics(text):
# YuE2 accepts multiple [Verse]/[Chorus] headings. Only wrap when none exist.
import re
text = str(text or '').strip()
if not text:
raise ValueError('YuE2 requires non-empty lyrics.')
if re.search(r'\[[^\]]+\]', text):
return text if text.endswith('\n') else text + '\n'
return '[song]\n' + text + '\n\n'
def is_cuda_oom(error):
message = str(error).lower()
return 'out of memory' in message or ('cuda' in message and 'alloc' in message) or 'cudnn_status_alloc_failed' in message
def resolve_attention_backend(torch_mod):
"""Never flash. Prefer torch-eager (no CUDA graphs) on the 16GB 5080."""
# Windows wheels expose flash ops without USE_FLASH_ATTENTION; never select flash.
# torch-eager disables GraphAR / CUDA graphs via YuE2Pipeline.backend.
return 'sdpa', 'torch-eager'
def patch_graph_attention(attention_backend):
from yue2.cuda_graph import GraphAR
original = GraphAR.__init__
def init(self, model, prefixes, max_tokens, *, capture=True, attention_backend='auto', fuse_projections=False):
if attention_backend in ('auto', 'flash'):
attention_backend = patch_graph_attention.forced
return original(self, model, prefixes, max_tokens, capture=capture,
attention_backend=attention_backend, fuse_projections=fuse_projections)
patch_graph_attention.forced = attention_backend
GraphAR.__init__ = init
def free_cuda(torch_mod):
gc.collect()
if torch_mod.cuda.is_available():
torch_mod.cuda.empty_cache()
torch_mod.cuda.synchronize()
def memory_snapshot(torch_mod, stage):
if not torch_mod.cuda.is_available():
emit(stage=stage, message=f'YuE2 {stage}', cuda=False)
return
free, total = torch_mod.cuda.mem_get_info()
emit(
stage=stage,
message=f'YuE2 {stage}',
gpuAllocatedMiB=round(torch_mod.cuda.memory_allocated() / 1024 ** 2),
gpuReservedMiB=round(torch_mod.cuda.memory_reserved() / 1024 ** 2),
gpuFreeMiB=round(free / 1024 ** 2),
gpuTotalMiB=round(total / 1024 ** 2),
)
def main():
import psutil
parent = psutil.Process(os.getppid())
def parent_watchdog():
while parent.is_running():
time.sleep(2)
os._exit(2) # The host died: never leave an orphan consuming the GPU.
threading.Thread(target=parent_watchdog, daemon=True).start()
parser = argparse.ArgumentParser()
parser.add_argument('--root', required=True)
parser.add_argument('--request', required=True)
cli = parser.parse_args()
request = json.loads(Path(cli.request).read_text(encoding='utf-8-sig'))
root = Path(cli.root).resolve()
output = Path(cli.request).resolve().parent
duration = int(request.get('duration', 60))
if not 30 <= duration <= 150:
raise ValueError('Duration must be 30–150 seconds.')
lyrics = normalize_lyrics(request['lyrics'])
style = ' '.join(str(request['tags']).split())
seed = int(request['seed'])
model = request.get('model') or os.environ.get('YUE2_MODEL') or 'm-a-p/YuE2-3B'
vae = request.get('vae') or os.environ.get('YUE2_VAE') or 'm-a-p/YuE2-Vae'
os.chdir(root)
if str(root) not in sys.path:
sys.path.insert(0, str(root))
import torch
import soundfile as sf
attention_backend, pipeline_backend = resolve_attention_backend(torch)
emit(stage='loading', progress=1, message='Loading YuE2', model=model, vae=vae,
duration=duration, attention_backend=attention_backend, pipeline_backend=pipeline_backend,
cudaAllocConf=os.environ.get('PYTORCH_CUDA_ALLOC_CONF'))
from yue2 import YuE2Pipeline
if not torch.cuda.is_available():
raise RuntimeError('YuE2 requires a CUDA GPU; CPU fallback is disabled.')
patch_graph_attention(attention_backend)
free_cuda(torch)
memory_snapshot(torch, 'memory-before-load')
# Prefer tiled VAE; do not cap the process with set_per_process_memory_fraction.
pipe_load = dict(
device='cuda',
backend=pipeline_backend,
vae_core_frames=512,
offload_ar=True,
progress=False,
)
cot = 'full'
pipe_kwargs = dict(style=style, lyrics=lyrics, cot=cot, seed=seed)
# YuE2Pipeline.__init__ always calls set_per_process_memory_fraction; skip it on this 16GB host.
_set_fraction = torch.cuda.set_per_process_memory_fraction
torch.cuda.set_per_process_memory_fraction = lambda *args, **kwargs: None
try:
pipe_cm = YuE2Pipeline.from_pretrained(model, vae=vae, **pipe_load)
finally:
torch.cuda.set_per_process_memory_fraction = _set_fraction
with pipe_cm as pipe:
memory_snapshot(torch, 'memory-loaded')
emit(stage='plan', message='Planning melody and chords', progress=5, cot=cot)
try:
plan = pipe.plan(**pipe_kwargs)
except Exception as error:
if cot != 'full' or not is_cuda_oom(error):
raise
free_cuda(torch)
cot = 'melody'
pipe_kwargs['cot'] = cot
emit(stage='plan', message='Full CoT OOM; retrying melody CoT', progress=5, cot=cot,
error=str(error))
memory_snapshot(torch, 'memory-before-melody-plan')
plan = pipe.plan(**pipe_kwargs)
free_cuda(torch)
memory_snapshot(torch, 'memory-after-plan')
emit(stage='semantic', message='Generating semantic tokens', progress=25, cot=cot)
semantic = pipe.generate_semantic(plan)
free_cuda(torch)
memory_snapshot(torch, 'memory-after-semantic')
emit(stage='synthesize', message='Synthesizing acoustic latents', progress=55, cot=cot)
latents = pipe.synthesize(semantic)
free_cuda(torch)
memory_snapshot(torch, 'memory-after-synthesize')
emit(stage='decode', message='Decoding audio (tiled)', progress=80, cot=cot)
# full=False uses decode_tiled with vae_core_frames / halo; model offloads to CPU after.
audio = pipe.decode(latents, full=False)
free_cuda(torch)
memory_snapshot(torch, 'memory-after-decode')
free_cuda(torch)
wave = audio
if hasattr(audio, 'detach'):
wave = audio.detach().cpu().numpy()
import numpy as np
wave = np.asarray(wave)
if wave.ndim == 1:
pass
elif wave.shape[0] <= 8 and wave.shape[0] < wave.shape[-1]:
wave = wave.T
sample_rate = 48000
target = output / 'audio.wav'
sf.write(str(target), wave, sample_rate, subtype='PCM_16')
info = sf.info(str(target))
if info.frames <= 0:
raise RuntimeError('YuE2 produced empty audio.')
emit(stage='complete', message='Audio ready', progress=100, duration=info.duration, cot=cot)
if __name__ == '__main__':
try:
main()
except Exception as error:
emit(stage='error', message=str(error), error=str(error))
raise
+20 -58
View File
@@ -1,15 +1,7 @@
import { yueGpConfigured } from '~/server/utils/yueGp'
import { yue2Configured } from '~/server/utils/yue2'
import { addStudioJob, kickStudioQueue, listStudioJobs, videoJobsBusy } from '~/server/utils/studioQueue'
import { comfyConfigured } from '~/server/utils/comfy'
import {
clampLyricsStrength,
clampMusicCfg,
clampMusicDuration,
clampMusicSteps,
MUSIC_CFG_DEFAULT,
MUSIC_CFG_DEFAULT_15,
MUSIC_STEPS_DEFAULT,
MUSIC_STEPS_DEFAULT_15,
parseMusicEngine,
parseMusicSeed,
sanitizeMusicLyrics,
@@ -22,36 +14,28 @@ export default defineEventHandler(async (event) => {
tags?: unknown
lyrics?: unknown
duration?: unknown
steps?: unknown
seed?: unknown
cfg?: unknown
lyricsStrength?: unknown
instrumental?: unknown
folderId?: unknown
name?: unknown
yueProfile?: unknown
engine?: unknown
sampler_name?: unknown
scheduler?: unknown
}>(event).catch(() => ({} as Record<string, unknown>))
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 !== 'yue' && !comfyConfigured()) {
throw createError({
statusCode: 503,
statusMessage: 'Beast ComfyUI is not configured. Set COMFY_HOST.'
})
if (engine !== 'yue2') {
throw createError({ statusCode: 400, statusMessage: 'Only YuE2 is supported for music generation.' })
}
if (!yue2Configured()) {
throw createError({ statusCode: 503, statusMessage: 'YuE2 host is not configured. Set COMFY_CONTROL_URL.' })
}
const instrumental = body.instrumental === true || body.instrumental === 'true'
const tags = sanitizeMusicTags(body.tags)
const lyrics = sanitizeMusicLyrics(body.lyrics, instrumental)
const lyrics = sanitizeMusicLyrics(body.lyrics, false)
if (!tags) {
throw createError({ statusCode: 400, statusMessage: 'Genre and style tags are required' })
}
if (!instrumental && !lyrics) {
throw createError({ statusCode: 400, statusMessage: 'Lyrics are required unless Instrumental is on' })
const lyricsProblem = yueLyricsProblem(lyrics)
if (lyricsProblem) {
throw createError({ statusCode: 400, statusMessage: lyricsProblem })
}
const ownerKey = libraryOwnerKey(event)
@@ -66,31 +50,12 @@ export default defineEventHandler(async (event) => {
const destFolder = library.folders.find(folder => folder.id === folderId)
const folderLocked = Boolean(destFolder?.protected && !destFolder.unlocked)
if (engine === 'yue' && instrumental) throw createError({ statusCode: 400, statusMessage: 'YuEGP 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 })
}
const duration = clampMusicDuration(body.duration)
if (engine === 'yue' && duration > 150) throw createError({ statusCode: 400, statusMessage: 'YuEGP 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 clipName = allocateOutputName(ownerKey, folderId, String(body.name || '').trim(), {
engine: engine === 'ace-step-1.5' ? 'ace15' : engine === 'yue' ? 'yue' : 'ace'
engine: 'yue2'
})
const samplerName = String(body.sampler_name || 'euler')
const scheduler = String(body.scheduler || 'simple')
const familyId = crypto.randomUUID()
const studio = await addStudioJob({
ownerKey,
@@ -103,15 +68,15 @@ export default defineEventHandler(async (event) => {
aspect: 'audio',
width: 0,
height: 0,
steps,
steps: 0,
turbo: false,
seed,
cfg,
cfg: 0,
fps: 0,
samplerName,
scheduler,
samplerName: 'euler',
scheduler: 'simple',
duration,
sound: !instrumental,
sound: true,
workflow: 'v1',
useIdentityRefs: false,
hideThumbnail: false,
@@ -120,10 +85,9 @@ export default defineEventHandler(async (event) => {
extensions: [],
queueAutoRun: false,
lyrics,
instrumental,
lyricsStrength,
musicEngine: engine,
...(engine === 'yue' ? { yueProfile: yueProfile as 1 | 3 } : {})
instrumental: false,
lyricsStrength: 0,
musicEngine: 'yue2'
}
})
await kickStudioQueue()
@@ -140,11 +104,9 @@ export default defineEventHandler(async (event) => {
queued: !liveJobId,
seed,
duration,
steps,
cfg,
folderId,
folderLocked,
engine,
engine: 'yue2' as const,
name: clipName
}
})
+2 -1
View File
@@ -1,5 +1,6 @@
import { existsSync } from 'node:fs'
import { sendPathWithRange } from '~/server/utils/httpRange'
import { inlineFilename } from '~/shared/content-disposition.mjs'
export default defineEventHandler((event) => {
const { owner } = assertLibraryOwner(event)
@@ -11,7 +12,7 @@ export default defineEventHandler((event) => {
throw createError({ statusCode: 404, statusMessage: 'Video file is missing' })
}
return sendPathWithRange(event, path, 'video/mp4', {
'Content-Disposition': `inline; filename="${safeDownloadName(clipTitle(clip))}.mp4"`,
'Content-Disposition': inlineFilename(`${safeDownloadName(clipTitle(clip))}.mp4`),
'Cache-Control': 'private, max-age=0, must-revalidate'
})
})
+3
View File
@@ -0,0 +1,3 @@
import { readRecord } from '../../utils/studio2/store';
import { cancelStudioJob } from '../../utils/studioQueue';
export default defineEventHandler(async (event) => { const { owner } = assertLibraryOwner(event), body = await readBody(event), r = readRecord(owner, String(body.id)); assertFolderAccess(event, r.request.folderId); await cancelStudioJob(owner, r.queueId); return { ok: true }; });
@@ -0,0 +1,15 @@
import { records, saveRecord } from '../../utils/studio2/store';
import { listStudioJobs } from '../../utils/studioQueue';
export default defineEventHandler(event => {
const { owner } = assertLibraryOwner(event), queue=listStudioJobs(owner);
let cleared=0;
for (const r of records(owner)) {
try { assertFolderAccess(event,r.request.folderId); } catch { continue; }
const status=queue.find(q=>q.id===r.queueId)?.status;
if (['complete','failed','cancelled'].includes(r.state) || ['cancelled','error'].includes(status || '')) {
r.dismissed=true; saveRecord(r); cleared++;
}
}
// Retain internal manifests for cleanup retries; remove these records from the visible jobs list.
return {cleared};
});
+11
View File
@@ -0,0 +1,11 @@
import { jobView } from '~/shared/studio2/queue-view.mjs';
import { getJob } from '../../utils/jobs';
import { records } from '../../utils/studio2/store';
import { listStudioJobs } from '../../utils/studioQueue';
export default defineEventHandler(event => { const { owner } = assertLibraryOwner(event); const rows = listStudioJobs(owner); return records(owner).filter(r => !r.dismissed).filter(r => { try {
assertFolderAccess(event, r.request.folderId);
return true;
}
catch {
return false;
} }).map(r => { const q = rows.find(q => q.id === r.queueId); const live=r.liveId?getJob(r.liveId):null; if (['waiting','queued','held'].includes(r.state) && !q && !live) return null; return jobView(r,q,live); }).filter(Boolean); });
+33
View File
@@ -0,0 +1,33 @@
import {filterLoraStackForUse} from '~/utils/loras';
import { validateRequest } from '~/shared/studio2/contracts.mjs';
import { resolveRequestSize } from '../../utils/studio2/size';
import { parsePostedLoraStack, assertImageV2LoraStack } from '../../utils/loras';
import { saveRecord, readRecord } from '../../utils/studio2/store';
import { addStudioJob, kickStudioQueue, type StudioJobPayload } from '../../utils/studioQueue';
export default defineEventHandler(async (event) => {
const { owner } = assertLibraryOwner(event), raw = await readBody(event);
let request: any;
try {
request = validateRequest(raw, useRuntimeConfig().public.studio === 'xaigen');
}
catch (e: any) {
throw createError({ statusCode: e.statusCode || 400, statusMessage: e.message });
}
const video = ['video', 'extend'].includes(request.mode);
request.settings.loraStack = parsePostedLoraStack(request.settings.loraStack, video ? 'video' : 'image');
request.settings.loraStack = filterLoraStackForUse(request.settings.loraStack, request.engine, useRuntimeConfig().public.studio === 'xaigen');
if (!video)
assertImageV2LoraStack(request.settings.loraStack, request.engine);
assertFolderAccess(event, request.folderId);
for (const id of [request.identityStillId, request.imageAId, request.imageBId, request.maskId, request.endStillId, ...request.guides.map((g: any) => g.stillId)].filter(Boolean))
assertFolderAccess(event, getStill(owner, id).folderId);
if (request.startClipId)
assertFolderAccess(event, getClip(owner, request.startClipId).folderId);
await resolveRequestSize(owner,request);
const id = crypto.randomUUID(), record = { id, owner, request, state: 'waiting', queuedAt: Date.now(), familyId: crypto.randomUUID(), outputs: [], purgeResult: 'Not yet saved' };
saveRecord(record);
const row = await addStudioJob({ ownerKey: owner, kind: ['video', 'extend'].includes(request.mode) ? 'video' : 'edit', familyId: record.familyId, payload: { ...request.settings, studio2Id: id, prompt: request.compiledPrompt, folderId: request.folderId, extensions: [], referenceStillIds: [], useIdentityRefs: false, queueAutoRun: true } as StudioJobPayload });
saveRecord({ ...readRecord(owner, id), queueId: row.id });
kickStudioQueue();
return { id, seed:request.settings.seed, width:request.settings.width, height:request.settings.height };
});
+9
View File
@@ -0,0 +1,9 @@
import { existsSync } from 'node:fs';
import { studio2LoraCatalog } from '../../utils/studio2/lora-catalog';
import { publicLibrary, stillPath, clipVideoPath } from '../../utils/library';
export default defineEventHandler(event => {
const { owner } = assertLibraryOwner(event);
const library = publicLibrary(event);
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))), tracks:library.tracks || []};
});
+15
View File
@@ -0,0 +1,15 @@
import { readFileSync,writeFileSync,renameSync } from 'node:fs';
import { join } from 'node:path';
import { cachedLoras } from '~/shared/studio2/lora-cache.mjs';
import { listStudioLoras } from '../../utils/loras';
import { studio2Root } from '../../utils/studio2/store';
import { studio2LoraCatalog } from '../../utils/studio2/lora-catalog';
export default defineEventHandler(async event => {
const {owner}=assertLibraryOwner(event),path=join(studio2Root(),`loras-${owner}.cache`);
const listed=await cachedLoras({
read:()=>{try{return JSON.parse(readFileSync(path,'utf8'));}catch{return null;}},
write:value=>{writeFileSync(path+'.tmp',JSON.stringify(value));renameSync(path+'.tmp',path);},
live:()=>listStudioLoras({fresh:true}),fresh:getQuery(event).fresh==='1'
});
return {...listed,...studio2LoraCatalog(owner)};
});
+15
View File
@@ -0,0 +1,15 @@
import { imageDimensions } from '../../utils/resolution'
export default defineEventHandler(async event => {
const parts=await readMultipartFormData(event)
const image=parts?.find(p=>p.name==='image' && p.filename && p.data?.length)
const field=(name:string)=>parts?.find(p=>p.name===name)?.data.toString('utf8') || ''
if (!image) throw createError({statusCode:400,statusMessage:'Choose an image to upload.'})
const {owner}=assertLibraryOwner(event),folderId=field('folderId')
assertFolderAccess(event,folderId)
const size=imageDimensions(image.data)
if (!size) throw createError({statusCode:400,statusMessage:'Use a PNG, JPEG, WebP, GIF, or BMP image.'})
const filename=image.filename!.split(/[\\/]/).at(-1)!
const still=await saveStill({ownerKey:owner,folderId,filename,name:filename.replace(/\.[^.]+$/,''),data:image.data,...size,hideInput:field('hideInput')==='true',role:'input'})
if (!still) throw createError({statusCode:400,statusMessage:'This file was recognized as an internal video handoff frame and was not added. Choose a regular source image.'})
return {still,...publicLibrary(event,{light:true})}
})
+18
View File
@@ -0,0 +1,18 @@
import {upscaleRecords} from '../../utils/videoUpscale';
import {listStudioJobs} from '../../utils/studioQueue';
import {getClip} from '../../utils/library';
export default defineEventHandler(event=>{
const {owner}=assertLibraryOwner(event),records=upscaleRecords(owner);
const rows=listStudioJobs(owner).filter(j=>j.payload.upscale);
const ids=new Set([...rows.map(r=>r.id),...records.map(r=>r.id)]);
return [...ids].flatMap(id=>{
const row=rows.find(r=>r.id===id),record=records.find(r=>r.id===id);
try {
const clip=getClip(owner,row?.payload.upscale?.sourceId || record?.sourceId);
assertFolderAccess(event,clip.folderId);
const state=['waiting','held','running'].includes(row?.status || '')?row!.status:record?.status || row?.status;
return [{id,folderId:clip.folderId,name:clip.name || 'Video',status:({waiting:'queued',held:'queued',complete:'done',error:'failed',cancelled:'failed'} as any)[state || ''] || state,
progress:record?.progress || 0,width:record?.width,height:record?.height,elapsedMs:record?.elapsedMs || (record?.createdAt?Date.now()-record.createdAt:0),message:record?.message || row?.waitReason,error:record?.error || row?.lastError,outputClipId:record?.outputClipId}];
}catch{return [];}
});
});
+9 -7
View File
@@ -50,22 +50,24 @@ export default defineEventHandler(async (event) => {
payload.prompt = text(body.tags, 2000)
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)
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.promptMid != null) payload.promptMid = text(body.promptMid)
if (body.promptPost != null) payload.promptPost = text(body.promptPost)
if (body.negative != null) payload.negative = text(body.negative, 2000)
const duration = optionalNumber(body.duration)
if (duration != null) {
if (row.kind === 'music' && payload.musicEngine === 'yue') {
if (!Number.isInteger(duration) || duration < 30 || duration > 150) throw createError({ statusCode: 400, statusMessage: 'YuEGP duration must be 30–150 seconds.' })
if (row.kind === 'music') {
if (!Number.isInteger(duration) || duration < 30 || duration > 150) throw createError({ statusCode: 400, statusMessage: 'YuE2 duration must be 30–150 seconds.' })
payload.duration = duration
payload.musicEngine = 'yue2'
} else payload.duration = Math.min(120, Math.max(0.5, duration))
}
const steps = optionalNumber(body.steps)
+338
View File
@@ -0,0 +1,338 @@
{
"92": {
"inputs": {
"filename_prefix": "video/MiniMax_H3",
"format": "auto",
"codec": "auto",
"video": [
"172",
0
]
},
"class_type": "SaveVideo",
"_meta": {
"title": "Save Video"
}
},
"155": {
"inputs": {
"prompt": "",
"width": 1344,
"height": 768,
"length": [
"174",
1
],
"ref_image_size": "max",
"clip": [
"170",
0
],
"vae": [
"161",
0
],
"audio_vae": [
"162",
0
],
"ref_images.ref_image_0": [
"hero",
0
]
},
"class_type": "MiniMaxH3ReferenceToVideo",
"_meta": {
"title": "Reference to Video (MiniMax H3) – Identity Locked"
}
},
"161": {
"inputs": {
"vae_name": "minimax_h3_video_vae_fp16.safetensors"
},
"class_type": "VAELoader",
"_meta": {
"title": "Load VAE"
}
},
"162": {
"inputs": {
"vae_name": "minimax_h3_audio_vae_fp32.safetensors"
},
"class_type": "VAELoader",
"_meta": {
"title": "Load VAE"
}
},
"163": {
"inputs": {
"samples": [
"167",
0
],
"vae": [
"162",
0
]
},
"class_type": "VAEDecodeAudio",
"_meta": {
"title": "VAE Decode Audio"
}
},
"164": {
"inputs": {
"samples": [
"167",
0
],
"vae": [
"161",
0
]
},
"class_type": "VAEDecode",
"_meta": {
"title": "VAE Decode"
}
},
"165": {
"inputs": {
"sampler_name": "res_multistep"
},
"class_type": "KSamplerSelect",
"_meta": {
"title": "KSamplerSelect"
}
},
"166": {
"inputs": {
"scheduler": "simple",
"steps": [
"178",
0
],
"denoise": 1,
"model": [
"177",
0
]
},
"class_type": "BasicScheduler",
"_meta": {
"title": "BasicScheduler"
}
},
"167": {
"inputs": {
"noise": [
"171",
0
],
"guider": [
"168",
0
],
"sampler": [
"165",
0
],
"sigmas": [
"166",
0
],
"latent_image": [
"155",
1
]
},
"class_type": "SamplerCustomAdvanced",
"_meta": {
"title": "SamplerCustomAdvanced"
}
},
"168": {
"inputs": {
"model": [
"177",
0
],
"conditioning": [
"guidance",
0
]
},
"class_type": "BasicGuider",
"_meta": {
"title": "Basic Guider"
}
},
"169": {
"inputs": {
"unet_name": "minimax_h3_fl2va_pruned_int8_convrot.safetensors",
"weight_dtype": "default"
},
"class_type": "UNETLoader",
"_meta": {
"title": "Load Diffusion Model"
}
},
"170": {
"inputs": {
"clip_name": "qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors",
"type": "minimax",
"device": "default"
},
"class_type": "CLIPLoader",
"_meta": {
"title": "Load CLIP"
}
},
"171": {
"inputs": {
"noise_seed": 1
},
"class_type": "RandomNoise",
"_meta": {
"title": "RandomNoise"
}
},
"172": {
"inputs": {
"fps": 24,
"bit_depth": 8,
"color_space": "sRGB",
"images": [
"164",
0
],
"audio": [
"163",
0
]
},
"class_type": "CreateVideo",
"_meta": {
"title": "Create Video"
}
},
"174": {
"inputs": {
"expression": "max(5, round(a * 24)) + (5 - (max(5, round(a * 24)) % 17)) % 17",
"values.a": [
"175",
0
]
},
"class_type": "ComfyMathExpression",
"_meta": {
"title": "Math Expression"
}
},
"175": {
"inputs": {
"value": 2
},
"class_type": "PrimitiveFloat",
"_meta": {
"title": "Float (duration)"
}
},
"176": {
"inputs": {
"lora_name": "minimax_h3_fl2v_turbo_8step_v1.0_comfyui_bf16.safetensors",
"strength_model": 1,
"model": [
"169",
0
]
},
"class_type": "LoraLoaderModelOnly",
"_meta": {
"title": "Load LoRA"
}
},
"177": {
"inputs": {
"switch": [
"181",
0
],
"on_false": [
"169",
0
],
"on_true": [
"176",
0
]
},
"class_type": "ComfySwitchNode",
"_meta": {
"title": "If/Else Switch (Model)"
}
},
"178": {
"inputs": {
"switch": [
"181",
0
],
"on_false": [
"179",
0
],
"on_true": [
"180",
0
]
},
"class_type": "ComfySwitchNode",
"_meta": {
"title": "If/Else Switch (Steps)"
}
},
"179": {
"inputs": {
"value": 20
},
"class_type": "PrimitiveInt",
"_meta": {
"title": "Int"
}
},
"180": {
"inputs": {
"value": 6
},
"class_type": "PrimitiveInt",
"_meta": {
"title": "Int"
}
},
"181": {
"inputs": {
"value": false
},
"class_type": "PrimitiveBoolean",
"_meta": {
"title": "Boolean (Enable Lightning LoRA)"
}
},
"hero": {
"class_type": "LoadImage",
"inputs": {
"image": ""
}
},
"guidance": {
"class_type": "FluxGuidance",
"inputs": {
"conditioning": [
"155",
0
],
"guidance": 1.5
}
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
export default defineEventHandler((event) => {
if (!authEnabled()) return
const path = event.path || getRequestURL(event).pathname
const path = getRequestURL(event).pathname
if (
path.startsWith('/api/auth/login') ||
path.startsWith('/api/auth/callback') ||
@@ -20,5 +20,5 @@ export default defineEventHandler((event) => {
if (path.startsWith('/api/')) {
throw createError({ statusCode: 401, statusMessage: 'Authentication required' })
}
return sendRedirect(event, '/login', 302)
return sendRedirect(event, path === '/studio-2' ? '/login?next=/studio-2' : '/login', 302)
})
+3
View File
@@ -0,0 +1,3 @@
import { retryCleanup } from '../utils/studio2/cleanup';
import { resumeStudio2Jobs } from '../utils/studio2/runner';
export default defineNitroPlugin(() => { resumeStudio2Jobs(); const timer = setInterval(() => { void retryCleanup().catch(error => console.warn('[Studio 2 cleanup retry]', error.message)); }, 30000); timer.unref(); });
+4
View File
@@ -0,0 +1,4 @@
import { resumeYue2Jobs } from '~/server/utils/yue2'
// Restore live music IDs before queue repair examines their durable studio rows.
export default defineNitroPlugin(() => { resumeYue2Jobs() })
+2 -4
View File
@@ -1,4 +1,2 @@
import { resumeYueGpJobs } from '~/server/utils/yueGp'
// Restore live music IDs before queue repair examines their durable studio rows.
export default defineNitroPlugin(() => { resumeYueGpJobs() })
// YuEGP resume removed — music is YuE2-only. scripts/yuegp-* remain on disk unused.
export default defineNitroPlugin(() => {})
+2 -7
View File
@@ -166,17 +166,12 @@ function sanitizeImageV2Settings(raw: unknown): ImageV2PresetSettings {
function sanitizeMusicSettings(raw: unknown): MusicPresetSettings {
const rec = raw && typeof raw === 'object' ? raw as Record<string, unknown> : {}
const instrumental = rec.instrumental === true
return {
tags: sanitizeMusicTags(rec.tags),
lyrics: sanitizeMusicLyrics(rec.lyrics, instrumental),
instrumental,
lyrics: sanitizeMusicLyrics(rec.lyrics, false),
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,
cfg: rec.cfg == null ? undefined : clampMusicCfg(rec.cfg),
engine: parseMusicEngine(rec.engine),
...(parseMusicEngine(rec.engine) === 'yue' ? { yueProfile: rec.yueProfile === 3 ? 3 as const : 1 as const } : {})
engine: 'yue2'
}
}
+2
View File
@@ -32,8 +32,10 @@ export interface JobEvent {
}
export interface Job {
studio2?: boolean
upscale?: boolean
yueGp?: boolean
yue2?: boolean
musicActivity?: { checkedAt: number; running: boolean }
id: string
kind?: 'video' | 'edit' | 'music'
+11
View File
@@ -35,6 +35,7 @@ export interface LibraryFolder {
}
export interface LibraryClip {
studio2?: Record<string, any>
id: string
folderId: string
name: string
@@ -95,6 +96,7 @@ export interface LibraryTrack {
export type StillRole = 'input' | 'output'
export interface LibraryStill {
studio2?: Record<string, any>
id: string
folderId: string
filename: string
@@ -270,6 +272,7 @@ function normalizeStill(still: LibraryStill): LibraryStill {
manualGroupIndex: still.manualGroupIndex,
parentStillId: still.parentStillId,
chainIndex: still.chainIndex,
studio2: still.studio2,
settings: normalizeStillSettings(still.settings)
}
}
@@ -1785,6 +1788,10 @@ export function findYueGpTrack(owner: string, jobId: string) {
return readCatalog(owner).tracks.find(track => track.comfyFilename === `yuegp-${jobId}.wav`)
}
export function findYue2Track(owner: string, jobId: string) {
return readCatalog(owner).tracks.find(track => track.comfyFilename === `yue2-${jobId}.wav`)
}
export async function saveTrack(params: {
ownerKey: string
folderId: string
@@ -2211,3 +2218,7 @@ export function groupLibraryStills(owner: string, ids: string[], ungroup = false
}
export function findUpscaledClip(owner: string, jobId: string) { return readCatalog(owner).clips.find(clip => clip.upscaleJobId === jobId) }
export async function attachStudio2Metadata(owner: string, id: string, metadata: Record<string, any>) {
await mutate(owner, catalog => { const item = catalog.clips.find(c => c.id === id) || catalog.stills.find(c => c.id === id); if (!item) throw new Error("Saved Studio 2 asset missing"); item.studio2 = metadata })
}
+11 -409
View File
@@ -1,12 +1,4 @@
import { startYueGpJob } from './yueGp'
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 { startYue2Job } from './yue2'
import type { MusicEngine } from '~/utils/music'
export type MusicJobParams = {
@@ -22,412 +14,22 @@ export type MusicJobParams = {
lyricsStrength: number
instrumental: boolean
folderLocked?: boolean
yueProfile?: 1 | 3
engine?: MusicEngine
samplerName?: string
scheduler?: string
}
function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms))
}
function classifyError(message: string) {
const lower = message.toLowerCase()
if (lower.includes('out of memory') || lower.includes('exceed allowed memory') || (lower.includes('cuda') && lower.includes('alloc')) || lower.includes('vram')) {
return 'ComfyUI ran out of VRAM.'
/** Music generation is YuE2-only. ACE Comfy and YuEGP paths are removed. */
export async function startMusicJob(params: MusicJobParams) {
if (params.instrumental) {
throw new Error('YuE2 requires lyrics. Instrumental mode is not supported.')
}
if (lower.includes('econnrefused') || lower.includes('unreachable') || lower.includes('fetch failed')) {
return 'ComfyUI host connection dropped. Confirm the desktop instance is running.'
if (!String(params.lyrics || '').trim()) {
throw new Error('YuE2 needs lyrics.')
}
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.')
}
})
return startYue2Job({
...params,
engine: 'yue2',
instrumental: false
})
}
export async function startMusicJob(params: MusicJobParams) {
if (params.engine === 'yue') return startYueGpJob(params)
const job = createJob('music')
job.library = {
ownerKey: params.ownerKey,
folderId: params.folderId,
hideThumbnail: false,
folderLocked: params.folderLocked === true,
name: params.name,
prompt: params.tags,
tags: params.tags,
lyrics: params.lyrics,
instrumental: params.instrumental,
aspect: 'audio',
width: 0,
height: 0,
steps: params.steps,
turbo: false,
seed: params.seed,
cfg: params.cfg,
duration: params.duration,
sound: true,
engine: params.engine || 'ace-step',
samplerName: params.samplerName || 'euler',
scheduler: params.scheduler || 'simple',
lyricsStrength: params.lyricsStrength
}
job.maxStep = params.steps
job.status = 'queued'
emitJob(job, { type: 'status', message: 'Checking ComfyUI...', progress: 1 })
void (async () => {
try {
const done = watchMusicJob(job)
await ensureComfyReady((status) => {
emitJob(job, {
type: status.state === 'busy' ? 'busy' : 'status',
message: status.message,
progress: status.state === 'online' ? 3 : 1,
busy: status.state === 'busy',
queueRunning: status.queueRunning,
queuePending: status.queuePending
})
})
job.status = '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
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (job.status !== 'cancelled' && job.status !== 'complete') {
job.status = 'error'
job.error = classifyError(message)
emitJob(job, { type: 'error', error: job.error, message: job.error })
await settleMusicStudio(job)
}
}
})()
return job
}
+2
View File
@@ -101,6 +101,7 @@ function buildAce15Workflow(params: MusicWorkflowParams): WorkflowGraph {
export function buildMusicWorkflow(params: MusicWorkflowParams): WorkflowGraph {
const engine = params.engine || 'ace-step'
if (engine === 'yue') throw new Error('YuE requires the standalone YuEGP backend.')
if (engine === 'yue2') throw new Error('YuE2 requires the standalone YuE2 backend.')
if (engine === 'ace-step-1.5') return buildAce15Workflow(params)
return buildAceV1Workflow(params)
}
@@ -108,6 +109,7 @@ export function buildMusicWorkflow(params: MusicWorkflowParams): WorkflowGraph {
export async function assertMusicEngineNodes(engine: MusicEngine | undefined) {
const { comfyHasClassType } = await import('~/server/utils/comfy')
if (engine === 'yue') throw new Error('YuE cannot run on Comfy.')
if (engine === 'yue2') throw new Error('YuE2 cannot run on Comfy.')
if (engine === 'ace-step-1.5') {
const present = await comfyHasClassType('TextEncodeAceStepAudio1.5')
if (present === false) {
+50
View File
@@ -0,0 +1,50 @@
import { scopedFile } from '~/shared/studio2/contracts.mjs';
import { sharedGpuHeaders, withSharedGpuStart } from '../sharedGpu';
import { records, saveRecord } from './store';
/** Called only after the output and handoff frame are durably saved. */
export async function purge(r: any, includeCurrent = true) {
const config = useRuntimeConfig(), prefix = String(config.comfyFilenamePrefix);
const pending = r.cleanupPending || [];
const files = [...pending, ...(includeCurrent ? r.files || [] : [])].filter((file, i, all) => all.findIndex(f => f.filename === file.filename && f.subfolder === file.subfolder && f.type === file.type) === i);
r.cleanupPending = files;
r.purgeResult = 'Cleanup pending';
saveRecord(r);
if (!files.length) return;
try {
if (files.some(file => !scopedFile(file, prefix))) throw new Error('Cleanup manifest is outside this studio');
for (let offset = 0; offset < files.length; offset += 100) {
const batch = files.slice(offset, offset + 100);
const response = await fetch(String(config.comfyControlUrl).replace(/\/$/, '') + '/studio2/purge', {
method: 'POST', headers: { 'Content-Type': 'application/json', ...sharedGpuHeaders() },
body: JSON.stringify({ prefix, files: batch }), signal: AbortSignal.timeout(12000)
});
const result = await response.json() as any;
if (!response.ok || !result.ok || result.cleared !== batch.length) throw new Error(`Local cleanup was not confirmed (${response.status})`);
r.cleanupPending = files.slice(offset + batch.length);
saveRecord(r);
}
r.purgeResult = 'Cleared input + output';
r.cleanupError = '';
} catch (error: any) {
r.cleanupError = error.message || 'Local cleanup unavailable';
console.warn('[Studio 2 cleanup]', r.id, r.cleanupError);
}
saveRecord(r);
}
let retrying = false;
export async function retryCleanup() {
if (retrying) return;
retrying = true;
try {
const pending = records().filter(r => r.cleanupPending?.length && ['complete', 'failed', 'cancelled'].includes(r.state));
if (!pending.length) return;
await withSharedGpuStart(async () => {
for (const r of pending) {
// Never add a later failed shot's unsaved files to the saved cleanup manifest.
await purge(r, false);
}
}, async () => {});
} finally { retrying = false; }
}
@@ -0,0 +1,22 @@
"""Export only LoRA labels/triggers; invoked by the host's catalog change watcher."""
import json
import os
import sys
from pathlib import Path
def export(source, destination):
catalog = json.loads(Path(source).read_text())
result = {key: {name: value for name, value in catalog.get(key, {}).items()
if isinstance(value, str)} for key in ('loraAliases', 'loraTriggers')}
target = Path(destination)
content = json.dumps(result, ensure_ascii=False)
if target.exists() and target.read_text() == content:
return
target.parent.mkdir(parents=True, exist_ok=True)
temporary = target.with_suffix('.tmp')
temporary.write_text(content)
os.chmod(temporary, 0o644)
temporary.replace(target)
if __name__ == '__main__':
export(*sys.argv[1:])
+15
View File
@@ -0,0 +1,15 @@
import {readFileSync} from 'node:fs';
import {getLoraAliases,getLoraTriggers} from '../library';
import {loraIdentityKey} from '~/utils/loras';
import {join} from 'node:path';
import {studio2Root} from './store';
/** Optional metadata-only production export; no production media/catalog mount. */
export function studio2LoraCatalog(owner: string) {
const local={aliases:getLoraAliases(owner),triggers:getLoraTriggers(owner)};
try {
const source=JSON.parse(readFileSync(process.env.STUDIO2_LORA_CATALOG_PATH || join(studio2Root(),'shared-lora-catalog.cache'),'utf8'));
const normalize=(map: any)=>Object.fromEntries(Object.entries(map || {}).filter(([,value])=>typeof value==='string').map(([key,value])=>[loraIdentityKey(key),value]));
return {aliases:{...local.aliases,...normalize(source.loraAliases)},triggers:{...local.triggers,...normalize(source.loraTriggers)}};
} catch { return local; }
}
+23
View File
@@ -0,0 +1,23 @@
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { runFfmpeg } from '../ffmpeg'
import { imageDimensions } from '../resolution'
export async function fitStill(data: Buffer, width: number, height: number) {
const dir=mkdtempSync(join(tmpdir(),'studio2-fit-'))
try {
const input=join(dir,'source'),output=join(dir,'fitted.png')
writeFileSync(input,data)
await runFfmpeg(['-y','-i',input,'-vf',`scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1`,'-frames:v','1',output])
return readFileSync(output)
} finally { rmSync(dir,{recursive:true,force:true}) }
}
/** Only skip the scale+pad when the still already matches the job canvas pixel-for-pixel.
* A same-aspect-but-larger still (e.g. a 4032x2268 phone photo against a 1344x768 canvas)
* still needs fitting: MiniMaxH3AddGuide expects an image sized to the job's own latent,
* not merely the same aspect ratio. Unreadable dimensions always fit (safe default). */
export function needsFit(data: Buffer, width: number, height: number) {
const size = imageDimensions(data)
if (!size?.width || !size?.height) return true
return !(size.width === width && size.height === height)
}
+28
View File
@@ -0,0 +1,28 @@
import { comfyWsUrl } from '../comfy'
import { saveRecord } from './store'
/** Listen only to this client's prompt; history polling remains the completion authority. */
export function watchProgress(record: any, job: any) {
let ws: WebSocket | undefined, timer: ReturnType<typeof setTimeout>, stopped = false
const connect = () => {
if (stopped) return
try {
ws = new WebSocket(comfyWsUrl(job.clientId))
ws.addEventListener('message', event => {
if (typeof event.data !== 'string') return
try {
const {type,data} = JSON.parse(event.data)
if (!record.promptId || (data?.prompt_id && data.prompt_id !== record.promptId)) return
if (type === 'progress' && Number(data.max) > 0) {
record.progress = {step:Number(data.value),maxStep:Number(data.max),percent:Math.round(Number(data.value)/Number(data.max)*100),updatedAt:Date.now()}
saveRecord(record)
}
} catch { /* Binary previews and malformed events cannot interrupt a job. */ }
})
ws.addEventListener('error', () => { ws?.close() })
ws.addEventListener('close', () => { if (!stopped) timer=setTimeout(connect,3000) })
} catch { if (!stopped) timer=setTimeout(connect,3000) }
}
connect()
return () => { stopped=true;clearTimeout(timer);ws?.close() }
}
+338
View File
@@ -0,0 +1,338 @@
import { kleinIdentityPlan, applyKleinIdentity } from '~/shared/studio2/klein-identity.mjs';
import { stylePrompt } from '~/shared/studio2/styles.mjs';
import { purge } from './cleanup';
import { watchProgress } from './progress';
import { fitStill, needsFit } from './media';
import { resolveRequestSize } from './size';
import { queueSeeds } from '~/shared/studio2/seed.mjs';
import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from 'node:fs';
import { join } from 'node:path';
import template from '../../assets/studio2_minimax_native.json';
import { nativeVideoGraph, attachHeroReference, applyResolvedImageSize } from '~/shared/studio2/graphs.mjs';
import { compilePrompt, scopedFile } from '~/shared/studio2/contracts.mjs';
import { createJob, restoreJob, getJob, emitJob, type Job } from '../jobs';
import { markStudioLive, onLiveVideoSettled, type StudioJob } from '../studioQueue';
import { readRecord, saveRecord, studio2Root, records } from './store';
import { ensureComfyReady } from '../comfyLifecycle';
import { comfyFetch, queuePrompt, fetchHistory, extractVideo } from '../comfy';
import { extractEditedImage } from '../imageComfy';
import { buildImageV2Workflow, resolveKreaGenerateAssets } from '../imageWorkflowV2';
import { resolveGraphLoraNames, applyUserLoraToGraph, ensureComfyLoraNames } from '../loras';
import { getClip, getStill, stillPath, clipVideoPath, saveStill, saveClip, downloadComfyImage, downloadComfyVideo, attachStudio2Metadata } from '../library';
import { imageDimensions } from '../resolution';
import { stitchExtension, probeDuration } from '../ffmpeg';
import { videoSourcePaths } from '../videoSources';
import { resolveExtensionHandoffFrame, persistClipAnchorFrame } from '../extensionFrame';
import { acquireSharedGpu, sharedGpuHeaders } from '../sharedGpu';
type DiskFile = {
filename: string;
subfolder: string;
type: string;
};
function update(r: any, state: string) { r.state = state; r.updatedAt = Date.now(); if (['complete','failed','cancelled'].includes(state)) r.finishedAt ??= r.updatedAt; saveRecord(r); }
// Labels whose pixels must never be resampled: the hero identity still, and the
// extend/video start frame (which is either the exact anchor PNG or an already-canvas-sized
// extracted last frame). Fitting these every hop is what destroys identity across chained extends.
const NEVER_FIT = new Set(['hero', 'start']);
async function upload(r: any, name: string, data: Buffer): Promise<string> {
const prefix = String(useRuntimeConfig().comfyFilenamePrefix).replace(/\/$/, '') + `/studio2/${r.id}/${r.index}`;
let fitted = false;
if (!NEVER_FIT.has(name) && needsFit(data, r.request.settings.width, r.request.settings.height)) {
data = await fitStill(data, r.request.settings.width, r.request.settings.height);
fitted = true;
}
if (name === 'start') { r.handoffFitted = fitted; saveRecord(r); }
const body = new FormData();
body.append('image', new Blob([new Uint8Array(data)]), name + '.png');
body.append('subfolder', prefix);
body.append('type', 'input');
body.append('overwrite', 'true');
const response = await comfyFetch('/upload/image', { method: 'POST', body });
if (!response.ok)
throw new Error('Reference image upload failed');
const file = await response.json() as any;
const disk = { filename: file.name, subfolder: file.subfolder || '', type: 'input' };
if (!scopedFile(disk, String(useRuntimeConfig().comfyFilenamePrefix)))
throw new Error('Comfy returned an input outside this instance prefix');
r.files.push(disk);
saveRecord(r);
return `${disk.subfolder}/${disk.filename}`;
}
async function prepareGraph(r: any) {
const q = r.request, s = q.settings, prefix = String(useRuntimeConfig().comfyFilenamePrefix).replace(/\/$/, '') + `/studio2/${r.id}/${r.index}`;
const load = async (id: string, label: string) => id ? upload(r, label, readFileSync(stillPath(r.owner, getStill(r.owner, id).id))) : '';
const video=['video','extend'].includes(q.mode);
const identityPlan=kleinIdentityPlan(q);
const sourceId=identityPlan ? identityPlan.sourceId : q.imageAId;
await resolveRequestSize(r.owner,identityPlan ? {...q,imageAId:sourceId} : q);
saveRecord(r);
const a=await load(sourceId,'source'),b=await load(q.imageBId,'compose'),mask=await load(q.maskId,'mask');
const attachHero=(video || q.engine==='flux') && (q.lockFace!==false || q.lockOutfit!==false) && !(!video && sourceId && sourceId===q.identityStillId);
const hero=attachHero ? await load(q.identityStillId,'hero') : '';
r.sourceStillId=sourceId || null;
r.heroReferenceAttached=!!hero || (!video && q.engine==='flux' && !!sourceId && sourceId===q.identityStillId);
let graph: any;
if (['video', 'extend'].includes(q.mode)) {
let start = a;
if (q.startClipId) {
const dir = join(studio2Root(), r.id);
mkdirSync(dir, { recursive: true });
const dest = join(dir, 'handoff.png');
const handoff = await resolveExtensionHandoffFrame({ ownerKey: r.owner, sourceClipId: q.startClipId, sourceVideoPath: clipVideoPath(r.owner, q.startClipId), destPath: dest });
r.handoffSource = handoff.source;
start = await upload(r, 'start', readFileSync(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 = [];
for (const [i, g] of q.guides.entries())
guides.push({ image: await load(g.stillId, `guide${i}`), frame: g.frame });
graph = nativeVideoGraph(template, q, { hero, start, end, guides }, prefix);
await ensureComfyLoraNames('video');
resolveGraphLoraNames(graph, 'video');
applyUserLoraToGraph(graph, s.loraStack);
r.graphId = 'studio2_minimax_native.json';
}
}
else {
const mode = identityPlan?.mode ?? (q.mode === 'iterate' ? (a ? 'edit' : 'generate') : q.mode);
const found = q.engine === 'krea' ? await resolveKreaGenerateAssets() : null;
const assets = found ? { kreaUnetName: found.unet, kreaClipName: found.clip, kreaVaeName: found.vae, kreaConceptLora: found.conceptLora } : {};
graph = buildImageV2Workflow({ ...s, ...assets, engine: q.engine, mode, task: 'scene', prompt: q.compiledPrompt, negative: stylePrompt(q.imageStyles, true), imageAName: a, imageBName: mode === 'compose' ? b : undefined, maskName: mode === 'refine' ? mask : undefined, filenamePrefix: prefix + '/image' }).graph;
applyResolvedImageSize(graph,s);
if (identityPlan) Object.assign(r,applyKleinIdentity(graph,identityPlan));
if (q.engine === 'flux')
attachHeroReference(graph, hero);
if (!identityPlan) {
r.sampleLatent=q.mode==='refine'?'masked source latent':'empty latent';
r.sampleDenoise=null;
}
if (q.engine !== 'flux' && hero && ['edit', 'compose', 'iterate'].includes(q.mode))
throw new Error('Krea native hero-reference binding is not supported by this graph. Select Klein explicitly; no fallback is performed.');
await ensureComfyLoraNames('image');
resolveGraphLoraNames(graph, 'image');
r.graphId = `${q.engine === 'flux' ? 'klein' : 'krea'}_v2_${mode}.json`;
}
saveRecord(r);
return graph;
}
async function run(r: any, job: Job) {
const stopProgress = watchProgress(r,job);
try {
const prompts = r.prompts || [r.request.promptSections, ...r.request.batch];
r.prompts = prompts;
r.request.shotSeeds ||= queueSeeds(r.request.settings,prompts.length);
saveRecord(r);
for (r.index = r.index || 0; r.index < prompts.length; r.index++) {
if (job.status === 'cancelled')
throw new Error('Cancelled');
if (!r.promptId) {
while (!await acquireSharedGpu()) {
if (job.status === 'cancelled')
throw new Error('Cancelled');
await new Promise(resolve => setTimeout(resolve, 2000));
}
r.request.settings.seed = r.request.shotSeeds[r.index];
r.files = [];
r.progress = null;
r.request.promptSections = prompts[r.index];
r.request.compiledPrompt = compilePrompt(prompts[r.index], ['video', 'extend'].includes(r.request.mode), r.request);
update(r, 'waking');
await ensureComfyReady(message => emitJob(job, { type: 'status', message }));
if (job.status === 'cancelled')
throw new Error('Cancelled');
const graph = await prepareGraph(r);
if (job.status === 'cancelled')
throw new Error('Cancelled');
update(r, 'submitting');
const queued = await queuePrompt(graph, job.clientId);
job.promptId = queued.prompt_id;
r.promptId = queued.prompt_id;
r.renderStartedAt = Date.now();
update(r, 'rendering');
}
let history: any, missingSince = 0;
for (;;) {
try {
history = await fetchHistory(r.promptId);
}
catch {
history = null;
}
const e = history?.[r.promptId];
if (job.status === 'cancelled')
throw new Error('Cancelled');
if (['error', 'interrupted'].includes(e?.status?.status_str))
throw new Error('Comfy generation failed or was interrupted; inspect the host error log.');
if (!e) {
try {
const response = await comfyFetch('/queue', { signal: AbortSignal.timeout(5000) });
if (response.ok) {
const q = await response.json() as any;
const present = [...(q.queue_running || []), ...(q.queue_pending || [])].some((entry: any) => entry[1] === r.promptId);
if (present)
missingSince = 0;
else if (!missingSince)
missingSince = Date.now();
else if (Date.now() - missingSince > 60000)
throw new Error('PROMPT_MISSING');
}
}
catch (error: any) {
if (error.message === 'PROMPT_MISSING')
throw new Error('The saved Comfy prompt is no longer in history or the queue. It was not resubmitted.');
}
}
if (e?.status?.completed || e?.status?.status_str === 'success')
break;
if (job.status === 'cancelled')
throw new Error('Cancelled');
await new Promise(resolve => setTimeout(resolve, 1500));
}
const messages = history[r.promptId]?.status?.messages || [];
const executionStart = messages.find((m: any) => m[0] === 'execution_start')?.[1]?.timestamp;
const executionEnd = messages.find((m: any) => m[0] === 'execution_success')?.[1]?.timestamp;
r.gpuSeconds = executionStart && executionEnd ? (executionEnd - executionStart) / 1000 : null;
update(r, 'saving');
job.saving = true;
const q = r.request, s = q.settings, video = ['video', 'extend'].includes(q.mode);
const file = video ? extractVideo(history, r.promptId) : extractEditedImage(history, r.promptId);
if (!file)
throw new Error('Comfy finished without a media output');
if (!r.files.some((f: DiskFile) => f.filename === file.filename && f.subfolder === file.subfolder))
r.files.push(file);
let asset: any = r.savedAssetId ? (video ? getClip(r.owner, r.savedAssetId) : getStill(r.owner, r.savedAssetId)) : null;
if (!asset && video) {
const data = await downloadComfyVideo(file);
if (!data.length || data.length < 64)
throw new Error('Comfy wrote an empty video file (<64 bytes); the job did not render. Nothing was saved.');
const sourceSegments = q.startClipId ? videoSourcePaths(clipVideoPath(r.owner, q.startClipId)) : [];
const tmp = join(studio2Root(), r.id);
mkdirSync(tmp, { recursive: true });
const assembled = q.startClipId ? await stitchExtension({ part1Path: clipVideoPath(r.owner, q.startClipId), part2: data, tmpDir: tmp, sourcePaths: sourceSegments }) : data;
if (!assembled.length || assembled.length < 64)
throw new Error('The assembled video file is empty (<64 bytes); the job did not render. Nothing was saved.');
const checkPath = join(tmp, 'duration-check.mp4');
writeFileSync(checkPath, assembled);
let duration = 0;
try { duration = await probeDuration(checkPath); }
finally { try { unlinkSync(checkPath); } catch { /* ignore */ } }
if (!Number.isFinite(duration) || duration <= 0)
throw new Error('The rendered video has no readable duration; the job did not render. Nothing was saved.');
asset = await saveClip({ ownerKey: r.owner, folderId: q.folderId, prompt: q.compiledPrompt, ...s, aspect: s.aspect || 'auto', hideThumbnail: false, video: assembled, originalSegment: data, sourceSegments, fps: s.fps || 24, sound: true, familyId: r.familyId, parentClipId: q.startClipId || undefined, chainIndex: r.index, comfyFilename: file.filename });
}
else if (!asset) {
const data = await downloadComfyImage(file), size = imageDimensions(data) || { width: s.width, height: s.height };
asset = await saveStill({ ownerKey: r.owner, folderId: q.folderId, filename: file.filename, data, ...size, role: 'output', prompt: q.compiledPrompt, familyId: r.familyId, chainIndex: r.index, settings: s });
}
if (!asset)
throw new Error('Library did not save the output');
r.savedAssetId = asset.id;
saveRecord(r);
r.savedAt = Date.now();
r.wallTime = (r.savedAt - r.startedAt) / 1000;
const metadata = { ...structuredClone(q), id: r.id, sourceStillId:r.sourceStillId,heroReferenceAttached:r.heroReferenceAttached,sampleLatent:r.sampleLatent,sampleDenoise:r.sampleDenoise,kind: video ? 'video' : 'image', graphId: r.graphId, promptId: r.promptId, queuedAt: r.queuedAt, startedAt: r.startedAt, savedAt: r.savedAt, gpuSeconds: r.gpuSeconds, wallTime: r.wallTime, outputWidth: asset.width || s.width, outputHeight: asset.height || s.height, purgeResult: 'Left on host', handoffSource: r.handoffSource || null, fitted: video && q.startClipId ? !!r.handoffFitted : null };
await attachStudio2Metadata(r.owner, asset.id, metadata);
if (!r.outputs.some((o:any)=>o.id===asset.id)) r.outputs.push({id:asset.id,kind:video?'clip':'still',studio2:metadata});
saveRecord(r);
const anchor = video && history[r.promptId].outputs?.anchor_save?.images?.[0];
if (anchor) {
if (!r.files.some((f: DiskFile)=>f.filename===anchor.filename && f.subfolder===anchor.subfolder)) r.files.push(anchor);
await persistClipAnchorFrame({ownerKey:r.owner,clipId:asset.id,fromBuffer:await downloadComfyImage(anchor)});
}
await purge(r);
metadata.purgeResult = r.purgeResult;
await attachStudio2Metadata(r.owner, asset.id, metadata);
saveRecord(r);
job.saving = false;
if (video) {
q.startClipId = asset.id;
q.startFrameSource = { kind: 'previous-last-frame', clipId: asset.id };
}
else if (q.mode === 'iterate')
q.imageAId = asset.id;
r.promptId = '';
r.savedAssetId = '';
r.index++;
saveRecord(r);
r.index--;
// Identity remains the original hero throughout the batch.
}
if (job.status === 'cancelled')
throw new Error('Cancelled');
job.status = 'complete';
update(r, 'complete');
emitJob(job, { type: 'complete', message: 'Studio 2 output saved' });
}
catch (e: any) {
job.status = job.status === 'cancelled' ? 'cancelled' : 'error';
job.error = e.message;
r.error = e.message;
update(r, job.status === 'cancelled' ? 'cancelled' : 'failed');
emitJob(job, { type: 'error', message: e.message });
}
finally {
stopProgress();
job.saving = false;
await onLiveVideoSettled(job);
}
}
export async function startStudio2Job(item: StudioJob) {
const r = readRecord(item.ownerKey, item.payload.studio2Id!), job = createJob(['video', 'extend'].includes(r.request.mode) ? 'video' : 'edit');
job.studio2 = true;
job.library = { ownerKey: item.ownerKey, folderId: r.request.folderId, extensions: [], queueAutoRun: false } as any;
job.status = 'running';
r.startedAt = Date.now();
delete r.finishedAt;
r.liveId = job.id;
r.clientId = job.clientId;
saveRecord(r);
await markStudioLive(item.ownerKey, item.id, job.id);
void run(r, job);
}
export function resumeStudio2Jobs() {
for (const r of records()) {
if (!r.liveId || getJob(r.liveId) || ['waiting', 'complete', 'failed', 'cancelled'].includes(r.state))
continue;
if (r.state === 'submitting' && !r.promptId) {
r.state = 'failed';
r.error = 'Submission outcome unknown after restart. Inspect the host before retrying.';
saveRecord(r);
continue;
}
const job = restoreJob({ id: r.liveId, clientId: r.clientId, startedAt: r.startedAt, promptId: r.promptId || '', library: { ownerKey: r.owner, folderId: r.request.folderId, extensions: [], queueAutoRun: false } as any });
job.studio2 = true;
void run(r, job);
}
}
+25
View File
@@ -0,0 +1,25 @@
import { readFileSync } from 'node:fs'
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import { getClip, getStill, clipVideoPath, stillPath } from '../library'
import { imageDimensions } from '../resolution'
import { resolveSize } from '~/shared/studio2/input.mjs'
const exec = promisify(execFile)
export async function resolveRequestSize(owner: string, request: any) {
const video=['video','extend'].includes(request.mode)
let dimensions:any
if (video && request.startClipId) {
getClip(owner,request.startClipId)
const result=await exec('ffprobe',['-v','error','-select_streams','v:0','-show_entries','stream=width,height','-of','json',clipVideoPath(owner,request.startClipId)],{windowsHide:true,timeout:15000})
dimensions=JSON.parse(result.stdout).streams?.[0]
} else {
const id=request.imageAId || request.identityStillId
if (id) {
getStill(owner,id)
dimensions=imageDimensions(readFileSync(stillPath(owner,id)))
if (!dimensions) throw new Error('Cannot measure the source still.')
}
}
if (video && request.startClipId && !dimensions?.width) throw new Error('Cannot measure the previous clip.')
Object.assign(request.settings,resolveSize(request.settings.aspect,dimensions))
}
+14
View File
@@ -0,0 +1,14 @@
import { mkdirSync, readFileSync, writeFileSync, renameSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
export function studio2Root() { const dir = join(String(useRuntimeConfig().libraryDir), 'studio2'); mkdirSync(dir, { recursive: true }); return dir; }
export function saveRecord(record: any) { const path = join(studio2Root(), `${record.id}.json`); writeFileSync(path + '.tmp', JSON.stringify(record)); renameSync(path + '.tmp', path); }
export function readRecord(owner: string, id: string) { if (!/^[\w-]+$/.test(id))
throw createError({ statusCode: 400, statusMessage: 'Invalid job ID' }); const r = JSON.parse(readFileSync(join(studio2Root(), `${id}.json`), 'utf8')); if (r.owner !== owner)
throw createError({ statusCode: 404, statusMessage: 'Job not found' }); return r; }
export function records(owner?: string) { return readdirSync(studio2Root()).filter(n => n.endsWith('.json')).flatMap(n => { try {
const r = JSON.parse(readFileSync(join(studio2Root(), n), 'utf8'));
return !owner || r.owner === owner ? [r] : [];
}
catch {
return [];
} }).sort((a, b) => b.queuedAt - a.queuedAt); }
+31 -9
View File
@@ -16,6 +16,7 @@ export type StudioJobStatus = 'waiting' | 'running' | 'held' | 'complete' | 'err
export type StudioJobKind = 'video' | 'edit' | 'music'
export interface StudioJobPayload {
studio2Id?: string
upscale?: { sourceId: string; scale: 2 | 4; target: string; fps: string | number; enhance: string }
prompt: string
promptMid?: string
@@ -285,7 +286,7 @@ function failZombieLiveJob(job: Job, error: string) {
function sweepStaleLiveJobs() {
const now = Date.now()
for (const job of listJobs()) {
if (job.yueGp || job.upscale) continue
if (job.studio2 || job.yueGp || job.yue2 || job.upscale) continue
if (job.saving) continue
if (job.status === 'queued' && !job.promptId && now - job.startedAt >= QUEUED_GRACE_MS) {
failZombieLiveJob(job, 'Job never started')
@@ -305,7 +306,7 @@ async function reapZombieLiveJobs() {
const { fetchHistory } = await import('~/server/utils/comfy')
for (const job of listJobs()) {
// Never interrupt download/stitch/library save — Comfy is idle then by design.
if (job.yueGp || job.upscale) continue
if (job.studio2 || job.yueGp || job.yue2 || job.upscale) continue
if (job.saving) continue
if (job.library?.chainContinuing) continue
if (job.status !== 'running' && job.status !== 'uploading' && job.status !== 'queued') continue
@@ -350,7 +351,7 @@ async function reapZombieLiveJobs() {
}
function liveJobOwnsGpu(job: Job) {
if ((job.yueGp || job.upscale) && ['running', 'queued', 'uploading'].includes(job.status)) return true
if ((job.studio2 || job.yueGp || job.yue2 || job.upscale) && ['running', 'queued', 'uploading'].includes(job.status)) return true
if (job.library?.stopAfterCurrent) return false
if (job.library?.chainContinuing) return true
if (job.saving) return true
@@ -370,7 +371,7 @@ function liveJobOwnsGpu(job: Job) {
*/
function clearDeadGpuClaimsForForceStart() {
for (const live of listJobs()) {
if (live.yueGp || live.upscale) continue
if (live.studio2 || live.yueGp || live.yue2 || live.upscale) continue
if (live.saving) continue
if (jobIsLocallySubmitting(live)) continue
if (live.status !== 'running' && live.status !== 'queued' && live.status !== 'uploading') {
@@ -597,6 +598,10 @@ export async function clearStuckStudioWork(owner: string) {
const { cancelYueGpJob } = await import('./yueGp')
await cancelYueGpJob(job)
}
if (job.yue2) {
const { cancelYue2Job } = await import('./yue2')
await cancelYue2Job(job)
}
job.status = 'cancelled'
job.error = 'Cleared by force reset'
if (job.library) {
@@ -664,6 +669,11 @@ async function stopLiveGeneration(liveJobId?: string, shotQueueId?: string) {
await cancelYueGpJob(live)
return
}
if (live?.yue2) {
const { cancelYue2Job } = await import('./yue2')
await cancelYue2Job(live)
return
}
if (live) {
live.status = 'cancelled'
if (live.library) {
@@ -913,7 +923,7 @@ function repairStaleJobs(jobs: StudioJob[]) {
job.updatedAt = Date.now()
continue
}
if (!job.payload.upscale && job.payload.musicEngine !== 'yue' && job.status === 'error' && isTransientComfyError(job.lastError)) {
if (!job.payload.upscale && job.kind !== 'music' && job.status === 'error' && isTransientComfyError(job.lastError)) {
job.status = 'waiting'
job.liveJobId = undefined
job.lastError = undefined
@@ -922,7 +932,7 @@ function repairStaleJobs(jobs: StudioJob[]) {
job.updatedAt = Date.now()
continue
}
if (!job.payload.upscale && job.payload.musicEngine !== 'yue' && job.status === 'held' && isTransientComfyError(job.lastError)) {
if (!job.payload.upscale && job.kind !== 'music' && job.status === 'held' && isTransientComfyError(job.lastError)) {
job.status = 'waiting'
job.liveJobId = undefined
job.lastError = undefined
@@ -1397,8 +1407,7 @@ async function startStudioMusicJob(item: StudioJob) {
lyricsStrength: payload.instrumental ? 0 : (payload.lyricsStrength ?? 0.9),
instrumental: payload.instrumental === true,
folderLocked: payload.folderLocked,
engine: payload.musicEngine || 'ace-step',
yueProfile: payload.yueProfile ?? 1,
engine: 'yue2',
samplerName: payload.samplerName || 'euler',
scheduler: payload.scheduler || 'simple'
})
@@ -1422,6 +1431,19 @@ export async function startStudioJob(item: StudioJob) {
}
async function startStudioJobReserved(item: StudioJob) {
if (item.payload.studio2Id) {
try {
const { startStudio2Job } = await import('./studio2/runner')
await startStudio2Job(item)
} catch (error) {
await patchStudioJob(item.ownerKey, item.id, row => {
row.status = 'error'
row.lastError = error instanceof Error ? error.message : String(error)
})
scheduleKickRetry()
}
return
}
if (item.payload.upscale) {
try { const { startUpscaleJob } = await import('./videoUpscale'); await startUpscaleJob(item) }
catch (error) { await patchStudioJob(item.ownerKey, item.id, row => { row.status = 'error'; row.lastError = error instanceof Error ? error.message : String(error) }); scheduleKickRetry() }
@@ -1673,7 +1695,7 @@ export async function onLiveVideoSettled(job: Job) {
return
}
const remaining = remainingStudioShots(job)
const wakeFail = !job.yueGp && !job.upscale && job.status === 'error' && remaining > 0 && isTransientComfyError(job.error)
const wakeFail = !job.studio2 && !job.yueGp && !job.yue2 && !job.upscale && job.status === 'error' && remaining > 0 && isTransientComfyError(job.error)
const failed = (job.status === 'error' || job.status === 'cancelled') && !wakeFail
await mutateStore(owner, (store) => {
+148
View File
@@ -0,0 +1,148 @@
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'node:fs'
import { join } from 'node:path'
import { createJob, emitJob, getJob, restoreMusicJob, type Job } from './jobs'
import { saveTrack, findYue2Track } from './library'
import { sharedGpuHeaders } from './sharedGpu'
import type { MusicJobParams } from './musicChain'
function settings() {
const config = useRuntimeConfig()
return { url: String(config.comfyControlUrl || process.env.COMFY_CONTROL_URL || '').replace(/\/$/, ''),
token: String(config.comfyControlToken || process.env.COMFY_CONTROL_TOKEN || '') }
}
export function yue2Configured() { return Boolean(settings().url) }
async function request(path: string, body?: unknown) {
const { url, token } = settings()
if (!url) throw new Error('YuE2 host is not configured. Set COMFY_CONTROL_URL.')
const response = await fetch(`${url}/yue2/${path}`, {
method: body === undefined ? 'GET' : 'POST',
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}),
...(body === undefined ? {} : { ...sharedGpuHeaders(), 'Content-Type': 'application/json' }) },
body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(60_000)
})
if (!response.ok) {
const detail = await response.json().catch(() => ({})) as { message?: string; error?: string }
throw Object.assign(new Error(detail.message || detail.error || `YuE2 host returned ${response.status}`), { statusCode: response.status })
}
return response
}
function pendingRoot() { return join(String(useRuntimeConfig().libraryDir || process.env.LIBRARY_DIR || '/data/library'), 'yue2-pending') }
function persist(job: Job) {
mkdirSync(pendingRoot(), { recursive: true })
const path = join(pendingRoot(), `${job.id}.json`)
writeFileSync(path + '.tmp', JSON.stringify({ id: job.id, clientId: job.clientId, startedAt: job.startedAt, library: job.library, trackId: job.trackId }))
renameSync(path + '.tmp', path)
}
async function settle(job: Job) {
const { onLiveVideoSettled } = await import('./studioQueue')
await onLiveVideoSettled(job)
const path = join(pendingRoot(), `${job.id}.json`)
if (existsSync(path)) unlinkSync(path)
}
export async function cancelYue2Job(job: Job) {
await request(`jobs/${job.id}/cancel`, {})
job.status = 'cancelled'
emitJob(job, { type: 'error', error: 'Cancelled', message: 'Cancelled' })
}
async function watch(job: Job) {
let failures = 0
while (job.status !== 'cancelled') {
try {
const state = await (await request(`jobs/${job.id}`)).json() as {
status: string; message: string; error?: string; stage?: string; progress?: number; step?: number; maxStep?: number; duration?: number
}
if (job.status === 'cancelled') { await settle(job); return }
job.musicActivity = { checkedAt: Date.now(), running: ['starting', 'running', 'cancelling'].includes(state.status) }
if (state.status === 'error' || state.status === 'cancelled') {
job.status = state.status === 'cancelled' ? 'cancelled' : 'error'
job.error = state.error || state.message
if (/out of memory/i.test(job.error || '')) job.error += ' YuE2 does not switch engines automatically.'
emitJob(job, { type: 'error', error: job.error, message: job.error })
await settle(job)
return
}
if (state.status === 'complete') {
job.saving = true
emitJob(job, { type: 'status', message: 'Saving audio to library…', progress: 98 })
const lib = job.library!
lib.audioExt = 'wav'
job.trackId ||= findYue2Track(lib.ownerKey, job.id)?.id
if (!job.trackId) {
const audio = Buffer.from(await (await request(`jobs/${job.id}/audio`)).arrayBuffer())
const track = await saveTrack({ ownerKey: lib.ownerKey, folderId: lib.folderId, name: lib.name,
tags: lib.tags || lib.prompt, lyrics: lib.lyrics || '', duration: state.duration || lib.duration || 60,
seed: lib.seed, steps: 0, cfg: 0, instrumental: lib.instrumental === true,
engine: 'yue2', audio, ext: 'wav', comfyFilename: `yue2-${job.id}.wav` })
job.trackId = track.id
persist(job)
}
job.status = 'complete'; job.saving = false
emitJob(job, { type: 'complete', message: lib.folderLocked ? 'Saved to the locked folder.' : 'Track ready',
progress: 100, trackId: job.trackId, folderLocked: lib.folderLocked, audioExt: 'wav' })
await settle(job)
return
}
failures = 0
job.status = 'running'
const percent = Number(state.progress || 0)
emitJob(job, { type: 'progress', message: `${state.message}${state.maxStep ? ` · ${state.step}/${state.maxStep}` : ''}`,
progress: Math.min(95, Math.max(1, percent)), step: state.step || 0, maxStep: state.maxStep || 0 })
} catch (error) {
failures++
job.saving = false
emitJob(job, { type: 'status', message: `YuE2 connection/save check failed; retrying: ${error instanceof Error ? error.message : String(error)}` })
if (failures >= 10) {
job.status = 'error'; job.error = 'YuE2 host unreachable or audio save failed. Job files are retained for recovery.'
emitJob(job, { type: 'error', error: job.error, message: job.error })
const { onLiveVideoSettled } = await import('./studioQueue')
await onLiveVideoSettled(job)
return
}
}
await new Promise(resolve => setTimeout(resolve, 2000))
}
await settle(job)
}
export function startYue2Job(params: MusicJobParams) {
const job = createJob('music')
job.yue2 = true
job.library = { ...params, prompt: params.tags, engine: 'yue2', aspect: 'audio', width: 0, height: 0,
hideThumbnail: false, turbo: false, sound: true }
persist(job)
setTimeout(() => { void (async () => {
try {
if (job.status === 'cancelled') { await settle(job); return }
await request('jobs', { id: job.id, tags: params.tags, lyrics: params.lyrics,
duration: params.duration, seed: params.seed })
await watch(job)
} catch (error) {
const statusCode = (error as { statusCode?: number })?.statusCode
if (statusCode && statusCode >= 400 && statusCode < 500) {
job.status = 'error'; job.error = error instanceof Error ? error.message : String(error)
emitJob(job, { type: 'error', error: job.error, message: job.error })
await settle(job)
return
}
emitJob(job, { type: 'status', message: `Checking YuE2 submission: ${error instanceof Error ? error.message : String(error)}` })
await watch(job)
}
})() }, 0)
return job
}
export function resumeYue2Jobs() {
if (!existsSync(pendingRoot())) return
for (const file of readdirSync(pendingRoot()).filter(file => /^[a-zA-Z0-9-]+\.json$/.test(file))) {
try {
const record = JSON.parse(readFileSync(join(pendingRoot(), file), 'utf8'))
if (getJob(record.id)) continue
const job = restoreMusicJob(record)
job.yueGp = false
job.yue2 = true
job.message = 'Reconnecting to YuE2'
void watch(job)
} catch { /* Preserve invalid records for diagnosis. */ }
}
}
+7
View File
@@ -0,0 +1,7 @@
/** ASCII header fallback plus UTF-8 filename; titles may contain emoji or smart punctuation. */
export function inlineFilename(name) {
const clean=String(name).replace(/[\r\n\x00-\x1f\x7f]/g,'').toWellFormed()
const ascii=clean.replace(/[^\x20-\x7e]/g,'_').replace(/["\\]/g,'_') || 'video.mp4'
const encoded=encodeURIComponent(clean || 'video.mp4').replace(/['()*]/g,c=>'%'+c.charCodeAt(0).toString(16).toUpperCase())
return `inline; filename="${ascii}"; filename*=UTF-8''${encoded}`
}
+71
View File
@@ -0,0 +1,71 @@
import {normalizeStyles,stylePrompt} from './styles.mjs'
import { queueSeeds } from './seed.mjs'
import { sectionsFrom, compileSections } from './prompt.mjs'
export { sectionsFrom } from './prompt.mjs'
export const cameraFamilies = ['static','dolly in','dolly out','pan left','pan right','tilt up','tilt down','zoom in','zoom out','orbit left','orbit right']
export const identityLock = '<Picture 1> / hero is identity for face, hair, glasses, body, and wardrobe. Keep the person and clothes fully preserved. Start frame is composition, pose, place, and camera only. Do not copy facial drift from the start frame.'
export function appearanceLock({lockFace = true, lockOutfit = true} = {}) {
if (lockFace && lockOutfit) return identityLock
if (lockFace) return '<Picture 1> / hero is identity for face, hair, glasses, and body only — not wardrobe. The start frame is composition, pose, place, and clothing. Do not restore an earlier outfit. Do not reset clothes, straps, or print to the hero still.'
if (lockOutfit) return '<Picture 1> / hero is wardrobe only. Preserve clothes from the hero. Face, hair, glasses, body, pose, place, and camera follow the start frame.'
return 'The start frame is the only appearance reference for person and clothing, as well as pose, place, and camera. Do not use the hero as an appearance reference.'
}
export function compilePrompt(value, video = false, locks = {}) {
if (video) return [compileSections(value),appearanceLock(locks)].filter(Boolean).join('\n\n')
const hasImage=!!(locks.imageAId || locks.identityStillId)
const attached=hasImage && locks.engine !== 'krea'
const reference=locks.mode!=='generate' && locks.identityStillId && locks.imageAId && locks.identityStillId!==locks.imageAId ? 'second (hero) reference' : 'image reference'
return [sectionsFrom(value).action, stylePrompt(locks.imageStyles),
attached && locks.lockFace !== false ? `Preserve the person’s face, glasses, hair, and body identity from the ${reference}.` : '',
attached && locks.lockOutfit !== false ? `Preserve the wardrobe from the ${reference}.` : '',
attached && locks.lockOutfit === false ? 'Clothing may follow the source and requested scene; do not restore the hero outfit.' : ''
].filter(Boolean).join('\n\n')
}
export function restoreInput(item) {
const saved = structuredClone(item.studio2 || item)
return { ...saved, lockFace:saved.lockFace !== false, lockOutfit:saved.lockOutfit !== false, hideInput:!!saved.hideInput, settings:structuredClone(saved.settings || {}), promptSections: sectionsFrom(saved.promptSections || { action: item.prompt || saved.compiledPrompt || '' }) }
}
export function validateRequest(raw, xaigen = false) {
raw = raw || {}
const fail = message => { throw Object.assign(new Error(message), {statusCode:400}) }
const mode = raw.mode || 'generate', engine = raw.engine || 'flux'
if (!['generate','edit','compose','refine','iterate','video','extend'].includes(mode)) fail('Unknown task.')
const video = ['video','extend'].includes(mode)
if (!(video ? ['minimax','ltx'] : ['flux','krea']).includes(engine)) fail('Engine does not support this task.')
if (engine === 'ltx' && !xaigen) fail('LTX is available only in xAIGen.')
if (engine === 'krea' && ['edit','compose','iterate'].includes(mode)) fail('Sticky hero edits require Klein. Krea remains available for Generate and masked Refine; no engine fallback is performed.')
if (Array.isArray(raw.batch) && raw.batch.length>30) fail('A batch supports up to 31 shots including the first prompt.')
if (Array.isArray(raw.guides) && raw.guides.length>8) fail('Use up to eight guide stills.')
if (!raw.folderId) fail('Choose a folder.')
if ((video || ['edit','compose','iterate'].includes(mode)) && (raw.lockFace !== false || raw.lockOutfit !== false) && !raw.identityStillId && (video || !raw.imageAId)) fail('Choose a sticky hero still.')
if (mode === 'compose' && !raw.imageBId) fail('Compose requires a second still.')
if (mode === 'refine' && !raw.maskId) fail('Refine requires a mask.')
if (['edit','compose','refine'].includes(mode) && !raw.imageAId && !(mode==='edit' && engine==='flux' && raw.identityStillId)) fail('Choose the source still.')
if (mode === 'extend' && !raw.startClipId) fail('Select the clip in the family strip or Previous clip.')
const allSections = sectionsFrom(raw.promptSections || {action:raw.prompt || ''})
const promptSections = video ? allSections : {action:allSections.action}
if (!promptSections.action.trim()) fail('Enter an action prompt.')
const number=(key, fallback, min, max) => { const n=Number(raw.settings?.[key] ?? fallback); if (!Number.isFinite(n)||n<min||n>max) fail(`Invalid ${key}.`); return n }
for (const key of ['steps']) if (!Number.isInteger(number(key,key==='steps'?24:1,0,2147483647))) fail(`Invalid ${key}.`)
for (const key of ['width','height']) if (number(key,key==='width'?1344:768,256,4096)%32) fail('Dimensions must be multiples of 32.')
const duration=number('duration',5,1,15)
if (video) {
if (promptSections.cameraBeats.slice(0,-1).some(b=>b.durationSec == null)) fail('Only the last camera beat can run to the end.')
if (promptSections.cameraBeats.reduce((sum,b)=>sum+(b.durationSec || 0),0)>duration) fail('Camera beats exceed the clip duration.')
if (raw.lockFace === false && raw.lockOutfit === false && !raw.imageAId && !raw.startClipId) fail('Choose a start still or previous clip when both identity locks are off.')
}
const frames=Math.ceil((duration*number('fps',24,1,60)-5)/17)*17+5
const shotSeeds=queueSeeds(raw.settings,1+(Array.isArray(raw.batch)?raw.batch.length:0))
return { mode, engine, shotSeeds, imageStyles:video?{positive:[],negative:[]}:normalizeStyles(raw.imageStyles), hideOutput:!!raw.hideOutput, lockFace:raw.lockFace !== false, lockOutfit:raw.lockOutfit !== false, hideInput:!!raw.hideInput, folderId:String(raw.folderId), identityStillId:raw.identityStillId || '', imageAId:raw.imageAId || '', imageBId:raw.imageBId || '', maskId:raw.maskId || '', startClipId:raw.startClipId || '', endStillId:raw.endStillId || '',
startFrameSource: raw.startClipId ? {kind:'previous-last-frame',clipId:raw.startClipId} : {kind:'still',stillId:raw.imageAId || ''},
promptSections, compiledPrompt:compilePrompt(promptSections,video,raw),
settings: { aspect:['auto','16:9','9:16','1:1'].includes(raw.settings?.aspect)?raw.settings.aspect:'auto', fps:number('fps',24,1,60), width:number('width',1344,256,4096),height:number('height',768,256,4096),steps:number('steps',video?8:24,1,100),cfg:number('cfg',video?1.5:4,0,20),seedMode:raw.settings?.seedMode==='locked'?'locked':'random',seed:shotSeeds[0],duration:number('duration',5,1,15),turbo:raw.settings?.turbo !== false, loraStack:Array.isArray(raw.settings?.loraStack)?raw.settings.loraStack:[] },
guides: (Array.isArray(raw.guides)?raw.guides:[]).slice(0,8).map(g=>{ if (!g.stillId || !Number.isInteger(g.frame) || g.frame<1 || g.frame>=frames) fail('Each guide requires a still and positive frame index.'); return {stillId:String(g.stillId),frame:g.frame} }),
batch: (Array.isArray(raw.batch)?raw.batch:[]).slice(0,30).map(s=>{const next=sectionsFrom({...promptSections,...(typeof s==='string'?{action:s}:s)});if(!next.action.trim())fail('Every shot needs an action prompt.');return video?next:{action:next.action}}) }
}
export function scopedFile(file, prefix) {
const clean = s => String(s || '').replaceAll('\\','/')
const path = [clean(file.subfolder),clean(file.filename)].filter(Boolean).join('/')
const p=clean(prefix).replace(/\/$/,'')
return !!p && !path.split('/').some(s=>s==='..'||s==='.') && !path.startsWith('/') && path.startsWith(p + '/')
}
+60
View File
@@ -0,0 +1,60 @@
export function nativeVideoGraph(template, request, files, prefix) {
const appearance = request.lockFace !== false || request.lockOutfit !== false
if (appearance && !files.hero) throw new Error('Identity still is required; never substitute the start frame.')
const g=structuredClone(template), s=request.settings
if (appearance) { g.hero.inputs.image=files.hero; g['155'].inputs['ref_images.ref_image_0']=['hero',0] }
else { delete g.hero; delete g['155'].inputs['ref_images.ref_image_0'] }
Object.assign(g['155'].inputs,{prompt:request.compiledPrompt,width:s.width,height:s.height,length:Math.ceil((s.duration*(s.fps || 24)-5)/17)*17+5,ref_image_size:'max'})
let conditioning=['155',0]
for (const [name,image,frame] of [['start',files.start,0],['end',files.end,-1],...(files.guides||[]).map((f,i)=>[`guide${i}`,f.image,f.frame])]) {
if (!image) continue
g[`load_${name}`]={class_type:'LoadImage',inputs:{image}}
g[name]={class_type:'MiniMaxH3AddGuide',inputs:{positive:conditioning,latent:['155',1],vae:['161',0],image:[`load_${name}`,0],frame_idx:frame}}
conditioning=[name,0]
}
g.guidance.inputs={conditioning,guidance:s.cfg}
g['171'].inputs.noise_seed=s.seed
g['172'].inputs.fps=s.fps || 24
g['179'].inputs.value=s.steps;g['180'].inputs.value=s.steps;g['181'].inputs.value=s.turbo
g['92'].inputs.filename_prefix=prefix+'/video'
g.anchor_frame={class_type:'ImageFromBatch',inputs:{image:['164',0],batch_index:g['155'].inputs.length-1,length:1}}
g.anchor_save={class_type:'SaveImage',inputs:{images:['anchor_frame',0],filename_prefix:prefix+'/anchor'}}
return g
}
export function attachHeroReference(g, image) {
if (!image) return g
const guider = g['18']; if (!guider) throw new Error('Klein reference conditioning is missing.')
g.hero_load={class_type:'LoadImage',inputs:{image}}
g.hero_scale={class_type:'ImageScaleToTotalPixels',inputs:{image:['hero_load',0],upscale_method:'lanczos',megapixels:1,resolution_steps:1}}
g.hero_encode={class_type:'VAEEncode',inputs:{pixels:['hero_scale',0],vae:['6',0]}}
for (const name of ['positive','negative']) {
if (!guider.inputs[name]) continue
g[`hero_${name}`]={class_type:'ReferenceLatent',inputs:{conditioning:guider.inputs[name],latent:['hero_encode',0]}}
guider.inputs[name]=[`hero_${name}`,0]
}
return g
}
export function sampleKleinSource(graph, denoise = .65) {
if (!graph['11'] || !graph['19'] || !graph['17']) throw new Error('Klein source sampling nodes are missing.')
graph['19'].inputs.latent_image=['11',0]
delete graph['14']
const steps=graph['17'].inputs.steps
graph['17'].inputs.steps=Math.ceil(steps/denoise)
graph.source_sigmas={class_type:'SplitSigmasDenoise',inputs:{sigmas:['17',0],denoise}}
graph['19'].inputs.sigmas=['source_sigmas',1]
return graph
}
/** The shared image builders have their own defaults and megapixel scalers.
* Studio 2 must send its already resolved canvas size through those nodes. */
export function applyResolvedImageSize(graph, {width,height}) {
for (const node of Object.values(graph)) {
if (node.class_type === 'ImageScaleToTotalPixels') {
node.class_type='ImageScale'
node.inputs={image:node.inputs.image,upscale_method:'lanczos',width,height,crop:'disabled'}
} else {
if (typeof node.inputs?.width === 'number') node.inputs.width=width
if (typeof node.inputs?.height === 'number') node.inputs.height=height
}
}
return graph
}
+69
View File
@@ -0,0 +1,69 @@
import {restoreLoras,loraUse} from './lora-memory.mjs'
import { restoreInput } from './contracts.mjs'
export const aspectOptions = [{id:'auto',label:'Automatic',hint:'Match start or hero still'},{id:'16:9',label:'16:9',hint:'1344 × 768 · padded'},{id:'9:16',label:'9:16',hint:'768 × 1344 · padded'},{id:'1:1',label:'1:1',hint:'960 × 960 · padded'}]
export function resolveSize(aspect, source, fallback = {width:1344,height:768}) {
const presets = {'16:9':[1344,768],'9:16':[768,1344],'1:1':[960,960]}
if (presets[aspect]) return {width:presets[aspect][0],height:presets[aspect][1]}
if (!source?.width || !source?.height) return {width:fallback.width || 1344,height:fallback.height || 768}
const factor = Math.min(1344/Math.max(source.width,source.height),768/Math.min(source.width,source.height))
return {width:Math.max(256,Math.round(source.width*factor/32)*32),height:Math.max(256,Math.round(source.height*factor/32)*32)}
}
export function bindClip(form, clip, xaigen = false, memory) {
if (!clip?.id) return form
const saved = clip.studio2 || clip.job || {}
const settings = saved.settings || clip.settings || {}
const next = {...form, startClipId:clip.id, mode:'extend', settings:{...form.settings}}
next.settings.aspect=['auto','16:9','9:16','1:1'].includes(settings.aspect)?settings.aspect:(form.settings?.aspect || 'auto')
for (const key of ['duration','width','height','fps','loraStack','turbo','steps','cfg']) {
if (settings[key] == null) continue
try { next.settings[key] = typeof structuredClone === 'function' ? structuredClone(settings[key]) : JSON.parse(JSON.stringify(settings[key])) }
catch { next.settings[key] = settings[key] }
}
next.engine = xaigen && (saved.engine || clip.engine)==='ltx' ? 'ltx' : 'minimax'
if (saved.identityStillId) next.identityStillId = saved.identityStillId
for (const key of ['lockFace','lockOutfit']) if (typeof saved[key] === 'boolean') next[key] = saved[key]
return restoreLoras(next,settings.loraStack,xaigen,memory)
}
export function restoreAsset(form, item, xaigen = false, memory) {
if (item.kind==='clip' || item.studio2?.kind==='video') {
const next=bindClip(form,item,xaigen,memory)
const seed=item.studio2?.settings?.seed ?? item.settings?.seed ?? item.seed
if (seed != null) next.settings.seed=seed
next.settings.seedMode='random'
return {...next,mode:'extend'}
}
const restored = restoreInput(item)
const next = {...form,...restored,settings:{...form.settings,...restored.settings}}
next.settings.seedMode='random'
next.engine=restored.engine==='krea'?'krea':'flux'
next.mode=['generate','edit','compose','refine','iterate'].includes(restored.mode)?restored.mode:'edit'
next.imageAId=item.id
next.startClipId=''
next.settings.aspect=item.studio2?.settings?.aspect || item.settings?.aspect || 'auto'
return restoreLoras(next,item.studio2?.settings?.loraStack ?? item.settings?.loraStack,xaigen,memory)
}
export function extendSelected(form, clip, xaigen = false, memory) {
const next = { ...form, settings:{...form.settings}, mode: 'extend', engine: xaigen && form.engine === 'ltx' ? 'ltx' : 'minimax' }
const target = clip?.id ? clip : null
if (!target?.id) return restoreLoras(next,loraUse(form)===loraUse(next)?form.settings?.loraStack:[],xaigen,memory)
// Already bound to this clip: switching task must not stomp the user's overrides
// (locks, CFG, etc.) by re-copying the clip's saved settings over them.
if (form.startClipId === target.id) return restoreLoras(next,loraUse(form)===loraUse(next)?form.settings?.loraStack:[],xaigen,memory)
try { return bindClip(next, target, xaigen, memory) }
catch { return { ...next, startClipId: target.id } }
}
export function changeSource(form) {
return form.mode==='extend' ? form : {...form,startClipId:'',settings:{...form.settings,aspect:'auto'}}
}
export function useAsHero(form, item) { return {...form,identityStillId:item.id} }
export function useAsSource(form, item) { return {...form,imageAId:item.id} }
export function restoreAll(form,item,xaigen=false,memory) {
const restored=restoreInput(item),next={...form,...restored,settings:{...form.settings,...restored.settings,seedMode:'random'}}
if(item.kind==='clip') return {...bindClip(next,item,xaigen,memory),mode:'extend'}
next.mode=restored.mode==='iterate'?'edit':restored.mode || 'edit'
next.engine=restored.engine==='krea'?'krea':'flux'
next.imageAId=item.id
next.startClipId=''
return restoreLoras(next,item.studio2?.settings?.loraStack ?? item.settings?.loraStack,xaigen,memory)
}
+37
View File
@@ -0,0 +1,37 @@
export function kleinIdentityPlan(request) {
if(request.engine!=='flux' || ['video','extend'].includes(request.mode)) return null
const sourceId=request.mode==='generate' ? request.identityStillId || request.imageAId || '' : request.imageAId || request.identityStillId || ''
const mode=['generate','iterate'].includes(request.mode) ? (sourceId?'edit':'generate') : request.mode
return {sourceId,mode}
}
/** Follow the live conditioning chain, including optional hero/compose references. */
function hasSourceReference(graph, link, referenceId) {
const seen=new Set()
while(Array.isArray(link) && !seen.has(String(link[0]))) {
const id=String(link[0]);seen.add(id)
const node=graph[id]
if(id===referenceId) return node?.class_type==='ReferenceLatent' && node.inputs.latent?.[0]==='11' && graph['11']?.class_type==='VAEEncode'
link=node?.inputs?.conditioning
}
return false
}
export function assertKleinSample(graph, sourceId) {
const sampler=graph['19']?.inputs
const latent=graph[sampler?.latent_image?.[0]]
if(latent?.class_type==='EmptyFlux2LatentImage') {
if(!sourceId) return 'empty'
const guider=graph[sampler.guider?.[0]]?.inputs
if(hasSourceReference(graph,guider?.positive,'12') && hasSourceReference(graph,guider?.negative,'13')) return 'empty+reference'
throw new Error('Klein source reference conditioning is missing from the sampler.')
}
// Refine retains its dedicated masked-source workflow.
if(latent?.class_type==='SetLatentNoiseMask' && latent.inputs.samples?.[0]==='11' && graph['11']?.class_type==='VAEEncode') return 'masked source'
throw new Error('Klein edit must sample empty latent with source references, not the source image latent.')
}
export function applyKleinIdentity(graph, plan) {
const sampleLatent=assertKleinSample(graph,plan.sourceId)
return {sampleLatent,sampleDenoise:sampleLatent==='masked source' ? graph['17']?.inputs.denoise ?? null : null}
}
+10
View File
@@ -0,0 +1,10 @@
export async function cachedLoras({read,write,live,fresh=false}) {
const cached=read() || {image:[],video:[]};
if (!fresh && (cached.image?.length || cached.video?.length)) return cached;
try {
const listed=await live();
const next={image:listed.image?.length?listed.image:cached.image||[],video:listed.video?.length?listed.video:cached.video||[]};
if(next.image.length || next.video.length) write(next);
return next;
} catch { return cached; }
}
+43
View File
@@ -0,0 +1,43 @@
import {filterLoraStackForUse} from '../../utils/loras.ts'
export function loraUse(form) {
const video=['video','extend'].includes(form.mode)
return video ? (form.engine==='ltx'?'ltx':'minimax') : (form.engine==='krea'?'krea':'flux')
}
// One controller per bench. Storage is optional (SSR/private browsing still works).
export function createLoraMemory(xaigen, storage) {
const stacks={}
let active=null
const key=`studio2-lora-stacks:${xaigen?'xaigen':'aigen'}`
try { Object.assign(stacks,JSON.parse(storage?.getItem(key)||'{}')) } catch {}
const load=use=>filterLoraStackForUse(stacks[use],use,xaigen)
const save=(use,stack)=>{
stacks[use]=filterLoraStackForUse(stack,use,xaigen)
try { storage?.setItem(key,JSON.stringify(stacks)) } catch {}
}
return {
sync(form) {
const use=loraUse(form)
if(active!==use) {
// Every stack edit is remembered by the bench watcher before a later switch.
active=use
form.settings.loraStack=load(use)
}
save(use,form.settings.loraStack)
},
restore(form,stack) {
const use=loraUse(form)
form.settings.loraStack=stack?.length ? filterLoraStackForUse(stack,use,xaigen) : load(use)
active=use
save(use,form.settings.loraStack)
return form
}
}
}
export function restoreLoras(form, stack, xaigen=false, memory) {
if(memory) return memory.restore(form,stack)
form.settings.loraStack=filterLoraStackForUse(stack,loraUse(form),xaigen)
return form
}
+40
View File
@@ -0,0 +1,40 @@
export const cameraMoves = ['hold','dolly-in','dolly-out','pan-left','pan-right','tilt-up','tilt-down','zoom-in','zoom-out','orbit-left','orbit-right']
export const soundscapeOptions = ['quiet room','distant birds','fabric rustle','footsteps','soft cloth','city hush','rain on glass','room tone only','no extra SFX']
export function normalizeBeat(beat = {}) {
let moves = [...new Set((beat.moves || []).filter(m => cameraMoves.includes(m)))]
if (moves.includes('hold') || !moves.length) moves = ['hold']
const duration = Number(beat.durationSec)
return { moves, amplitude: ['subtle','moderate','large'].includes(beat.amplitude) ? beat.amplitude : 'subtle', speed: ['slow','medium','fast'].includes(beat.speed) ? beat.speed : 'slow', durationSec: beat.durationSec == null ? null : Number.isFinite(duration) ? Math.max(.5, Math.round(duration * 2) / 2) : null }
}
export function toggleMove(beat, move) {
if (!cameraMoves.includes(move)) return normalizeBeat(beat)
if (move === 'hold') return normalizeBeat({...beat,moves:['hold']})
const moves = beat.moves.filter(m => m !== 'hold')
return normalizeBeat({...beat,moves:moves.includes(move) ? moves.filter(m => m !== move) : [...moves,move]})
}
export function sectionsFrom(value = {}) {
if (typeof value === 'string') value = {action:value}
value ||= {}
const s = Object.fromEntries(['action','camera','soundscape','music','locks'].map(k => [k,typeof value[k] === 'string' ? value[k] : '']))
const legacy = value.cameraChip
s.cameraBeats = Array.isArray(value.cameraBeats) ? value.cameraBeats.map(normalizeBeat) : legacy ? [normalizeBeat({...legacy,moves:[legacy.family === 'static' ? 'hold' : legacy.family.replaceAll(' ','-')]})] : []
s.soundscapeChips = [...new Set((value.soundscapeChips || []).filter(x => soundscapeOptions.includes(x)))]
s.soundscapeLevel = value.soundscapeLevel === 'crush' ? 'crush' : 'normal'
const d = typeof value.dialogue === 'object' && value.dialogue ? value.dialogue : {mode:value.dialogue ? 'spoken to camera' : 'none',line:value.dialogue || ''}
s.dialogue = {mode:['none','spoken to camera','spoken off-screen','whisper'].includes(d.mode) ? d.mode : 'none',language:String(d.language || 'English'),speaker:String(d.speaker || 'S1'),line:String(d.line || '')}
s.musicChip = ['N/A','none','low underscore'].includes(value.musicChip) ? value.musicChip : 'N/A'
return s
}
export function compileSections(value) {
const s = sectionsFrom(value)
const camera = s.cameraBeats.length ? ['One continuous take.', ...s.cameraBeats.map((b,i) => {
const move = b.moves[0] === 'hold' ? 'hold the camera locked on a tripod' : b.moves.map(m=>m.replaceAll('-',' ')).join(' and ') + (b.moves.length > 1 ? ' simultaneously' : '')
const timing = b.durationSec == null ? 'through the end of the remaining clip' : i === 0 ? `for the first ${b.durationSec} seconds` : `for ${b.durationSec} seconds`
return `${i ? 'Then' : 'First'}, ${move}, ${{subtle:'small',moderate:'medium',large:'large'}[b.amplitude]} amplitude, ${b.speed} speed, ${timing}.`
}), 'No cuts. No new setup.'].join(' ') : s.camera
const sound = [s.soundscapeChips.length ? `${s.soundscapeChips.join(', ')}.${s.soundscapeLevel === 'crush' ? ' Crushed soundscape dynamics.' : ''}` : '',s.soundscape].filter(Boolean).join(' ')
const d=s.dialogue, line=d.line.trim()
const dialogue = d.mode !== 'none' && line ? `${d.mode === 'spoken off-screen' ? 'Off-screen voice; on-screen lips remain closed. ' : ''}<Subject 1> (${d.speaker}) ${d.mode === 'whisper' ? 'whispers' : 'says'} <d>[${d.language}]${line}</d>` : ''
const music = s.music || (s.musicChip === 'N/A' ? 'non_diegetic_music: N/A' : s.musicChip === 'none' ? 'No non-diegetic music.' : 'A low non-diegetic musical underscore.')
return [s.action,camera && `Camera: ${camera}`,sound && `Soundscape: ${sound}`,dialogue,music,s.locks && `Locks: ${s.locks}`].filter(Boolean).join('\n\n')
}
+16
View File
@@ -0,0 +1,16 @@
const terminal = new Set(['complete','failed','cancelled'])
const running = new Set(['waking','submitting','rendering','saving'])
export function queueJobs(jobs) {
return jobs.filter(j=>running.has(j.state)||['waiting','queued','held'].includes(j.state)).sort((a,b)=>Number(running.has(b.state))-Number(running.has(a.state)) || (a.queuedAt||0)-(b.queuedAt||0))
}
export function jobView(record, queue, live) {
const statuses={complete:'complete',error:'failed',cancelled:'cancelled'}
const state=statuses[queue?.status] || (!live?.saving && statuses[live?.status]) || (queue?.status==='held'?'held':record.state)
const finishedAt=terminal.has(state)?record.finishedAt || (statuses[queue?.status]?queue.updatedAt:0) || (state==='complete'?record.savedAt:0) || record.updatedAt || record.startedAt || record.queuedAt:undefined
return {...record,state,finishedAt,error:record.error || queue?.lastError || live?.error}
}
export function elapsedSeconds(job, now) {
const start=job.startedAt || job.queuedAt
const end=terminal.has(job.state)?job.finishedAt || job.savedAt || job.updatedAt || start:now
return Number.isFinite(start)&&Number.isFinite(end)?Math.max(0,Math.floor((end-start)/1000)):0
}
+17
View File
@@ -0,0 +1,17 @@
let previous
export function randomSeed() {
const bytes = new Uint32Array(1)
globalThis.crypto.getRandomValues(bytes)
let seed = 2 + bytes[0] % 2147483646
if (seed === previous) seed = seed === 2147483647 ? 2 : seed + 1
previous = seed
return seed
}
export function queueSeeds(settings = {}, count = 1) {
if (settings.seedMode === 'locked') {
const seed=Number(settings.seed)
if (!Number.isInteger(seed) || seed<0 || seed>2147483647) throw Object.assign(new Error('Invalid seed.'),{statusCode:400})
return Array(count).fill(seed)
}
return Array.from({length:count},()=>randomSeed())
}
+11
View File
@@ -0,0 +1,11 @@
export function gpuStatus(jobs, health, upscales=[]) {
if (!health) return {label:'Status unavailable',tone:'unknown',detail:'Checking GPU connection…'}
const upscale=upscales.find(j=>j.status==='running')
if(upscale)return {label:'Upscaling',tone:'busy',detail:upscale.name || 'Video upscale'}
const job=jobs.find(j=>['waking','submitting','rendering','saving'].includes(j.state))
if(job)return {label:{waking:'Waking up',submitting:'Submitting',rendering:'Generating',saving:'Saving'}[job.state],tone:'busy',detail:job.request?.promptSections?.action || 'Generation in progress'}
if(health.comfy?.busy || health.imageComfy?.busy)return {label:'Busy',tone:'busy',detail:'GPU is working on another job'}
if(health.comfy?.ok || health.imageComfy?.ok)return {label:'Ready',tone:'ready',detail:'No job running'}
if(health.comfy?.asleep)return {label:'Sleeping',tone:'sleeping',detail:'Wakes when a job starts'}
return {label:'Offline',tone:'unknown',detail:'GPU connection unavailable'}
}
+5
View File
@@ -0,0 +1,5 @@
export const styleGroups={Render:['Photorealistic','Pixar','3D Disney','Comic','Watercolor','Oil painting'],Quality:['Realistic skin','Skin pores','Fine detail','Natural texture'],Lighting:['Golden hour','Soft studio','Window light','Overcast','Cinematic lighting']}
export function normalizeStyles(value={}) { const known=Object.values(styleGroups).flat();const positive=[...new Set((value.positive||[]).filter(s=>known.includes(s)))];return {positive,negative:[...new Set((value.negative||[]).filter(s=>known.includes(s)&&!positive.includes(s)))]} }
export function toggleStyle(value,chip,negative=false) { const next=normalizeStyles(value),key=negative?'negative':'positive',other=negative?'positive':'negative';next[other]=next[other].filter(s=>s!==chip);next[key]=next[key].includes(chip)?next[key].filter(s=>s!==chip):[...next[key],chip];return next }
export function stylePrompt(value,negative=false){return normalizeStyles(value)[negative?'negative':'positive'].join(', ')}
export function parseBatch(value){return String(value||'').split(/\r?\n\s*\r?\n/).map(s=>s.trim()).filter(Boolean)}
+8
View File
@@ -0,0 +1,8 @@
export function applyUploadedStill(form, response, target) {
const still=response?.still
if (!still?.id) throw new Error('The upload did not return a saved still. Please try again.')
const next={...form,settings:{...form.settings}}
if (target==='hero') next.identityStillId=still.id
else {next.imageAId=still.id;if(next.mode!=='extend'){next.startClipId='';next.settings.aspect='auto'}}
return next
}
+22
View File
@@ -0,0 +1,22 @@
export function folderAssets(library, jobs, folderId) {
const metadata=new Map(jobs.flatMap(j=>j.outputs || []).map(a=>[a.id,a.studio2]))
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) {
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 activeJobs(jobs) { return jobs.filter(j=>['waking','submitting','rendering','saving'].includes(j.state)) }
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 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)
}
+51
View File
@@ -0,0 +1,51 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {readFileSync} from 'node:fs'
import ts from 'typescript'
import {folderAssets,reconcileSelection,activeJobs,previewHidden} from '../shared/studio2/viewer.mjs'
import {useAsHero,useAsSource,restoreAll} from '../shared/studio2/input.mjs'
import {toggleStyle,parseBatch,stylePrompt} from '../shared/studio2/styles.mjs'
import {compilePrompt,validateRequest} from '../shared/studio2/contracts.mjs'
import {cachedLoras} from '../shared/studio2/lora-cache.mjs'
test('deleted library files never return from job outputs; selection falls back or clears',()=>{
const deleted={id:'gone',folderId:'f'},keep={id:'keep',folderId:'f',createdAt:2};const jobs=[{request:{folderId:'f'},outputs:[deleted]}]
let assets=folderAssets({stills:[keep],clips:[]},jobs,'f');assert.deepEqual(assets.map(a=>a.id),['keep']);assert.equal(reconcileSelection(deleted,assets).id,'keep');assert.equal(reconcileSelection(deleted,[]),null)
})
test('bench includes only active execution phases and clear finished retains internal cleanup records',()=>{
assert.deepEqual(activeJobs(['waiting','waking','submitting','rendering','saving','complete','cancelled','failed'].map(state=>({state}))).map(j=>j.state),['waking','submitting','rendering','saving'])
const rows=[{id:'done',state:'complete',request:{folderId:'f'},cleanupPending:[{}]},{id:'active',state:'rendering',request:{folderId:'f'}}],saved=[]
const src=readFileSync(new URL('../server/api/studio-2/clear-finished.post.ts',import.meta.url),'utf8').replace(/^import .*;\r?\n/gm,'').replace('export default ','const handler=')
const js=ts.transpileModule(src,{compilerOptions:{target:ts.ScriptTarget.ES2022}}).outputText
const fn=new Function('defineEventHandler','assertLibraryOwner','listStudioJobs','records','assertFolderAccess','saveRecord',js+';return handler')(f=>f,()=>({owner:'o'}),()=>[],()=>rows,()=>{},r=>saved.push(r))
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()=>{
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')
assert.match(src,/aria-label="Current family outputs"/)
assert.match(src,/preview hidden/)
assert.doesNotMatch(src,/Hide input stills/)
assert.doesNotMatch(src,/Hide output preview/)
})
test('cached LoRAs survive sleeping GPU and failed explicit refresh',async()=>{
const cache={image:['Klein/portrait.safetensors'],video:['motion.safetensors']};let queried=0
const opts={read:()=>cache,write:()=>assert.fail('Do not erase cache'),live:async()=>{queried++;throw Error('Comfy asleep')}}
assert.deepEqual(await cachedLoras(opts),cache);assert.equal(queried,0);assert.deepEqual(await cachedLoras({...opts,fresh:true}),cache);assert.equal(queried,1)
})
test('source/hero actions are independent and full input restores structured settings',()=>{
const form={identityStillId:'hero',imageAId:'source',promptSections:{action:'old'},settings:{}}
assert.equal(useAsHero(form,{id:'new'}).imageAId,'source');assert.equal(useAsSource(form,{id:'new'}).identityStillId,'hero')
const saved={mode:'edit',identityStillId:'h',imageAId:'s',imageStyles:{positive:['Comic'],negative:['Skin pores']},promptSections:{action:'New',cameraBeats:[{moves:['hold']}]},settings:{steps:24,loraStack:[{name:'example.safetensors',strengthModel:.4,strengthClip:.6}]}}
const restored=restoreAll(form,{id:'result',studio2:saved});assert.equal(restored.imageAId,'result');assert.equal(restored.identityStillId,'h');assert.deepEqual(restored.settings.loraStack,saved.settings.loraStack);assert.deepEqual(restored.imageStyles,saved.imageStyles);assert.equal(restored.promptSections.action,'New');const clip=restoreAll(form,{id:'clip',kind:'clip',studio2:{...saved,mode:'video',engine:'minimax'}});assert.equal(clip.startClipId,'clip');assert.equal(clip.mode,'extend');assert.equal(clip.promptSections.action,'New')
})
test('image chips compile positive and negative independently; video chips do not leak',()=>{
let styles=toggleStyle({},'Comic');styles=toggleStyle(styles,'Skin pores',true);styles=toggleStyle(styles,'Comic',true);assert.equal(stylePrompt(styles),'');assert.match(stylePrompt(styles,true),/Comic/)
styles=toggleStyle(styles,'Photorealistic');const q=validateRequest({mode:'edit',engine:'flux',folderId:'f',imageAId:'s',imageStyles:styles,promptSections:{action:'Restaurant',cameraBeats:[{moves:['hold']}],locks:'VIDEO ONLY'}})
assert.match(q.compiledPrompt,/Photorealistic/);assert.doesNotMatch(q.compiledPrompt,/Comic|pores|tripod|hold|VIDEO ONLY/i);assert.deepEqual(q.imageStyles,styles)
assert.doesNotMatch(compilePrompt(q.promptSections,false,q),/Picture 1/)
assert.deepEqual(parseBatch('A wrapped\nsentence\n\nSecond shot\r\n\r\nThird'),['A wrapped\nsentence','Second shot','Third'])
})
+34
View File
@@ -0,0 +1,34 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {folderAssets,newestOutput,selectResult} from '../shared/studio2/viewer.mjs'
import {restoreAsset,resolveSize} from '../shared/studio2/input.mjs'
import {validateRequest} from '../shared/studio2/contracts.mjs'
import {applyResolvedImageSize} from '../shared/studio2/graphs.mjs'
const base={mode:'generate',engine:'flux',folderId:'f',prompt:'Walk'}
test('completed outputs join all folder assets and select only the output canvas',()=>{
const source={id:'source',folderId:'f'},output={id:'result',kind:'still',studio2:{savedAt:2}}
const jobs=[{request:{folderId:'f'},outputs:[output]}],library={stills:[source,{id:'other',folderId:'f'},{id:'result',folderId:'f'}],clips:[{id:'video',folderId:'f'}]}
const strip=folderAssets(library,jobs,'f');assert.equal(strip.length,4)
const state=selectResult({imageAId:'source',startClipId:'existing'},newestOutput(jobs,new Set(),'f'))
assert.equal(state.selected.id,'result');assert.equal(state.imageAId,'source');assert.equal(state.startClipId,'existing')
assert.equal(newestOutput(jobs,new Set(['result']),'f'),null)
})
test('Use as input binds the selected still, not its original parent input',()=>{
const next=restoreAsset({settings:{}},{id:'output',kind:'still',studio2:{imageAId:'old-parent',settings:{aspect:'1:1',seed:1}}})
assert.equal(next.imageAId,'output');assert.equal(next.startClipId,'');assert.equal(next.settings.aspect,'1:1');assert.equal(next.settings.seedMode,'random')
})
test('resolved aspect survives image graph default and megapixel scaling',()=>{
for(const aspect of ['auto','1:1','16:9','9:16']) {
const size=resolveSize(aspect,{width:1024,height:1024})
const request=validateRequest({...base,settings:{aspect,...size}})
const graph=applyResolvedImageSize({latent:{inputs:{width:1344,height:768}},scale:{class_type:'ImageScaleToTotalPixels',inputs:{image:['load',0],megapixels:1}}},request.settings)
assert.equal(graph.latent.inputs.width,size.width);assert.equal(graph.latent.inputs.height,size.height)
assert.equal(graph.scale.inputs.crop,'disabled');assert.equal(graph.scale.inputs.width,size.width)
if(['auto','1:1'].includes(aspect))assert.equal(size.width,size.height)
}
})
test('random Queues and shots differ; typed seed including 1 remains locked',()=>{
const a=validateRequest({...base,batch:['Turn','Sit']}),b=validateRequest(base)
assert.notEqual(a.settings.seed,b.settings.seed);assert.equal(new Set(a.shotSeeds).size,3);assert.ok(a.shotSeeds.every(s=>s!==1))
for(const seed of [1,987]){const raw={...base,batch:['Turn'],settings:{seedMode:'locked',seed}};assert.deepEqual(validateRequest(raw).shotSeeds,[seed,seed]);assert.equal(validateRequest(raw).settings.seed,seed)}
})
+15
View File
@@ -0,0 +1,15 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import ts from 'typescript';
import {scopedFile} from '../shared/studio2/contracts.mjs';
const source=readFileSync(new URL('../server/utils/studio2/cleanup.ts',import.meta.url),'utf8').replace(/^import .*;\r?\n/gm,'').replace(/export /g,'');
function load(scope){const js=ts.transpileModule(source,{compilerOptions:{target:ts.ScriptTarget.ES2022}}).outputText;return new Function(...Object.keys(scope),js+';return {purge,retryCleanup}')( ...Object.values(scope));}
test('cleanup persists failures and retries only saved manifests after restart',async()=>{
const saved=[];let online=false,calls=[];
const file={filename:'hero.png',subfolder:'video/dev/studio2/job/0',type:'input'};
const r={id:'job',state:'complete',files:[file]};
const scope={scopedFile,useRuntimeConfig:()=>({comfyFilenamePrefix:'video/dev',comfyControlUrl:'http://local'}),sharedGpuHeaders:()=>({}),saveRecord:r=>saved.push(structuredClone(r)),records:()=>[r],withSharedGpuStart:async fn=>fn(),console:{warn(){}},fetch:async(url,opts)=>{calls.push(JSON.parse(opts.body));return {ok:online,status:online?200:404,json:async()=>({ok:online,cleared:1})}}};
await load(scope).purge(r);assert.equal(r.cleanupPending.length,1);assert.match(r.cleanupError,/404/);assert.equal(saved[0].cleanupPending.length,1);
r.files=[{...file,filename:'unsaved.png'}];online=true;await load(scope).retryCleanup();assert.deepEqual(calls[1].files,[file]);assert.deepEqual(r.cleanupPending,[]);assert.equal(r.files[0].filename,'unsaved.png');
});
+45
View File
@@ -0,0 +1,45 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import {compilePrompt,validateRequest,restoreInput} from '../shared/studio2/contracts.mjs'
import {normalizeBeat,toggleMove} from '../shared/studio2/prompt.mjs'
import {bindClip,restoreAsset,resolveSize,extendSelected} from '../shared/studio2/input.mjs'
import {nativeVideoGraph} from '../shared/studio2/graphs.mjs'
const base={mode:'video',engine:'minimax',folderId:'f',identityStillId:'hero',promptSections:{action:'Walk forward'}}
const beats=[normalizeBeat({moves:['tilt-down','dolly-out'],durationSec:2}),normalizeBeat({moves:['pan-left'],durationSec:null})]
test('sequential and simultaneous beats compile as one continuous take',()=>{
const text=compilePrompt({cameraBeats:beats},true)
assert.match(text,/tilt down and dolly out simultaneously/);assert.match(text,/first 2 seconds/);assert.match(text,/Then, pan left/);assert.match(text,/No cuts\. No new setup\./);assert.doesNotMatch(text,/\[Shot|Shot 2|00:02/)
})
test('hold is exclusive; toggling moves supports simultaneous motion',()=>{
const mixed=normalizeBeat({moves:['hold','pan-left']});assert.deepEqual(mixed.moves,['hold'])
const moved=toggleMove(toggleMove(mixed,'tilt-down'),'dolly-out');assert.deepEqual(moved.moves,['tilt-down','dolly-out']);assert.deepEqual(toggleMove(moved,'hold').moves,['hold'])
})
test('Use as input restores camera, sound, dialogue, music, LoRAs, aspect and locks',()=>{
const request=validateRequest({...base,lockFace:true,lockOutfit:false,hideInput:true,promptSections:{action:'Walk',cameraBeats:beats,soundscapeChips:['footsteps'],dialogue:{mode:'whisper',line:'Hello',language:'English'},musicChip:'none'},settings:{aspect:'9:16',width:768,height:1344,loraStack:[{name:'example.safetensors',strengthModel:.8,strengthClip:.4}]}})
const restored=restoreInput({studio2:request});assert.deepEqual(restored,request);assert.equal(restored.lockOutfit,false);assert.equal(restored.hideInput,true)
})
test('extend binds completed clip, inherits settings and leaves delta prompt alone',()=>{
const form={...base,lockFace:true,lockOutfit:false,settings:{duration:5,width:1344,height:768},promptSections:{action:'Turn',cameraBeats:beats}}
const clip={id:'done',kind:'clip',studio2:{...base,lockFace:true,lockOutfit:false,settings:{duration:8,width:768,height:1344,aspect:'9:16',fps:24,steps:8,cfg:1.5,turbo:false,loraStack:[{name:'example.safetensors',strengthModel:1,strengthClip:.5}]}}}
const bound=bindClip(form,clip);assert.equal(bound.startClipId,'done');assert.deepEqual(bound.settings,clip.studio2.settings);assert.equal(bound.promptSections.action,'Turn');assert.equal(bound.identityStillId,'hero');assert.equal(bound.lockOutfit,false)
assert.equal(restoreAsset(form,clip).startClipId,'done');assert.equal(bindClip(form,{id:'legacy'}).lockOutfit,false)
const overridden={...bound,lockOutfit:false,settings:{...bound.settings,cfg:2.5}}
const extend=extendSelected(overridden,{...clip,studio2:{...clip.studio2,lockOutfit:true}})
assert.equal(extend.lockOutfit,false);assert.equal(extend.settings.cfg,2.5);assert.equal(extend.startClipId,'done')
})
test('appearance roles distinguish wardrobe lock and both-off graph excludes hero',()=>{
assert.match(compilePrompt({},true,{lockOutfit:false}),/Do not restore an earlier outfit/)
assert.match(compilePrompt({},true),/person and clothes fully preserved/)
const r=validateRequest({...base,lockFace:false,lockOutfit:false,imageAId:'start'})
const template=JSON.parse(fs.readFileSync(new URL('../server/assets/studio2_minimax_native.json',import.meta.url)))
const graph=nativeVideoGraph(template,r,{hero:'ignored.png',start:'start.png'},'preview')
assert.equal(graph.hero,undefined);assert.equal(graph['155'].inputs['ref_images.ref_image_0'],undefined);assert.equal(graph.load_start.inputs.image,'start.png');assert.equal(r.identityStillId,'hero')
})
test('sound/dialogue/music compile without empty invented audio',()=>{
const empty=compilePrompt({},true);assert.doesNotMatch(empty,/Soundscape:|<d>/);assert.match(empty,/non_diegetic_music: N\/A/)
const text=compilePrompt({soundscapeChips:['footsteps'],dialogue:{mode:'spoken off-screen',line:'Hello'}},true);assert.match(text,/lips remain closed/);assert.match(text,/<Subject 1> \(S1\) says <d>\[English\]Hello<\/d>/)
})
test('aspect sizing follows source and named presets',()=>{
assert.deepEqual(resolveSize('9:16',{width:1344,height:768}),{width:768,height:1344});const size=resolveSize('auto',{width:768,height:1024});assert.equal(size.width/size.height,.75)
})
+143
View File
@@ -0,0 +1,143 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { createRequire } from 'node:module'
import { readFileSync, mkdtempSync, writeFileSync, existsSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import ts from 'typescript'
const require = createRequire(import.meta.url)
function load(path, resolve = require) {
const source = readFileSync(new URL(path, import.meta.url), 'utf8')
const code = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText
const exports = {}
new Function('require', 'exports', code)(resolve, exports)
return exports
}
const { runFfmpeg } = load('../server/utils/ffmpeg.ts', id => id.startsWith('node:') ? require(id) : {})
const { imageDimensions } = load('../server/utils/resolution.ts')
const { fitStill, needsFit } = load('../server/utils/studio2/media.ts', id =>
id.startsWith('node:') ? require(id) : id === '../ffmpeg' ? { runFfmpeg } : id === '../resolution' ? { imageDimensions } : {}
)
const { isValidAnchorFile } = load('../server/utils/extensionFrame.ts', id =>
id.endsWith('.json') ? { default: {} } : id.startsWith('node:') ? require(id) : {}
)
// A fake PNG carrying a real signature + IHDR width/height for imageDimensions() to read.
// This is not a decodable image; tests never run ffmpeg against it (fit is either skipped
// or mocked), they only need imageDimensions() to report the right size.
function fakePng(width, height) {
const buf = Buffer.alloc(64) // isValidAnchorFile requires >= 64 bytes
buf.set([0x89, 0x50, 0x4e, 0x47], 0) // PNG signature start
buf.writeUInt32BE(width, 16)
buf.writeUInt32BE(height, 20)
return buf
}
const PNG_32x32 = fakePng(32, 32)
// A 16:9 phone photo -- same aspect as the 1344x768 job canvas but a much larger pixel size.
// This is the exact shape of the regression: same-aspect-different-size must still be fit.
const PNG_PHONE_16x9 = fakePng(4032, 2268)
const CANVAS_16x9 = { width: 1344, height: 768 }
const text = readFileSync(new URL('../server/utils/studio2/runner.ts', import.meta.url), 'utf8')
const tree = ts.createSourceFile('runner.ts', text, ts.ScriptTarget.Latest, true)
function extractUpload(scope) {
const nodes = tree.statements.filter(n =>
(ts.isFunctionDeclaration(n) && n.name?.text === 'upload') ||
(ts.isVariableStatement(n) && n.getText(tree).startsWith('const NEVER_FIT'))
)
const js = nodes.map(n => ts.transpileModule(n.getText(tree), { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText).join('\n')
return new Function(...Object.keys(scope), `${js};return upload`)(...Object.values(scope))
}
function fixture() {
const uploaded = []
const record = { id: 'r', index: 0, files: [], request: { settings: { width: 32, height: 32 } } }
const scope = {
fitStill: async (data) => { throw new Error('fitStill must not be called for an already-canvas-sized handoff frame') },
needsFit,
saveRecord() {},
useRuntimeConfig: () => ({ comfyFilenamePrefix: 'preview/MiniMax_H3' }),
comfyFetch: async (_url, opts) => {
uploaded.push(opts.body)
return { ok: true, json: async () => ({ name: 'start.png', subfolder: 'preview/MiniMax_H3/studio2/r/0' }) }
},
scopedFile: () => true
}
return { scope, record, uploaded }
}
test('upload() never fits the start/hero labels when the still already matches the job canvas', async () => {
const f = fixture()
const upload = extractUpload(f.scope)
await upload(f.record, 'start', PNG_32x32)
assert.equal(f.record.handoffFitted, false)
assert.equal(f.record.files.length, 1)
})
test('upload() never fits the hero label even when it mismatches the canvas aspect', async () => {
const f = fixture()
f.record.request.settings = { width: 1344, height: 768 }
const upload = extractUpload(f.scope)
// PNG_32x32 is square, canvas is 16:9 -- would trigger a fit for any other label.
await upload(f.record, 'hero', PNG_32x32)
assert.equal(f.record.handoffFitted, undefined) // only tracked for 'start'
})
test('upload() still fits a source still whose aspect truly differs from the job canvas', async () => {
const f = fixture()
f.record.request.settings = { width: 1344, height: 768 }
let fitCalled = false
f.scope.fitStill = async (data) => { fitCalled = true; return data }
const upload = extractUpload(f.scope)
await upload(f.record, 'compose', PNG_32x32)
assert.equal(fitCalled, true)
})
test('needsFit is false only for an exact width+height match; same-aspect-different-size still needs fitting', () => {
assert.equal(needsFit(PNG_32x32, 32, 32), false)
assert.equal(needsFit(PNG_32x32, 1344, 768), true)
// Same 16:9 aspect as the canvas, but 3x the pixel size -- must still fit.
assert.equal(needsFit(PNG_PHONE_16x9, CANVAS_16x9.width, CANVAS_16x9.height), true)
assert.equal(needsFit(PNG_PHONE_16x9, 4032, 2268), false)
assert.equal(needsFit(Buffer.alloc(64), CANVAS_16x9.width, CANVAS_16x9.height), true) // unreadable dims always fit
})
test('New Video (mode video, no startClipId): a same-aspect phone still uploaded as "source" is fitted to the job canvas', async () => {
const f = fixture()
f.record.request.settings = { ...CANVAS_16x9 }
let fitCalled = false
f.scope.fitStill = async (data, w, h) => { fitCalled = true; assert.equal(w, CANVAS_16x9.width); assert.equal(h, CANVAS_16x9.height); return Buffer.alloc(64) }
const upload = extractUpload(f.scope)
await upload(f.record, 'source', PNG_PHONE_16x9)
assert.equal(fitCalled, true, 'a 4032x2268 still against a 1344x768 canvas must still be fitted despite matching aspect')
})
test('Extend (startClipId set): an anchor already sized to the job canvas uploaded as "start" is not fitted', async () => {
const f = fixture()
f.record.request.settings = { ...CANVAS_16x9 }
const anchorAtCanvasSize = fakePng(CANVAS_16x9.width, CANVAS_16x9.height)
const upload = extractUpload(f.scope) // f.scope.fitStill throws if called -- see fixture()
await upload(f.record, 'start', anchorAtCanvasSize)
assert.equal(f.record.handoffFitted, false)
})
test('two extends at identical width/height: the anchor PNG reaches Comfy byte-identical, no ffmpeg fit', async () => {
const dir = mkdtempSync(join(tmpdir(), 'studio2-anchor-'))
const anchorPath = join(dir, 'anchor-last.png')
writeFileSync(anchorPath, PNG_32x32)
assert.ok(isValidAnchorFile(anchorPath))
for (const hop of [0, 1]) {
const f = fixture()
const upload = extractUpload(f.scope)
const anchorBytes = readFileSync(anchorPath)
const key = await upload(f.record, 'start', anchorBytes)
assert.equal(f.record.handoffFitted, false, `hop ${hop} must not resample the anchor frame`)
const sentBody = f.uploaded[0]
const sentBlob = sentBody.get('image')
const sentBytes = Buffer.from(await sentBlob.arrayBuffer())
assert.deepEqual(sentBytes, anchorBytes, `hop ${hop} must upload the anchor PNG byte-identical`)
assert.ok(key.length > 0)
}
})
+76
View File
@@ -0,0 +1,76 @@
import {kleinIdentityPlan,applyKleinIdentity,assertKleinSample} from '../shared/studio2/klein-identity.mjs'
import {stylePrompt} from '../shared/studio2/styles.mjs'
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import ts from 'typescript'
import {validateRequest,compilePrompt} from '../shared/studio2/contracts.mjs'
import {attachHeroReference,applyResolvedImageSize} from '../shared/studio2/graphs.mjs'
import {extendSelected,changeSource,restoreAsset} from '../shared/studio2/input.mjs'
const template=JSON.parse(fs.readFileSync(new URL('../server/assets/klein_v2_edit.json',import.meta.url)))
const source=fs.readFileSync(new URL('../server/utils/studio2/runner.ts',import.meta.url),'utf8')
const tree=ts.createSourceFile('runner.ts',source,ts.ScriptTarget.Latest,true)
const fn=tree.statements.find(n=>ts.isFunctionDeclaration(n)&&n.name.text==='prepareGraph')
const js=ts.transpileModule(fn.getText(tree),{compilerOptions:{target:ts.ScriptTarget.ES2022}}).outputText
async function prepare(request){
const uploads=[]
const scope={stylePrompt,useRuntimeConfig:()=>({comfyFilenamePrefix:'preview'}),upload:async(r,label)=>{uploads.push(label);return label+'.png'},readFileSync:()=>Buffer.from('fixture'),stillPath:()=>'',getStill:(_,id)=>({id}),resolveRequestSize:async()=>{},saveRecord(){},
buildImageV2Workflow:params=>{const graph=JSON.parse(fs.readFileSync(new URL('../server/assets/klein_v2_'+params.mode+'.json',import.meta.url)));if(graph['1']) graph['1'].inputs.image=params.imageAName;return {graph}},kleinIdentityPlan,applyKleinIdentity,attachHeroReference,applyResolvedImageSize,ensureComfyLoraNames:async()=>{},resolveGraphLoraNames(){}}
const run=new Function(...Object.keys(scope),`${js};return prepareGraph`)(...Object.values(scope))
const record={request,owner:'o',id:'job',index:0};return {graph:await run(record),record,uploads}
}
for(const sourceId of ['hero','source','']) test(`Klein edit samples source with hero ${sourceId || 'as sole source'}`,async()=>{
const q=validateRequest({folderId:'f',mode:'edit',engine:'flux',imageAId:sourceId,identityStillId:'hero',lockFace:true,lockOutfit:false,prompt:'she at a pool'})
const {graph,record,uploads}=await prepare(q)
assert.deepEqual(graph['19'].inputs.latent_image,['14',0]);assert.equal(graph['14'].class_type,'EmptyFlux2LatentImage')
assert.deepEqual(graph['19'].inputs.sigmas,['17',0]);assert.equal(graph.source_sigmas,undefined)
assert.equal(record.sourceStillId,sourceId||'hero');assert.equal(record.sampleLatent,'empty+reference')
assert.equal(uploads.includes('hero'),sourceId==='source');assert.equal(!!graph.hero_load,sourceId==='source')
assert.match(q.compiledPrompt,/face, glasses, hair/);assert.match(q.compiledPrompt,/do not restore the hero outfit/)
})
test('image requests omit video sections while form keeps them',()=>{
const sections={action:'sitting in a restaurant',cameraBeats:[{moves:['hold']}],soundscapeChips:['footsteps'],dialogue:{mode:'whisper',line:'Hello'},musicChip:'low underscore'}
const q=validateRequest({folderId:'f',mode:'edit',engine:'flux',identityStillId:'hero',promptSections:sections})
assert.doesNotMatch(q.compiledPrompt,/tripod|hold|soundscape|music|Hello|Picture 1/i)
assert.deepEqual(q.promptSections,{action:sections.action});assert.equal(sections.cameraBeats.length,1)
assert.equal(compilePrompt(sections,false,{engine:'krea',identityStillId:'hero'}),sections.action)
})
test('Extend normalizes engine and inherits clip without replacing action or camera',()=>{
const form={mode:'edit',engine:'flux',imageAId:'still',promptSections:{action:'New action',cameraBeats:[{moves:['hold']}]},settings:{loraStack:[]}}
const clip={id:'clip',kind:'clip',studio2:{engine:'minimax',identityStillId:'hero',lockOutfit:false,promptSections:{action:'Old action'},settings:{duration:8,aspect:'1:1',width:960,height:960,fps:24,turbo:false,steps:8,cfg:1.5,loraStack:[{name:'example.safetensors',strengthModel:.6,strengthClip:.3}]}}}
const next=extendSelected(form,clip);assert.equal(next.engine,'minimax');assert.equal(next.startClipId,'clip');assert.deepEqual(next.settings,clip.studio2.settings)
assert.deepEqual(next.promptSections,form.promptSections);assert.equal(next.lockOutfit,false)
assert.equal(changeSource({...next,imageAId:'new-still'}).startClipId,'clip');assert.equal(changeSource(next).mode,'extend')
assert.deepEqual(restoreAsset(form,clip).promptSections,form.promptSections)
assert.equal(extendSelected({...form,startClipId:'clip'},null).startClipId,'clip')
assert.throws(()=>validateRequest({folderId:'f',mode:'extend',engine:'minimax',identityStillId:'hero',prompt:'Go'}),/Select the clip in the family strip or Previous clip/)
})
for (const imageAId of ['', 'hero', 'other']) test('Generate samples hero exclusively: '+imageAId,async()=>{
const q=validateRequest({folderId:'f',mode:'generate',engine:'flux',imageAId,identityStillId:'hero',lockFace:true,lockOutfit:false,prompt:'she at a pool',settings:{loraStack:[]}})
const {graph,record,uploads}=await prepare(q)
assert.equal(record.graphId,'klein_v2_edit.json')
assert.equal(record.sourceStillId,'hero')
assert.equal(record.sampleLatent,'empty+reference')
assert.equal(record.sampleDenoise,null)
assert.deepEqual(graph['19'].inputs.latent_image,['14',0])
assert.deepEqual(uploads,['source'])
assert.equal(graph.hero_load,undefined)
assert.deepEqual(q.settings.loraStack,[])
assert.doesNotMatch(q.compiledPrompt,/Picture 1/)
})
test('reference sampling ignores face-lock denoise; text-only uses empty latent',async()=>{
const q=validateRequest({folderId:'f',mode:'edit',engine:'flux',imageAId:'source',lockFace:false,prompt:'pool'})
assert.equal((await prepare(q)).record.sampleDenoise,null)
const empty=validateRequest({folderId:'f',mode:'generate',engine:'flux',prompt:'pool'})
assert.equal((await prepare(empty)).record.sampleLatent,'empty')
})
test('validate live references and reject direct source sampling',()=>{
assert.equal(assertKleinSample(template,'hero'),'empty+reference')
const broken=structuredClone(template);broken['18'].inputs.positive=['9',0]
assert.throws(()=>assertKleinSample(broken,'hero'),/reference conditioning is missing/)
const copy=structuredClone(template);copy['19'].inputs.latent_image=['11',0]
assert.throws(()=>assertKleinSample(copy,'hero'),/not the source image latent/)
assert.equal(kleinIdentityPlan({engine:'krea',mode:'generate',identityStillId:'hero'}),null)
assert.equal(kleinIdentityPlan({engine:'minimax',mode:'extend'}),null)
})
+19
View File
@@ -0,0 +1,19 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {readFileSync} from 'node:fs'
import ts from 'typescript'
import {loraIdentityKey,loraFriendlyName,loraTriggerFor} from '../utils/loras.ts'
test('Studio 2 uses production labels/triggers and sees later changes without a restart',()=>{
const source=readFileSync(new URL('../server/utils/studio2/lora-catalog.ts',import.meta.url),'utf8').replace(/^import .*;\r?\n/gm,'').replace('export function','function')
const js=ts.transpileModule(source,{compilerOptions:{target:ts.ScriptTarget.ES2022}}).outputText
let exported={loraAliases:{'XAIgen-klein_portrait.safetensors':'My portrait'},loraTriggers:{'xaigen-klein_portrait.safetensors':'portrait token'}}
const read=new Function('readFileSync','getLoraAliases','getLoraTriggers','loraIdentityKey','join','studio2Root',js+';return studio2LoraCatalog')(()=>{if(!exported)throw new Error('missing');return JSON.stringify(exported)},()=>({'klein_portrait.safetensors':'Old local name'}),()=>({}),loraIdentityKey,(...parts)=>parts.join('/'),()=>'/dev/studio2')
let catalog=read('owner')
assert.equal(loraFriendlyName('folder/xaigen-klein_portrait.safetensors',catalog.aliases),'My portrait')
assert.equal(loraTriggerFor('klein_portrait.safetensors',catalog.triggers),'portrait token')
exported.loraAliases['XAIgen-klein_portrait.safetensors']='Updated portrait'
assert.equal(loraFriendlyName('klein_portrait.safetensors',read('owner').aliases),'Updated portrait')
exported=null
assert.equal(loraFriendlyName('klein_portrait.safetensors',read('owner').aliases),'Old local name')
})
+19
View File
@@ -0,0 +1,19 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {readFileSync} from 'node:fs'
import * as Vue from 'vue'
import {compile} from '@vue/compiler-dom'
import {renderToString} from '@vue/server-renderer'
import {loraFriendlyName,loraIdentityKey} from '../utils/loras.ts'
test('LoRA dropdown renders one clean label or alias, with filename only in tooltip',async()=>{
const source=readFileSync(new URL('../components/LoraSelect.vue',import.meta.url),'utf8')
const option=source.match(/<option\s+v-for="name in availableNames"[\s\S]*?<\/option>/)[0]
const render=new Function('Vue',compile('<select>'+option+'</select>',{mode:'function',prefixIdentifiers:true}).code)(Vue)
const names=['xaigen-minimax csot.safetensors','folder/XAIgen-klein-portrait.safetensors']
const aliases={[loraIdentityKey(names[1])]:'Portrait style'}
const html=await renderToString(Vue.createSSRApp({render,setup:()=>({availableNames:names,filenameSubtitle:true,labelFor:name=>loraFriendlyName(name,aliases)})}))
const options=[...html.matchAll(/<option[^>]*title="([^"]*)"[^>]*>([^<]*)<\/option>/g)]
assert.deepEqual(options.map(match=>match[1]),names)
assert.deepEqual(options.map(match=>match[2]),['xaigen-minimax csot','Portrait style'])
})
+69
View File
@@ -0,0 +1,69 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {reactive,watch} from 'vue'
import {inferLoraUse,filterLorasForUse,filterLorasForStudio,filterLoraStackForUse} from '../utils/loras.ts'
import {createLoraMemory,loraUse} from '../shared/studio2/lora-memory.mjs'
import {restoreAll,restoreAsset,bindClip,extendSelected,useAsHero,useAsSource} from '../shared/studio2/input.mjs'
const stack=name=>[{name:name+'.safetensors',strengthModel:.6,strengthClip:.35}]
test('engine token matrix uses paths and names, omits unknown and system picker entries',()=>{
const cases={'xaigen-klein_car':'flux','klein/style':'flux','XAIgen-flux_test':'flux','Flux2-Klein/test':'flux','MiniMax/car':'minimax','folder/LTX_style':'ltx','xiagen-Krea_look':'krea','xaigen-krea/style':'krea','unlabelled':null}
for(const [name,use] of Object.entries(cases)) {
assert.deepEqual(inferLoraUse(name+'.safetensors'),use?[use]:null)
for(const current of ['flux','krea','minimax','ltx']) assert.equal(filterLorasForUse([name+'.safetensors'],current).length,use===current?1:0)
}
const restricted=['xaigen-klein.safetensors','XAIgen-MiniMax.safetensors','xiagen-Krea.safetensors']
assert.deepEqual(filterLorasForStudio(restricted,false),[])
assert.deepEqual(filterLoraStackForUse(stack('xaigen-klein'),'flux',false),[])
assert.deepEqual(filterLoraStackForUse(stack('LTX'),'ltx',false),[])
for(const [name,use] of [['minimax_h3_fl2v_turbo_8step_v1.0_comfyui_bf16','minimax'],['ltx-2.3-22b-distilled','ltx'],['Flux2-Klein-9B-consistency-V2','flux']]) assert.deepEqual(filterLorasForUse([name+'.safetensors'],use),[])
assert.deepEqual(filterLoraStackForUse(stack('unlabelled'),'flux',true),stack('unlabelled'))
})
function bench() {
const data=new Map(),storage={getItem:k=>data.get(k),setItem:(k,v)=>data.set(k,v)}
const memory=createLoraMemory(true,storage)
const form=reactive({mode:'generate',engine:'flux',settings:{loraStack:[]}})
const stop=watch(()=>[loraUse(form),form.settings.loraStack],()=>memory.sync(form),{deep:true,flush:'sync'})
memory.sync(form)
return {form,memory,storage,stop}
}
test('reactive task switches remember four independent stacks and both strengths',()=>{
const {form,memory,storage,stop}=bench()
form.settings.loraStack=stack('klein_car_bee')
form.settings.loraStack[0].strengthClip=.8
form.mode='video';form.engine='minimax'
assert.deepEqual(form.settings.loraStack,[])
form.settings.loraStack=stack('MiniMax_style')
form.engine='ltx';assert.deepEqual(form.settings.loraStack,[])
form.settings.loraStack=stack('LTX_style')
form.mode='edit';form.engine='flux'
assert.equal(form.settings.loraStack[0].name,'klein_car_bee.safetensors')
assert.equal(form.settings.loraStack[0].strengthClip,.8)
form.engine='krea';assert.deepEqual(form.settings.loraStack,[])
form.settings.loraStack=stack('Krea_style')
form.mode='video';form.engine='minimax'
assert.deepEqual(form.settings.loraStack,stack('MiniMax_style'))
Object.assign(form,useAsHero(form,{id:'image'}),useAsSource(form,{id:'source'}))
assert.deepEqual(form.settings.loraStack,stack('MiniMax_style'))
const fresh=createLoraMemory(true,storage),other={mode:'generate',engine:'krea',settings:{loraStack:[]}}
fresh.sync(other);assert.deepEqual(other.settings.loraStack,stack('Krea_style'))
stop()
})
test('Use as input and Extend copy saved stacks, filter wrong engines, and fall back only when saved empty',()=>{
const {form,memory,stop}=bench()
form.settings.loraStack=stack('klein_remembered')
const still={id:'still',kind:'still',studio2:{engine:'flux',mode:'edit',settings:{loraStack:[...stack('klein_saved'),...stack('MiniMax_wrong')]}}}
Object.assign(form,restoreAll(form,still,true,memory))
assert.deepEqual(form.settings.loraStack,stack('klein_saved'))
const clip={id:'clip',kind:'clip',studio2:{engine:'minimax',settings:{loraStack:[...stack('MiniMax_saved'),...stack('klein_wrong')]}}}
Object.assign(form,extendSelected(form,clip,true,memory))
assert.deepEqual(form.settings.loraStack,stack('MiniMax_saved'))
Object.assign(form,restoreAll(form,{...still,studio2:{...still.studio2,settings:{loraStack:[]}}},true,memory))
assert.deepEqual(form.settings.loraStack,stack('klein_saved'))
Object.assign(form,bindClip(form,{id:'empty',settings:{loraStack:[]}},true,memory))
assert.deepEqual(form.settings.loraStack,stack('MiniMax_saved'))
const legacy=restoreAsset(form,{id:'old-still',kind:'still',engine:'krea',settings:{loraStack:stack('Krea_saved')}},true,memory)
assert.equal(legacy.engine,'krea');assert.deepEqual(legacy.settings.loraStack,stack('Krea_saved'))
stop()
})
+19
View File
@@ -0,0 +1,19 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import ts from 'typescript'
test('progress accepts this client, rejects other prompts and closes on completion',()=>{
const source=fs.readFileSync(new URL('../server/utils/studio2/progress.ts',import.meta.url),'utf8')
const tree=ts.createSourceFile('progress.ts',source,ts.ScriptTarget.Latest,true)
const fn=tree.statements.find(n=>ts.isFunctionDeclaration(n)&&n.name.text==='watchProgress')
const code=ts.transpileModule(fn.getText(tree).replace(/^export /,''),{compilerOptions:{target:ts.ScriptTarget.ES2022}}).outputText
let socket,saves=0
class Socket { listeners={};constructor(){socket=this}addEventListener(name,fn){this.listeners[name]=fn}close(){this.closed=true} }
const watch=new Function('WebSocket','comfyWsUrl','saveRecord',`${code};return watchProgress`)(Socket,()=>'/fake',()=>saves++)
const record={promptId:'mine'},stop=watch(record,{clientId:'client'})
const send=data=>socket.listeners.message({data:JSON.stringify({type:'progress',data})})
send({prompt_id:'another',value:1,max:8});assert.equal(saves,0)
send({prompt_id:'mine',value:3,max:8});assert.equal(record.progress.step,3);assert.equal(record.progress.maxStep,8)
send({value:4,max:8});assert.equal(record.progress.percent,50)
stop();assert.equal(socket.closed,true)
})
+17
View File
@@ -0,0 +1,17 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {queueJobs,jobView,elapsedSeconds} from '../shared/studio2/queue-view.mjs'
test('queue shows every waiting job in order alongside the running job',()=>{
const jobs=[{id:'late',state:'waiting',queuedAt:30},{id:'done',state:'complete',queuedAt:1},{id:'early',state:'waiting',queuedAt:10},{id:'active',state:'rendering',queuedAt:20},{id:'held',state:'held',queuedAt:40}]
assert.deepEqual(queueJobs(jobs).map(j=>j.id),['active','early','late','held'])
assert.equal(queueJobs(Array.from({length:15},(_,i)=>({id:i,state:'waiting',queuedAt:i}))).length,15)
})
test('authoritative completion ends a stale rendering row and freezes its timer',()=>{
const record={state:'rendering',queuedAt:1000,startedAt:2000,updatedAt:5000}
for(const status of ['complete','error','cancelled']){
const result=jobView(record,{status,updatedAt:12000});assert.equal(elapsedSeconds(result,20000),10);assert.equal(elapsedSeconds(result,2000000),10);assert.equal(queueJobs([result]).length,0)
}
const live=jobView(record,null,{status:'complete'});assert.equal(live.state,'complete');assert.equal(elapsedSeconds(live,999999),3)
assert.equal(jobView(record,null,{status:'complete',saving:true}).state,'rendering')
assert.equal(elapsedSeconds({state:'complete',startedAt:2000,finishedAt:12000,updatedAt:200000},500000),10)
})
+65
View File
@@ -0,0 +1,65 @@
import {queueSeeds} from '../shared/studio2/seed.mjs'
import test from 'node:test'
import assert from 'node:assert/strict'
import {readFileSync,mkdtempSync,mkdirSync,writeFileSync,existsSync} from 'node:fs'
import {tmpdir} from 'node:os'
import {join} from 'node:path'
import ts from 'typescript'
import {compilePrompt,validateRequest} from '../shared/studio2/contracts.mjs'
import {purgeStudio2Files} from '../scripts/studio2-purge.mjs'
const text=readFileSync(new URL('../server/utils/studio2/runner.ts',import.meta.url),'utf8')
const tree=ts.createSourceFile('runner.ts',text,ts.ScriptTarget.Latest,true)
function runner(scope){const node=tree.statements.find(n=>ts.isFunctionDeclaration(n)&&n.name?.text==='run');const js=ts.transpileModule(node.getText(tree),{compilerOptions:{target:ts.ScriptTarget.ES2022}}).outputText;return new Function(...Object.keys(scope),`${js};return run`)(...Object.values(scope))}
function fixture(mode='video') {
const phases=[],queued=[],saved=[],purged=[]
const request=validateRequest({folderId:'f',mode,engine:mode==='video'?'minimax':'flux',identityStillId:'hero',promptSections:{action:'walk'},batch:['turn','sit']})
const record={id:'r',owner:'o',request,outputs:[],queuedAt:1,startedAt:2,familyId:'family'}
const job={status:'running',clientId:'client',library:{}}
let id=0
const scope={
queueSeeds,watchProgress:()=>()=>{},compilePrompt,saveRecord(){},update(r,state){r.state=state;phases.push(state)},acquireSharedGpu:async()=>true,ensureComfyReady:async()=>{},emitJob(){},
prepareGraph:async r=>{queued.push({hero:r.request.identityStillId,start:r.request.startClipId,action:r.request.promptSections.action});return {}},queuePrompt:async()=>({prompt_id:`p${++id}`}),
fetchHistory:async p=>({[p]:{status:{status_str:'success'},outputs:{}}}),comfyFetch:()=>{throw new Error('Unexpected network call')},
extractVideo:()=>({filename:'video.mp4',subfolder:'preview/studio2/r',type:'output'}),extractEditedImage:()=>({filename:'image.png',subfolder:'preview/studio2/r',type:'output'}),
downloadComfyVideo:async()=>Buffer.alloc(128,'v'),downloadComfyImage:async()=>Buffer.from('image'),imageDimensions:()=>({width:960,height:960}),
videoSourcePaths:()=>['source'],clipVideoPath:()=>'/fake/source',studio2Root:()=>'/fake',join,mkdirSync(){},writeFileSync(){},unlinkSync(){},probeDuration:async()=>5,stitchExtension:async()=>Buffer.alloc(128,'s'),
saveClip:async p=>{const a={id:`asset${saved.length}`,...p};saved.push(a);return a},saveStill:async p=>{const a={id:`asset${saved.length}`,...p};saved.push(a);return a},
attachStudio2Metadata:async(owner,id,data)=>{assert.ok(saved.some(a=>a.id===id));assert.equal(data.identityStillId,'hero')},
purge:async r=>{assert.ok(saved.length>purged.length);purged.push(r.index);r.purgeResult='Cleared input + output'},
getClip:()=>saved.at(-1),getStill:()=>saved.at(-1),persistClipAnchorFrame:async()=>{},onLiveVideoSettled:async()=>{}
}
return {scope,record,job,phases,queued,saved,purged}
}
for(const mode of ['video','iterate']) test(`${mode} runner saves each output before purge and keeps identity through the batch`,async()=>{
const f=fixture(mode);await runner(f.scope)(f.record,f.job)
assert.equal(f.record.state,'complete');assert.equal(f.saved.length,3);assert.equal(f.purged.length,3)
assert.deepEqual(f.queued.map(q=>q.hero),['hero','hero','hero']);assert.deepEqual(f.queued.map(q=>q.action),['walk','turn','sit'])
if(mode==='video')assert.deepEqual(f.queued.map(q=>q.start),['','asset0','asset1'])
assert.deepEqual(f.phases.slice(0,4),['waking','submitting','rendering','saving'])
})
test('failed library save never purges host media',async()=>{
const f=fixture();f.scope.saveClip=async()=>{throw new Error('Disk full')};await runner(f.scope)(f.record,f.job);assert.equal(f.record.state,'failed');assert.equal(f.purged.length,0)
})
test('an empty Comfy video output fails the job with a real error and never saves or attaches a clip',async()=>{
const f=fixture();f.scope.downloadComfyVideo=async()=>Buffer.alloc(0)
await runner(f.scope)(f.record,f.job)
assert.equal(f.record.state,'failed');assert.match(f.record.error,/empty video file/);assert.equal(f.saved.length,0);assert.equal(f.record.outputs.length,0)
})
test('a stitched extension with no readable duration fails the job and never saves or attaches a clip',async()=>{
const f=fixture('video');f.scope.probeDuration=async()=>NaN
await runner(f.scope)(f.record,f.job)
assert.equal(f.record.state,'failed');assert.match(f.record.error,/no readable duration/);assert.equal(f.saved.length,0);assert.equal(f.record.outputs.length,0)
})
test('resume uses persisted Comfy prompt and never submits it again',async()=>{
const f=fixture();f.record.request.batch=[];f.record.promptId='existing';f.record.files=[];f.record.index=0
f.scope.queuePrompt=async()=>{throw new Error('Duplicate submit')};await runner(f.scope)(f.record,f.job)
assert.equal(f.record.state,'complete');assert.equal(f.queued.length,0);assert.equal(f.saved.length,1)
})
test('strict purge leaves production and root fallback files intact',()=>{
const root=mkdtempSync(join(tmpdir(),'studio2-purge-')),prefix='video/MiniMax_H3_preview',subfolder=prefix+'/studio2/job/0'
mkdirSync(join(root,subfolder),{recursive:true});mkdirSync(join(root,'video/MiniMax_H3'),{recursive:true})
for(const path of ['same.png','video/MiniMax_H3/same.png',subfolder+'/same.png'])writeFileSync(join(root,path),'image')
const result=purgeStudio2Files({prefix,files:[{filename:'same.png',subfolder,type:'output'}]},()=>[root])
assert.equal(result.cleared,1);assert.equal(existsSync(join(root,subfolder,'same.png')),false);assert.ok(existsSync(join(root,'same.png')));assert.ok(existsSync(join(root,'video/MiniMax_H3/same.png')))
assert.throws(()=>purgeStudio2Files({prefix,files:[{filename:'same.png',subfolder:'video/MiniMax_H3',type:'output'}]},()=>[root]),/outside/)
})
+23
View File
@@ -0,0 +1,23 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {validateHeaderValue} from 'node:http'
import {gpuStatus} from '../shared/studio2/status.mjs'
import {inlineFilename} from '../shared/content-disposition.mjs'
import {readFileSync} from 'node:fs'
test('waiting records never replace the idle GPU status after completion',()=>{
const waiting={state:'waiting',request:{promptSections:{action:'Old waiting prompt'}}},running={state:'rendering',request:{promptSections:{action:'Current video'}}},health={comfy:{ok:true}};
assert.equal(gpuStatus([waiting,running],health).detail,'Current video');
assert.deepEqual(gpuStatus([waiting,{...running,state:'complete'}],health),{label:'Ready',tone:'ready',detail:'No job running'});
assert.equal(gpuStatus([waiting],health,[{status:'running',name:'Upscale current clip'}]).label,'Upscaling');
assert.equal(gpuStatus([waiting],{comfy:{asleep:true}}).label,'Sleeping');
});
test('Unicode video titles produce valid response headers and retain UTF-8 filename',()=>{
for(const title of ['A scene… “hello” — 🎥.mp4','日本語.mp4','bad\r\nheader".mp4']){
const header=inlineFilename(title);assert.doesNotThrow(()=>validateHeaderValue('Content-Disposition',header));assert.match(header,/filename\*=UTF-8''/);assert.doesNotMatch(header,/[\r\n]/);
}
assert.match(inlineFilename('Scene….mp4'),/%E2%80%A6/);
});
test('Studio 2 upscale button delegates progress to the queue without stretching actions',()=>{
const bench=readFileSync(new URL('../components/studio2/Bench.vue',import.meta.url),'utf8'),control=readFileSync(new URL('../components/VideoUpscaleControl.vue',import.meta.url),'utf8');
assert.match(bench,/:clip-id="selected.id" queue-only/);assert.match(bench,/items-start gap-3/);assert.match(bench,/Job queue/);assert.match(control,/state\?\.job && !queueOnly/);
});
+14
View File
@@ -0,0 +1,14 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {applyUploadedStill} from '../shared/studio2/upload.mjs'
test('uploaded source is selected without changing hero or output',()=>{
const form={imageAId:'old',identityStillId:'hero',selected:'result',startClipId:'clip',mode:'extend',settings:{aspect:'16:9'}}
const next=applyUploadedStill(form,{still:{id:'uploaded'}},'source')
assert.equal(next.imageAId,'uploaded');assert.equal(next.identityStillId,'hero');assert.equal(next.selected,'result');assert.equal(next.startClipId,'clip');assert.equal(next.mode,'extend');assert.equal(next.settings.aspect,'16:9')
})
test('uploaded hero changes identity only and missing saves are reported',()=>{
const form={identityStillId:'old',imageAId:'source',settings:{aspect:'1:1'}}
const next=applyUploadedStill(form,{still:{id:'hero'}},'hero')
assert.equal(next.identityStillId,'hero');assert.equal(next.imageAId,'source');assert.equal(next.settings.aspect,'1:1')
assert.throws(()=>applyUploadedStill(form,{},'hero'),/did not return a saved still/)
})
+20
View File
@@ -0,0 +1,20 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import {validateRequest,compilePrompt,restoreInput,scopedFile} from '../shared/studio2/contracts.mjs'
import {nativeVideoGraph} from '../shared/studio2/graphs.mjs'
const template=JSON.parse(fs.readFileSync(new URL('../server/assets/studio2_minimax_native.json',import.meta.url)))
const base={folderId:'folder',mode:'video',engine:'minimax',identityStillId:'hero',promptSections:{action:'walk'}}
test('ten shots retain hero as Picture 1 while each start frame changes',()=>{
for(let i=0;i<10;i++) {const r=validateRequest({...base,startClipId:'clip'+i});const g=nativeVideoGraph(template,r,{hero:'hero.png',start:`last${i}.png`},'preview/job');assert.equal(g.hero.inputs.image,'hero.png');assert.deepEqual(g['155'].inputs['ref_images.ref_image_0'],['hero',0]);assert.equal(g.load_start.inputs.image,`last${i}.png`);assert.equal(r.identityStillId,'hero');assert.equal(g['155'].inputs.ref_image_size,'max')}
})
test('compose without B and refine without mask are 400',()=>{
for(const mode of ['compose','refine']) assert.throws(()=>validateRequest({...base,mode,engine:'flux',imageAId:'a'}),e=>e.statusCode===400)
})
test('Use as input round-trips camera chip and never invents one for flat text',()=>{
const r=validateRequest({...base,promptSections:{action:'walk',cameraChip:{family:'dolly in',amplitude:'subtle',speed:'slow'}}}); assert.deepEqual(restoreInput({studio2:r}).promptSections,r.promptSections);assert.equal(restoreInput({prompt:'old text'}).promptSections.cameraChip,undefined);assert.match(compilePrompt(r.promptSections,true),/dolly in/)
})
test('purge accepts only exact preview path boundary',()=>{
assert.equal(scopedFile({subfolder:'video/MiniMax_H3_preview/job',filename:'a.png'},'video/MiniMax_H3_preview'),true)
for(const subfolder of ['video/MiniMax_H3','video/MiniMax_H3_preview_other','video/MiniMax_H3_preview/../MiniMax_H3']) assert.equal(scopedFile({subfolder,filename:'a.png'},'video/MiniMax_H3_preview'),false)
})
+41
View File
@@ -0,0 +1,41 @@
"""CPU-only YuE2 worker regression tests; never import torch or run inference."""
import importlib.util
import os
from pathlib import Path
import unittest
from types import SimpleNamespace
spec = importlib.util.spec_from_file_location('yue2_worker', Path(__file__).parents[1] / 'scripts/yue2-worker.py')
worker = importlib.util.module_from_spec(spec)
spec.loader.exec_module(worker)
class WorkerTests(unittest.TestCase):
def test_lyrics_allow_multiple_sections_and_wrap_plain_text(self):
multi = '[Verse 1]\nHello\n\n[Chorus]\nSing it'
self.assertEqual(worker.normalize_lyrics(multi), multi + '\n')
self.assertEqual(worker.normalize_lyrics('[Verse 1]\nHello'), '[Verse 1]\nHello\n')
self.assertEqual(worker.normalize_lyrics('No heading yet'), '[song]\nNo heading yet\n\n')
with self.assertRaises(ValueError):
worker.normalize_lyrics('')
with self.assertRaises(ValueError):
worker.normalize_lyrics(' ')
def test_attention_backend_never_selects_flash(self):
torch_mod = SimpleNamespace(
cuda=SimpleNamespace(is_available=lambda: True),
backends=SimpleNamespace(cudnn=SimpleNamespace(is_available=lambda: True)),
)
attention, pipeline = worker.resolve_attention_backend(torch_mod)
self.assertEqual(attention, 'sdpa')
self.assertEqual(pipeline, 'torch-eager')
self.assertNotEqual(attention, 'flash')
self.assertTrue(os.environ.get('PYTORCH_CUDA_ALLOC_CONF', '').startswith('expandable_segments'))
def test_cuda_oom_detection(self):
self.assertTrue(worker.is_cuda_oom(RuntimeError('CUDA out of memory. Tried to allocate 2.49 GiB')))
self.assertFalse(worker.is_cuda_oom(RuntimeError('bad lyrics')))
if __name__ == '__main__':
unittest.main()
+100
View File
@@ -0,0 +1,100 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { EventEmitter } from 'node:events'
import { PassThrough } from 'node:stream'
import { mkdtempSync, writeFileSync, readFileSync, existsSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import ts from 'typescript'
import { createYue2Host, validateYue2Request } from '../scripts/yue2-host.mjs'
import { createYueGpHost, validateYueGpRequest } from '../scripts/yuegp-host.mjs'
const request = { id: 'test-song-123456', tags: 'pop, warm vocals', lyrics: '[Verse 1]\nA quiet morning, a new day', seed: 0 }
test('YuE2 defaults are 60 seconds and allow multiple lyric sections', () => {
assert.deepEqual(validateYue2Request(request), { ...request, duration: 60 })
assert.deepEqual(
validateYue2Request({ ...request, lyrics: '[Verse]\nA\n[Chorus]\nB' }).lyrics,
'[Verse]\nA\n[Chorus]\nB'
)
assert.throws(() => validateYue2Request({ ...request, lyrics: '' }))
assert.throws(() => validateYue2Request({ ...request, duration: 20 }))
assert.throws(() => validateYue2Request({ ...request, id: '../escape' }))
})
test('yue still validates as YuEGP and stays separate from yue2', () => {
assert.equal(validateYueGpRequest(request).profile, 1)
assert.equal(validateYue2Request(request).duration, 60)
assert.notEqual(createYueGpHost.name, createYue2Host.name)
})
function fixture() {
const root = mkdtempSync(join(tmpdir(), 'aigen-yue2-'))
const python = join(root, 'python.exe')
writeFileSync(python, '')
writeFileSync(join(root, 'aigen-ready.json'), JSON.stringify({ model: 'm-a-p/YuE2-3B', vae: 'm-a-p/YuE2-Vae' }))
const child = new EventEmitter()
child.stdout = new PassThrough(); child.stderr = new PassThrough()
child.kill = () => { setImmediate(() => child.emit('close', 1)); return true }
const calls = []
const dataDir = join(root, 'jobs')
const host = createYue2Host({ root, python, dataDir, prepare: async () => {}, leaseValid: () => true,
spawnProcess: (...args) => { calls.push(args); return child } })
return { host, child, calls, root, dataDir }
}
test('host launches yue2-worker, preserves seed 0 and lyrics, and deduplicates POST retries', async () => {
const f = fixture()
await f.host.start(request, 'lease')
await f.host.start(request, 'lease')
assert.equal(f.calls.length, 1)
const [, args, opts] = f.calls[0]
assert.ok(String(args[1]).endsWith('yue2-worker.py'))
assert.equal(opts.windowsHide, true)
assert.equal(opts.shell, false)
const posted = JSON.parse(readFileSync(join(f.dataDir, request.id, 'request.json')))
assert.equal(posted.seed, 0)
assert.equal(posted.lyrics, request.lyrics)
assert.equal(posted.model, 'm-a-p/YuE2-3B')
assert.equal(posted.vae, 'm-a-p/YuE2-Vae')
await assert.rejects(f.host.start({ ...request, id: 'second-song-12345' }, 'lease'), /already running/)
await f.host.cancel(request.id)
assert.equal(f.host.busy(), false)
assert.equal(f.host.read(request.id).status, 'cancelled')
})
test('OOM stays on yue2 with no engine switch', async () => {
const f = fixture()
await f.host.start(request, 'lease')
f.child.stdout.write('AIGEN_EVENT {"stage":"error","error":"CUDA out of memory"}\n')
f.child.emit('close', 1)
assert.match(f.host.read(request.id).error, /out of memory/)
assert.equal(f.calls.length, 1)
})
test('music workflow refuses yue and yue2 while ACE graphs still build', () => {
const source = readFileSync(new URL('../server/utils/musicWorkflow.ts', import.meta.url), 'utf8')
const code = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText
const exports = {}
new Function('require', 'exports', 'useRuntimeConfig', code)(id => {
if (id.endsWith('.json')) return { default: JSON.parse(readFileSync(new URL(`../server/assets/${id.split('/').pop()}`, import.meta.url))) }
if (id === '~/utils/music') return { parseMusicBpm: () => 120 }
throw new Error(id)
}, exports, () => ({}))
const params = { tags: 'pop', lyrics: '[Verse]\nhello', duration: 60, steps: 8, seed: 0, cfg: 1, lyricsStrength: 0.9 }
assert.throws(() => exports.buildMusicWorkflow({ ...params, engine: 'yue' }), /standalone YuEGP/)
assert.throws(() => exports.buildMusicWorkflow({ ...params, engine: 'yue2' }), /standalone YuE2/)
for (const engine of ['ace-step', 'ace-step-1.5']) {
const graph = exports.buildMusicWorkflow({ ...params, engine })
assert.ok(Object.values(graph).some(n => n.class_type === 'KSampler' && n.inputs.seed === 0))
}
assert.equal(existsSync(new URL('../server/assets/workflow_yue.json', import.meta.url)), false)
})
test('musicChain is YuE2-only', () => {
const source = readFileSync(new URL('../server/utils/musicChain.ts', import.meta.url), 'utf8')
assert.match(source, /startYue2Job/)
assert.match(source, /from '\.\/yue2'/)
assert.doesNotMatch(source, /startYueGpJob/)
assert.doesNotMatch(source, /buildMusicWorkflow/)
})
-4
View File
@@ -5,14 +5,10 @@ import type { ImageV2PresetSettings } from '~/utils/imageV2'
export type GenerationPresetKind = 'video' | 'image' | 'imagev2' | 'music'
export type MusicPresetSettings = {
yueProfile?: 1 | 3
tags?: string
lyrics?: string
instrumental?: boolean
duration?: number
steps?: number
seed?: number
cfg?: number
engine?: import('~/utils/music').MusicEngine
}
+10 -1
View File
@@ -299,9 +299,18 @@ export function trackSettingsRows(track: {
}) {
const rows: SettingsRow[] = []
if (track.engine) {
const engine = String(track.engine)
rows.push({
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() })
+19 -13
View File
@@ -32,7 +32,7 @@ export function loraIdentityKey(name: string) {
export function isXaigenOnlyLora(name: string) {
const base = loraBasename(name)
// Include the common `xiagen-` typo so those files stay off AIGen too.
return /^(xai|xia)gen/i.test(base) || /klein_snofs|snofs_krea/i.test(base)
return String(name || '').replace(/\\/g, '/').split('/').some(part => /^(xai|xia)gen/i.test(part)) || /klein_snofs|snofs_krea/i.test(base)
}
/** Pick the real Comfy filename: exact path, then same basename ignoring `xaigen-`, then suffix/contains. */
@@ -293,22 +293,28 @@ export function isSystemGraphLora(name: string) {
return false
}
/** Filename hints. `null` means the LoRA can appear in any picker. */
/** Engine tokens in paths or basenames; unlabelled files are not picker options. */
export function inferLoraUse(name: string): LoraUse[] | null {
const imageEngine = imageLoraEngineOf(name)
if (imageEngine) return [imageEngine]
const id = loraIdentityKey(name)
if (/(^|[_-])ltx([_-]|$)|ltx-2/i.test(id)) return ['ltx']
if (/minimax|h3_fl2v|fl2v_turbo/i.test(id)) return ['minimax']
return null
const value = String(name || '').toLowerCase()
const uses: LoraUse[] = []
if (/minimax/.test(value)) uses.push('minimax')
if (/ltx/.test(value)) uses.push('ltx')
if (/klein|flux/.test(value)) uses.push('flux')
if (/krea/.test(value)) uses.push('krea')
return uses.length ? uses : null
}
export function loraMatchesUse(name: string, use: LoraUse) {
if (isSystemGraphLora(name)) return false
if (use === 'flux' || use === 'krea') return imageLoraEngineOf(name) === use
const inferred = inferLoraUse(name)
if (!inferred) return true
return inferred.includes(use)
return !isSystemGraphLora(name) && !!inferLoraUse(name)?.includes(use)
}
/** Saved stacks may contain unlabelled adapters; never carry a known wrong engine. */
export function filterLoraStackForUse(stack: unknown, use: LoraUse, xaigen: boolean) {
if (use === 'ltx' && !xaigen) return []
return filterLoraStackForStudio(stack, xaigen).filter(item => {
const uses = inferLoraUse(item.name)
return !isSystemGraphLora(item.name) && (!uses || uses.includes(use))
})
}
export function filterLorasForUse(names: string[], use: LoraUse) {
+10 -17
View File
@@ -1,5 +1,5 @@
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_STEPS_MIN = 8
export const MUSIC_STEPS_MAX = 100
@@ -33,24 +33,18 @@ export const DEFAULT_MUSIC_LYRICS = `[Verse 1]
[Outro]
`
export type MusicEngine = 'ace-step' | 'ace-step-1.5' | 'yue'
export type MusicEngine = 'yue2'
export function parseMusicEngine(raw: unknown): MusicEngine {
const value = String(raw || '').trim().toLowerCase()
if (value === 'ace-step-1.5' || value === 'ace15' || value === 'v1.5') return 'ace-step-1.5'
if (value === 'yue' || value === 'yue-s1' || value === 'lyrics2song') return 'yue'
return 'ace-step'
}
export function isAceMusicEngine(engine: MusicEngine | string | undefined) {
return parseMusicEngine(engine) !== 'yue'
if (value === 'yue2' || value === 'yue-2' || !value) return 'yue2'
// Legacy ACE / YuEGP values map to YuE2 — the only supported music engine.
return 'yue2'
}
export function musicEngineLabel(engine: MusicEngine | string | undefined) {
const value = parseMusicEngine(engine)
if (value === 'ace-step-1.5') return 'ACE-Step 1.5'
if (value === 'yue') return 'YuE'
return 'ACE-Step'
void engine
return 'YuE2'
}
export function parseMusicBpm(tags: unknown, fallback = 120) {
@@ -94,18 +88,17 @@ export function sanitizeMusicLyrics(raw: unknown, instrumental = false) {
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) {
const text = String(lyrics || '')
const matches = text.match(/\[[^\]]+\]/g)
return matches ? matches.length : 0
}
/** YuE2 allows multiple lyric sections; only empty lyrics are rejected. */
export function yueLyricsProblem(lyrics: unknown): string | null {
const text = String(lyrics || '').trim()
if (!text) return 'YuE needs lyrics with section tags like [Verse 1] and [Chorus].'
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.'
if (!text) return 'YuE2 needs lyrics. Use section tags like [Verse 1] and [Chorus] when you want structure.'
return null
}