Compare commits

...
4 Commits
22 changed files with 1024 additions and 6 deletions
+6
View File
@@ -62,3 +62,9 @@ IMAGE_COMFY_LAUNCH_CMD=
# NUXT_SESSION_SECRET=<generate a new preview-only secret> # NUXT_SESSION_SECRET=<generate a new preview-only secret>
# Same Beast Comfy 8198 / control 8199; one shared GPU, distinct purge prefix. # 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. # 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.
+87
View File
@@ -0,0 +1,87 @@
<template>
<main class="mx-auto max-w-7xl space-y-5 p-4 md:p-8">
<header class="flex flex-wrap items-center justify-between gap-4">
<div><h1 class="font-display text-3xl font-bold">Studio 2</h1><p class="text-sm text-zinc-400">One hero. Every shot.</p></div>
<div class="flex flex-wrap items-center gap-3">
<label class="text-sm">Folder <select v-model="form.folderId" class="rounded-lg bg-zinc-800 p-2"><option v-for="f in library.folders" :value="f.id" :key="f.id">{{ f.name }}</option></select></label>
<label class="text-sm">Hero identity <select v-model="form.identityStillId" class="max-w-52 rounded-lg bg-zinc-800 p-2"><option value="">Choose hero</option><option v-for="s in stills" :key="s.id" :value="s.id">{{ s.name || s.filename }}</option></select></label>
<img v-if="form.identityStillId" :src="stillUrl(form.identityStillId)" class="h-12 w-12 rounded-lg object-cover" alt="Sticky hero identity">
<NuxtLink to="/library" class="text-sm text-amber-200">Library</NuxtLink><NuxtLink to="/" class="text-sm text-zinc-400">Studio 1</NuxtLink>
</div>
</header>
<nav class="flex flex-wrap gap-2"><button v-for="task in tasks" :key="task" class="rounded-full border px-4 py-2 capitalize" :class="form.mode===task?'border-amber-300 bg-amber-300 text-black':'border-white/15'" @click="chooseTask(task)">{{ task }}</button></nav>
<div class="grid gap-6 lg:grid-cols-[1.3fr_1fr]">
<section class="space-y-4">
<div class="flex min-h-80 items-center justify-center overflow-hidden rounded-3xl border border-white/10 bg-black">
<video v-if="selected?.kind==='clip'" :src="`/api/library/clips/${selected.id}/video`" controls class="max-h-[65vh] w-full" />
<img v-else-if="selected" :src="stillUrl(selected.id)" class="max-h-[65vh] object-contain" alt="Current output">
<img v-else-if="form.imageAId" :src="stillUrl(form.imageAId)" class="max-h-[65vh] object-contain" alt="Source composition">
<p v-else class="text-zinc-500">Your next scene starts here</p>
</div>
<div v-if="selected" class="flex gap-3"><button class="rounded-full border border-white/20 px-4 py-2 text-sm" @click="restore(selected)">Use as input</button><button class="rounded-full border border-white/20 px-4 py-2 text-sm" @click="detailsOpen=true">Details</button><VideoUpscaleControl v-if="selected.kind==='clip'" :clip-id="selected.id" /></div>
<div class="flex gap-2 overflow-x-auto" aria-label="Current family outputs"><button v-for="o in family" :key="o.id" @click="selected=o" class="shrink-0 rounded-lg border p-1" :class="selected?.id===o.id?'border-amber-300':'border-white/10'"><img :src="o.kind==='clip'?`/api/library/clips/${o.id}/thumb`:stillUrl(o.id)" class="h-20 w-24 rounded object-cover" alt="Family output"></button></div>
<div class="grid gap-3 sm:grid-cols-2">
<label>Source / start still<select v-model="form.imageAId" class="mt-1 w-full rounded-xl bg-zinc-800 p-2"><option value="">None</option><option v-for="s in stills" :key="s.id" :value="s.id">{{ s.name || s.filename }}</option></select></label>
<label v-if="form.mode==='compose'">Second still<select v-model="form.imageBId" class="mt-1 w-full rounded-xl bg-zinc-800 p-2"><option value="">Required</option><option v-for="s in stills" :key="s.id" :value="s.id">{{ s.name || s.filename }}</option></select></label>
<label v-if="form.mode==='refine'">Mask<select v-model="form.maskId" class="mt-1 w-full rounded-xl bg-zinc-800 p-2"><option value="">Required: white area to refine</option><option v-for="s in stills" :key="s.id" :value="s.id">{{ s.name || s.filename }}</option></select></label>
<label v-if="form.mode==='extend'">Previous clip<select v-model="form.startClipId" class="mt-1 w-full rounded-xl bg-zinc-800 p-2"><option value="">Choose a clip</option><option v-for="c in clips" :key="c.id" :value="c.id">{{ c.name || c.prompt?.slice(0,50) }}</option></select></label>
<label v-if="video">Optional end still<select v-model="form.endStillId" class="mt-1 w-full rounded-xl bg-zinc-800 p-2"><option value="">None</option><option v-for="s in stills" :key="s.id" :value="s.id">{{ s.name || s.filename }}</option></select></label>
<label class="text-sm">Add still to folder<input type="file" accept="image/*" class="mt-2 block text-xs" @change="upload"></label>
</div>
<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"><select v-model="g.stillId" class="min-w-0 flex-1 bg-zinc-800 p-2"><option value="">Choose guide</option><option v-for="s in stills" :key="s.id" :value="s.id">{{ s.name || s.filename }}</option></select><input v-model.number="g.frame" type="number" min="1" aria-label="Guide frame" class="w-20 bg-zinc-800 p-2"><button @click="form.guides.splice(i,1)">Remove</button></div><button class="mt-3 text-amber-200" @click="form.guides.push({stillId:'',frame:24})">Add guide</button></details>
</section>
<section class="space-y-4">
<div class="flex justify-between"><h2 class="text-xl font-semibold">Prompt</h2><button class="text-sm text-amber-200" @click="advanced=!advanced">{{ advanced?'Simple':'Advanced' }}</button></div>
<label class="block">Action<textarea v-model="form.promptSections.action" rows="4" class="mt-1 w-full rounded-xl bg-zinc-900 p-3" placeholder="What happens in this shot?" /></label>
<fieldset v-if="video" class="space-y-2"><legend>Camera</legend><div class="flex flex-wrap gap-2"><button v-for="c in cameraFamilies" :key="c" type="button" class="rounded-full border px-3 py-1 text-xs" :class="form.promptSections.cameraChip?.family===c?'border-amber-300 text-amber-200':'border-white/15'" @click="camera(c)">{{ c==='static'?'Hold / tripod':c }}</button></div><div v-if="form.promptSections.cameraChip" class="flex gap-2"><select v-model="form.promptSections.cameraChip.amplitude" class="rounded bg-zinc-800 p-2"><option v-for="a in ['subtle','moderate','large']" :key="a">{{ a }}</option></select><select v-model="form.promptSections.cameraChip.speed" class="rounded bg-zinc-800 p-2"><option v-for="a in ['slow','medium','fast']" :key="a">{{ a }}</option></select><button @click="delete form.promptSections.cameraChip">Clear camera</button></div></fieldset>
<template v-for="section in ['camera','soundscape','dialogue','music','locks']" :key="section"><label v-if="(video || section==='locks') && (section!=='camera' || advanced)" class="block capitalize">{{ section }}<textarea v-model="form.promptSections[section]" :rows="section==='locks'?2:1" class="mt-1 w-full rounded-xl bg-zinc-900 p-3" /></label></template>
<details class="rounded-xl border border-white/10 p-3"><summary>{{ video?'More shots':'Iteration prompts' }}</summary><textarea v-model="batchText" rows="5" class="mt-2 w-full rounded-xl bg-zinc-900 p-3" placeholder="One action prompt per line. Hero stays attached to every output." /></details>
<button class="text-sm text-amber-200" @click="settingsOpen=true">Generation settings</button>
<p v-if="error" role="alert" class="text-sm text-red-300">{{ error }}</p>
<button :disabled="submitting" @click="queue" class="w-full rounded-2xl bg-amber-300 p-4 font-semibold text-black disabled:opacity-50">{{ submitting?'Queuing…':'Queue' }}</button>
<div v-for="job in jobs.slice(0,4)" :key="job.id" class="rounded-xl border border-white/10 p-3 text-sm"><div class="flex justify-between"><button @click="focusJob(job)">{{ job.request.mode }} · {{ job.state }}</button><button v-if="!['complete','failed','cancelled'].includes(job.state)" @click="cancel(job)">Cancel</button></div><p v-if="job.error" class="text-red-300">{{ job.error }}</p></div>
<details v-if="advanced"><summary>Compiled prompt</summary><pre class="whitespace-pre-wrap text-xs text-zinc-400">{{ compiled }}</pre></details>
</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 class="float-right" @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"><template v-if="video"><option value="minimax">MiniMax H3</option></template><template v-else><option value="flux">Flux Klein 9B Base</option><option value="krea" :disabled="['edit','compose','iterate'].includes(form.mode)">Krea 2 (Generate / Refine)</option></template></select></label><label class="mb-4 block">Aspect<select :value="form.settings.width===form.settings.height?'1:1':form.settings.width>form.settings.height?'16:9':'9:16'" @change="size(($event.target as HTMLSelectElement).value)" class="mt-1 w-full rounded bg-zinc-800 p-2"><option>16:9</option><option>9:16</option><option>1:1</option></select></label><label v-for="key in ['width','height','steps','cfg','seed',...(video?['duration']:[])]" :key="key" class="mb-4 block capitalize">{{ key }}<input v-model.number="form.settings[key]" type="number" class="mt-1 w-full rounded bg-zinc-800 p-2"></label><label v-if="video"><input v-model="form.settings.turbo" type="checkbox"> Turbo</label><fieldset v-if="advanced" class="mt-5"><legend>LoRAs</legend><div v-for="(l,i) in form.settings.loraStack" :key="i" class="mt-2 space-y-2"><input v-model="l.name" placeholder="Installed LoRA name" class="w-full rounded bg-zinc-800 p-2"><input v-model.number="l.strengthModel" aria-label="LoRA strength" type="number" min="0" max="2" step="0.05" class="w-24 rounded bg-zinc-800 p-2"><button @click="form.settings.loraStack.splice(i,1)">Remove</button></div><button class="mt-2 text-amber-200" @click="form.settings.loraStack.push({name:'',strengthModel:1,strengthClip:1})">Add LoRA</button></fieldset></template>
<template v-else><h2 class="mb-6 text-xl font-semibold">Output details</h2><Studio2Details :metadata="selected?.studio2" /></template>
</aside>
</div>
</main>
</template>
<script setup lang="ts">
import {cameraFamilies,compilePrompt,sectionsFrom,restoreInput} from '~/shared/studio2/contracts.mjs'
const library=ref<any>({folders:[],stills:[],clips:[]}),jobs=ref<any[]>([]),selected=ref<any>(null),family=ref<any[]>([])
const form=reactive<any>({mode:'generate',engine:'flux',folderId:'',identityStillId:'',imageAId:'',imageBId:'',maskId:'',startClipId:'',endStillId:'',guides:[],promptSections:sectionsFrom({}),settings:{width:1344,height:768,steps:24,cfg:4,seed:1,duration:5,turbo:true,loraStack:[]}})
const route=useRoute()
const tasks=['generate','edit','compose','refine','iterate','video','extend'],advanced=ref(false),settingsOpen=ref(false),detailsOpen=ref(false),submitting=ref(false),error=ref(''),batchText=ref('')
const video=computed(()=>['video','extend'].includes(form.mode)),stills=computed(()=>library.value.stills.filter((s:any)=>s.folderId===form.folderId)),clips=computed(()=>library.value.clips.filter((s:any)=>s.folderId===form.folderId)),compiled=computed(()=>compilePrompt(form.promptSections,video.value))
const stillUrl=(id:string)=>`/api/library/stills/${id}`
function chooseTask(task:string){const was=video.value;form.mode=task;if(video.value!==was){form.engine=video.value?'minimax':'flux';form.settings.steps=video.value?8:24;form.settings.cfg=video.value?1.5:4}}
function camera(family:string){form.promptSections.cameraChip={family,amplitude:'subtle',speed:'slow'}}
function size(aspect:string){const [w,h]=aspect==='9:16'?[768,1344]:aspect==='1:1'?[960,960]:[1344,768];form.settings.width=w;form.settings.height=h}
function restore(item:any){const restored=restoreInput(item);Object.assign(form,restored);form.guides=restored.guides||[];batchText.value=''}
function focusJob(job:any){family.value=job.outputs||[];selected.value=family.value.at(-1)||null}
async function queue(){submitting.value=true;error.value='';try{await $fetch('/api/studio-2/jobs',{method:'POST',body:{...form,batch:batchText.value.split('\n').map(s=>s.trim()).filter(Boolean)}});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){const file=(event.target as HTMLInputElement).files?.[0];if(!file)return;const body=new FormData();body.append('image',file);body.append('folderId',form.folderId);try{library.value=await $fetch('/api/library/stills',{method:'POST',body})}catch(e:any){error.value=e.data?.statusMessage||e.message}}
async function refresh(){
jobs.value=await $fetch<any[]>('/api/studio-2/jobs')
const latest=jobs.value.find(j=>j.request.folderId===form.folderId&&j.outputs?.length)
if(latest&&!selected.value){focusJob(latest);return}
const current=jobs.value.find(j=>j.outputs?.some((o:any)=>family.value.some(f=>f.id===o.id)))
if(current&&current.outputs.some((o:any)=>!family.value.some(f=>f.id===o.id))){
const following=selected.value?.id===family.value.at(-1)?.id
family.value=current.outputs
if(following)selected.value=family.value.at(-1)
library.value=await $fetch('/api/library?light=1')
}
}
watch(()=>form.folderId,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>,disposed=false
onMounted(async()=>{library.value=await $fetch('/api/library?light=1');form.folderId=library.value.folders[0]?.id||'';const id=String(route.query.still||route.query.clip||'');const asset=[...library.value.stills,...library.value.clips].find((a:any)=>a.id===id);if(asset){selected.value={...asset,kind:route.query.clip?'clip':'still'};family.value=[selected.value];if(route.query.use==='1')restore(asset)};const poll=async()=>{try{await refresh()}catch(e:any){error.value=e.data?.statusMessage||e.message}if(!disposed)timer=setTimeout(poll,3000)};void poll()})
onBeforeUnmount(()=>{disposed=true;clearTimeout(timer)})
</script>
+19
View File
@@ -0,0 +1,19 @@
<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:'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:'Identity still',value:m.identityStillId||'None'},{label:'Start frame',value:m.startFrameSource?JSON.stringify(m.startFrameSource):'None'},
{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>
+1 -1
View File
@@ -53,7 +53,7 @@ export default defineNuxtConfig({
authMode: process.env.NUXT_AUTH_MODE || process.env.AUTH_MODE || '', authMode: process.env.NUXT_AUTH_MODE || process.env.AUTH_MODE || '',
authUsername: process.env.AUTH_USERNAME || '', authUsername: process.env.AUTH_USERNAME || '',
authPassword: process.env.AUTH_PASSWORD || '', 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', backupStudioToken: process.env.BACKUP_STUDIO_TOKEN || 'carr-aigen-xaigen-backup',
backupIngestToken: process.env.BACKUP_INGEST_TOKEN || 'carr-aigen-xaigen-backup', backupIngestToken: process.env.BACKUP_INGEST_TOKEN || 'carr-aigen-xaigen-backup',
purgeComfyOutputs: process.env.PURGE_COMFY_OUTPUTS !== 'false', purgeComfyOutputs: process.env.PURGE_COMFY_OUTPUTS !== 'false',
+10
View File
@@ -1074,6 +1074,11 @@ async function onMoveFocused(event: Event) {
} }
function openInStudio(entry: LibraryEntry) { 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) { if (entry.kind === 'clip' && entry.group) {
navigateTo({ path: '/', query: { clip: focusedPartId.value || entry.group.head.id } }) navigateTo({ path: '/', query: { clip: focusedPartId.value || entry.group.head.id } })
return return
@@ -1088,6 +1093,11 @@ function openInStudio(entry: LibraryEntry) {
} }
function useInStudio(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) { if (entry.kind === 'clip' && entry.group) {
navigateTo({ path: '/', query: { clip: focusedPartId.value || entry.group.head.id, use: '1' } }) navigateTo({ path: '/', query: { clip: focusedPartId.value || entry.group.head.id, use: '1' } })
return return
+7
View File
@@ -0,0 +1,7 @@
<template>
<Studio2Bench v-if="config.public.studio !== 'xaigen'" />
<main v-else class="mx-auto max-w-xl p-8"><h1 class="text-2xl font-semibold">Studio 2 preview</h1><p class="mt-3">Studio 2 is AIGen-first. Your LTX studio remains available.</p><NuxtLink to="/" class="mt-4 inline-block text-amber-200">Open xAIGen</NuxtLink></main>
</template>
<script setup lang="ts">
const config = useRuntimeConfig()
</script>
+7
View File
@@ -1,4 +1,5 @@
import { createUpscaleHost } from './upscale-host.mjs' import { createUpscaleHost } from './upscale-host.mjs'
import { purgeStudio2Files } from './studio2-purge.mjs'
import { stableMemoryArgs } from './comfy-memory-policy.mjs' import { stableMemoryArgs } from './comfy-memory-policy.mjs'
import { createGpuReservation } from './gpu-reservation.mjs' import { createGpuReservation } from './gpu-reservation.mjs'
import { createGpuProxy } from './gpu-proxy.mjs' import { createGpuProxy } from './gpu-proxy.mjs'
@@ -989,6 +990,12 @@ async function handleControl(req, res) {
markWork() markWork()
return streamFile(res, path) 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') { if (req.method === 'POST' && url.pathname === '/purge') {
const body = await readJson(req) const body = await readJson(req)
return json(res, 200, purgeDesktopFiles(body)) return json(res, 200, purgeDesktopFiles(body))
+26
View File
@@ -0,0 +1,26 @@
import { resolve, relative, isAbsolute } from 'node:path'
import { existsSync, realpathSync, statSync, unlinkSync } from 'node:fs'
import { scopedFile } from '../shared/studio2/contracts.mjs'
/** 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)]}
}
+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 }; });
+9
View File
@@ -0,0 +1,9 @@
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 => { try {
assertFolderAccess(event, r.request.folderId);
return true;
}
catch {
return false;
} }).map(r => { const q = rows.find(q => q.id === r.queueId); return { ...r, state: q?.status === 'cancelled' ? 'cancelled' : q?.status === 'error' ? 'failed' : r.state, error: r.error || q?.lastError }; }); });
+31
View File
@@ -0,0 +1,31 @@
import { validateRequest } from '~/shared/studio2/contracts.mjs';
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);
if (useRuntimeConfig().public.studio === 'xaigen')
throw createError({ statusCode: 400, statusMessage: 'Studio 2 is currently an AIGen preview. Continue using the existing LTX studio on xAIGen.' });
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');
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);
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 };
});
+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
View File
@@ -0,0 +1,2 @@
import { resumeStudio2Jobs } from '../utils/studio2/runner';
export default defineNitroPlugin(() => { resumeStudio2Jobs(); });
+1
View File
@@ -32,6 +32,7 @@ export interface JobEvent {
} }
export interface Job { export interface Job {
studio2?: boolean
upscale?: boolean upscale?: boolean
yueGp?: boolean yueGp?: boolean
musicActivity?: { checkedAt: number; running: boolean } musicActivity?: { checkedAt: number; running: boolean }
+7
View File
@@ -35,6 +35,7 @@ export interface LibraryFolder {
} }
export interface LibraryClip { export interface LibraryClip {
studio2?: Record<string, any>
id: string id: string
folderId: string folderId: string
name: string name: string
@@ -95,6 +96,7 @@ export interface LibraryTrack {
export type StillRole = 'input' | 'output' export type StillRole = 'input' | 'output'
export interface LibraryStill { export interface LibraryStill {
studio2?: Record<string, any>
id: string id: string
folderId: string folderId: string
filename: string filename: string
@@ -270,6 +272,7 @@ function normalizeStill(still: LibraryStill): LibraryStill {
manualGroupIndex: still.manualGroupIndex, manualGroupIndex: still.manualGroupIndex,
parentStillId: still.parentStillId, parentStillId: still.parentStillId,
chainIndex: still.chainIndex, chainIndex: still.chainIndex,
studio2: still.studio2,
settings: normalizeStillSettings(still.settings) settings: normalizeStillSettings(still.settings)
} }
} }
@@ -2211,3 +2214,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 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 })
}
+274
View File
@@ -0,0 +1,274 @@
import { readFileSync, mkdirSync, existsSync, unlinkSync } from 'node:fs';
import { join } from 'node:path';
import template from '../../assets/studio2_minimax_native.json';
import { nativeVideoGraph, attachHeroReference } 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 } 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(); saveRecord(r); }
async function upload(r: any, name: string, data: Buffer): Promise<string> {
const prefix = String(useRuntimeConfig().comfyFilenamePrefix).replace(/\/$/, '') + `/studio2/${r.id}/${r.index}`;
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 purge(r: any) {
const config = useRuntimeConfig(), prefix = String(config.comfyFilenamePrefix);
const files = (r.files as DiskFile[]).filter(file => scopedFile(file, prefix));
r.purgeResult = 'Left on host';
if (files.length !== r.files.length || !files.length)
return;
try {
const response = await fetch(String(config.comfyControlUrl).replace(/\/$/, '') + '/studio2/purge', {
method: 'POST', headers: { 'Content-Type': 'application/json', ...sharedGpuHeaders() },
body: JSON.stringify({ prefix, files }), signal: AbortSignal.timeout(12000)
});
const result = await response.json() as any;
if (response.ok && result.ok && result.cleared === files.length)
r.purgeResult = 'Cleared input + output';
}
catch { /* Keep the durable library copy and report unconfirmed host cleanup. */ }
}
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 hero = await load(q.identityStillId, 'hero'), a = await load(q.imageAId, 'source'), b = await load(q.imageBId, 'compose'), mask = await load(q.maskId, 'mask');
let graph: any;
if (['video', 'extend'].includes(q.mode)) {
if (q.engine !== 'minimax')
throw new Error('Studio 2 native identity video currently requires MiniMax. Use the existing xAIGen studio for LTX.');
let start = a;
if (q.startClipId) {
const dir = join(studio2Root(), r.id);
mkdirSync(dir, { recursive: true });
const dest = join(dir, 'handoff.png');
await resolveExtensionHandoffFrame({ ownerKey: r.owner, sourceClipId: q.startClipId, sourceVideoPath: clipVideoPath(r.owner, q.startClipId), destPath: dest });
start = await upload(r, 'start', readFileSync(dest));
unlinkSync(dest);
}
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 = 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, imageAName: a, imageBName: mode === 'compose' ? b : undefined, maskName: mode === 'refine' ? mask : undefined, filenamePrefix: prefix + '/image' }).graph;
if (q.engine === 'flux')
attachHeroReference(graph, hero);
else if (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) {
try {
const prompts = r.prompts || [r.request.promptSections, ...r.request.batch];
r.prompts = prompts;
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.files = [];
r.request.promptSections = prompts[r.index];
r.request.compiledPrompt = compilePrompt(prompts[r.index], ['video', 'extend'].includes(r.request.mode));
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);
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;
asset = await saveClip({ ownerKey: r.owner, folderId: q.folderId, prompt: q.compiledPrompt, ...s, aspect: `${s.width}:${s.height}`, hideThumbnail: false, video: assembled, originalSegment: data, sourceSegments, fps: 24, sound: true, familyId: r.familyId, parentClipId: q.startClipId || undefined, chainIndex: r.index, comfyFilename: file.filename });
const anchor = history[r.promptId].outputs?.anchor_save?.images?.[0];
if (anchor) {
r.files.push(anchor);
await persistClipAnchorFrame({ ownerKey: r.owner, clipId: asset.id, fromBuffer: await downloadComfyImage(anchor) });
}
}
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, 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' };
await attachStudio2Metadata(r.owner, asset.id, metadata);
await purge(r);
metadata.purgeResult = r.purgeResult;
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);
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 {
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();
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);
}
}
+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); }
+19 -5
View File
@@ -16,6 +16,7 @@ export type StudioJobStatus = 'waiting' | 'running' | 'held' | 'complete' | 'err
export type StudioJobKind = 'video' | 'edit' | 'music' export type StudioJobKind = 'video' | 'edit' | 'music'
export interface StudioJobPayload { export interface StudioJobPayload {
studio2Id?: string
upscale?: { sourceId: string; scale: 2 | 4; target: string; fps: string | number; enhance: string } upscale?: { sourceId: string; scale: 2 | 4; target: string; fps: string | number; enhance: string }
prompt: string prompt: string
promptMid?: string promptMid?: string
@@ -285,7 +286,7 @@ function failZombieLiveJob(job: Job, error: string) {
function sweepStaleLiveJobs() { function sweepStaleLiveJobs() {
const now = Date.now() const now = Date.now()
for (const job of listJobs()) { for (const job of listJobs()) {
if (job.yueGp || job.upscale) continue if (job.studio2 || job.yueGp || job.upscale) continue
if (job.saving) continue if (job.saving) continue
if (job.status === 'queued' && !job.promptId && now - job.startedAt >= QUEUED_GRACE_MS) { if (job.status === 'queued' && !job.promptId && now - job.startedAt >= QUEUED_GRACE_MS) {
failZombieLiveJob(job, 'Job never started') failZombieLiveJob(job, 'Job never started')
@@ -305,7 +306,7 @@ async function reapZombieLiveJobs() {
const { fetchHistory } = await import('~/server/utils/comfy') const { fetchHistory } = await import('~/server/utils/comfy')
for (const job of listJobs()) { for (const job of listJobs()) {
// Never interrupt download/stitch/library save — Comfy is idle then by design. // Never interrupt download/stitch/library save — Comfy is idle then by design.
if (job.yueGp || job.upscale) continue if (job.studio2 || job.yueGp || job.upscale) continue
if (job.saving) continue if (job.saving) continue
if (job.library?.chainContinuing) continue if (job.library?.chainContinuing) continue
if (job.status !== 'running' && job.status !== 'uploading' && job.status !== 'queued') continue if (job.status !== 'running' && job.status !== 'uploading' && job.status !== 'queued') continue
@@ -350,7 +351,7 @@ async function reapZombieLiveJobs() {
} }
function liveJobOwnsGpu(job: Job) { function liveJobOwnsGpu(job: Job) {
if ((job.yueGp || job.upscale) && ['running', 'queued', 'uploading'].includes(job.status)) return true if ((job.studio2 || job.yueGp || job.upscale) && ['running', 'queued', 'uploading'].includes(job.status)) return true
if (job.library?.stopAfterCurrent) return false if (job.library?.stopAfterCurrent) return false
if (job.library?.chainContinuing) return true if (job.library?.chainContinuing) return true
if (job.saving) return true if (job.saving) return true
@@ -370,7 +371,7 @@ function liveJobOwnsGpu(job: Job) {
*/ */
function clearDeadGpuClaimsForForceStart() { function clearDeadGpuClaimsForForceStart() {
for (const live of listJobs()) { for (const live of listJobs()) {
if (live.yueGp || live.upscale) continue if (live.studio2 || live.yueGp || live.upscale) continue
if (live.saving) continue if (live.saving) continue
if (jobIsLocallySubmitting(live)) continue if (jobIsLocallySubmitting(live)) continue
if (live.status !== 'running' && live.status !== 'queued' && live.status !== 'uploading') { if (live.status !== 'running' && live.status !== 'queued' && live.status !== 'uploading') {
@@ -1422,6 +1423,19 @@ export async function startStudioJob(item: StudioJob) {
} }
async function startStudioJobReserved(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) { if (item.payload.upscale) {
try { const { startUpscaleJob } = await import('./videoUpscale'); await startUpscaleJob(item) } 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() } catch (error) { await patchStudioJob(item.ownerKey, item.id, row => { row.status = 'error'; row.lastError = error instanceof Error ? error.message : String(error) }); scheduleKickRetry() }
@@ -1673,7 +1687,7 @@ export async function onLiveVideoSettled(job: Job) {
return return
} }
const remaining = remainingStudioShots(job) 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.upscale && job.status === 'error' && remaining > 0 && isTransientComfyError(job.error)
const failed = (job.status === 'error' || job.status === 'cancelled') && !wakeFail const failed = (job.status === 'error' || job.status === 'cancelled') && !wakeFail
await mutateStore(owner, (store) => { await mutateStore(owner, (store) => {
+55
View File
@@ -0,0 +1,55 @@
export const identityLock = '<Picture 1> is identity. Face, hair, glasses, outfit fully preserved. Start frame is composition only. Do not copy facial drift from the start frame.'
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 function sectionsFrom(value = {}) {
value = value || {}
if (typeof value === 'string') value = { action: value }
const sections = Object.fromEntries(['action','camera','soundscape','dialogue','music','locks'].map(key => [key, String(value[key] || '')]))
const chip = value.cameraChip
if (chip && cameraFamilies.includes(chip.family)) sections.cameraChip = { family: chip.family, amplitude: ['subtle','moderate','large'].includes(chip.amplitude) ? chip.amplitude : 'subtle', speed: ['slow','medium','fast'].includes(chip.speed) ? chip.speed : 'slow' }
return sections
}
export function compilePrompt(value, video = false) {
const s = sectionsFrom(value), c = s.cameraChip
const camera = c ? (c.family === 'static' ? 'Static shot. Camera locked on a tripod.' : `Camera ${c.family}, ${c.amplitude} movement, ${c.speed} speed.`) : s.camera
return [s.action, camera && `Camera: ${camera}`, s.soundscape && `Soundscape: ${s.soundscape}`, s.dialogue && `Dialogue: (S1) <d>[English]${s.dialogue}</d>`, s.music && `Music: ${s.music}`, s.locks && `Locks: ${s.locks}`, video ? identityLock : ''].filter(Boolean).join('\n\n')
}
export function restoreInput(item) {
const saved = item.studio2 || item
return { ...saved, 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.identityStillId) 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) fail('Choose the source still.')
if (mode === 'extend' && !raw.startClipId) fail('Choose the video to extend.')
const promptSections = sectionsFrom(raw.promptSections || {action:raw.prompt || ''})
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','seed']) 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 frames=Math.ceil((number('duration',5,1,15)*24-5)/17)*17+5
return { mode, engine, 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),
settings: { 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),seed:number('seed',Math.floor(Math.random()*2147483647),0,2147483647),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 next}) }
}
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 + '/')
}
+34
View File
@@ -0,0 +1,34 @@
export function nativeVideoGraph(template, request, files, prefix) {
if (!files.hero) throw new Error('Identity still is required; never substitute the start frame.')
const g=structuredClone(template), s=request.settings
g.hero.inputs.image=files.hero
Object.assign(g['155'].inputs,{prompt:request.compiledPrompt,width:s.width,height:s.height,length:Math.ceil((s.duration*24-5)/17)*17+5,ref_image_size:'max'})
g['155'].inputs['ref_images.ref_image_0']=['hero',0]
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['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
}
+54
View File
@@ -0,0 +1,54 @@
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={
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.from('video'),downloadComfyImage:async()=>Buffer.from('image'),imageDimensions:()=>({width:960,height:960}),
videoSourcePaths:()=>['source'],clipVideoPath:()=>'/fake/source',studio2Root:()=>'/fake',join,mkdirSync(){},stitchExtension:async()=>Buffer.from('stitched'),
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('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/)
})
+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),/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)
})