Compare commits
4
Commits
d4f2b86587
...
8c464f283d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c464f283d | ||
|
|
70c17695e4 | ||
|
|
70b6b70fcd | ||
|
|
bd82f5bd5f |
@@ -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.
|
||||||
|
|||||||
@@ -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&¤t.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>
|
||||||
@@ -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
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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)]}
|
||||||
|
}
|
||||||
@@ -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,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 }; }); });
|
||||||
@@ -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 };
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
import { resumeStudio2Jobs } from '../utils/studio2/runner';
|
||||||
|
export default defineNitroPlugin(() => { resumeStudio2Jobs(); });
|
||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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); }
|
||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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 + '/')
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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/)
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user