Restore Studio 2 Load hydrate, sticky negatives, and strip actions.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-09-21 12:23:20 -05:00
co-authored by Cursor
parent df5ef0949e
commit bb27af506e
10 changed files with 401 additions and 15 deletions
+36
View File
@@ -437,6 +437,42 @@
.tile .tile-trash { right: 22px; } .tile .tile-trash { right: 22px; }
.strip-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
flex-shrink: 0;
border-top: 1px solid var(--line);
padding: 8px 10px;
background: var(--panel);
}
.strip-actions button {
border: 1px solid var(--line);
border-radius: 6px;
background: transparent;
color: var(--text);
padding: 4px 10px;
font-size: var(--type-sm);
cursor: pointer;
}
.strip-actions button:hover {
border-color: var(--amber);
color: var(--amber);
}
.neg-add-row {
display: flex;
gap: 8px;
align-items: center;
margin-top: 8px;
}
.neg-add-row .field-control {
flex: 1;
}
.transport { .transport {
display: flex; display: flex;
height: 28px; height: 28px;
+193 -6
View File
@@ -78,8 +78,28 @@
</template> </template>
<template v-else> <template v-else>
<Studio2AssetSlot size="hero" label="Hero still" :asset="stills.find((a:any)=>a.id===form.identityStillId)" :hidden="slotHidden(form.identityStillId)" upload :disabled="uploading" @pick="picker='identityStillId'" @upload="upload($event,'hero')" /> <Studio2AssetSlot
<Studio2AssetSlot :label="form.mode==='extend'?'Previous clip':'Start still'" :asset="sourceSlot" :hidden="slotHidden(form.mode==='extend'?form.startClipId:form.imageAId)" upload :disabled="uploading" @pick="picker=form.mode==='extend'?'startClipId':'imageAId'" @upload="upload($event,'source')" /> size="hero"
:label="heroSlotLabel"
:asset="stills.find((a:any)=>a.id===form.identityStillId)"
:hidden="slotHidden(form.identityStillId)"
upload
:disabled="uploading"
@pick="picker='identityStillId'"
@upload="upload($event,'hero')"
/>
<Studio2AssetSlot
:label="sourceSlotLabel"
:asset="sourceSlot"
:hidden="slotHidden(form.mode==='extend'?form.startClipId:form.imageAId)"
upload
:disabled="uploading"
:class="{ 'slot-required': editSourceRequired && !form.imageAId }"
@pick="picker=form.mode==='extend'?'startClipId':'imageAId'"
@upload="upload($event,'source')"
/>
<p v-if="editSourceRequired && !form.imageAId" class="text-[11px] text-[var(--amber)]">Required for Edit</p>
<p v-if="slotPairHint" class="text-[11px] text-[var(--muted)]">{{ slotPairHint }}</p>
<Studio2AssetSlot v-if="form.mode==='compose'" label="Second still" :asset="stills.find((a:any)=>a.id===form.imageBId)" :hidden="slotHidden(form.imageBId)" @pick="picker='imageBId'" /> <Studio2AssetSlot v-if="form.mode==='compose'" label="Second still" :asset="stills.find((a:any)=>a.id===form.imageBId)" :hidden="slotHidden(form.imageBId)" @pick="picker='imageBId'" />
<Studio2AssetSlot v-if="form.mode==='refine'" label="Mask" :asset="stills.find((a:any)=>a.id===form.maskId)" :hidden="slotHidden(form.maskId)" @pick="picker='maskId'" /> <Studio2AssetSlot v-if="form.mode==='refine'" label="Mask" :asset="stills.find((a:any)=>a.id===form.maskId)" :hidden="slotHidden(form.maskId)" @pick="picker='maskId'" />
<Studio2AssetSlot v-if="video" label="End still" :asset="stills.find((a:any)=>a.id===form.endStillId)" :hidden="slotHidden(form.endStillId)" @pick="picker='endStillId'" /> <Studio2AssetSlot v-if="video" label="End still" :asset="stills.find((a:any)=>a.id===form.endStillId)" :hidden="slotHidden(form.endStillId)" @pick="picker='endStillId'" />
@@ -105,6 +125,16 @@
<button type="button" :aria-label="'Remove '+chip" @click="removeNegative(chip)">×</button> <button type="button" :aria-label="'Remove '+chip" @click="removeNegative(chip)">×</button>
</span> </span>
</div> </div>
<div class="neg-add-row">
<input
v-model="negativeDraft"
type="text"
class="field-control"
placeholder="Add negative chip"
@keydown.enter.prevent="addNegative"
>
<button type="button" class="text-[var(--type-sm)] text-[var(--amber)]" @click="addNegative">Add</button>
</div>
</div> </div>
<div v-if="form.engine!=='qwen21'"> <div v-if="form.engine!=='qwen21'">
@@ -191,12 +221,13 @@
</template> </template>
</div> </div>
<div class="filmstrip" aria-label="Current family outputs"> <div class="filmstrip" ref="filmstripEl" aria-label="Current family outputs">
<div <div
v-for="o in family" v-for="o in family"
:key="o.id" :key="o.id"
class="tile" class="tile"
:data-active="selected?.id===o.id ? 'true' : undefined" :data-active="selected?.id===o.id ? 'true' : undefined"
:ref="el => setTileRef(o.id, el)"
> >
<button type="button" class="pick" @click="selectOutput(o)"> <button type="button" class="pick" @click="selectOutput(o)">
<span v-if="isHidden(o)" class="flex h-full items-center justify-center p-1 text-[10px] text-[var(--muted)]">Hidden</span> <span v-if="isHidden(o)" class="flex h-full items-center justify-center p-1 text-[10px] text-[var(--muted)]">Hidden</span>
@@ -223,6 +254,14 @@
</div> </div>
</div> </div>
<div v-if="selected && selected.kind!=='track'" class="strip-actions" aria-label="Selected still actions">
<button type="button" @click="applyUseAsSource">Use as start / input</button>
<button type="button" @click="applyUseAsHero">Use as hero</button>
<button type="button" @click="copySelectedPrompt">Copy prompt</button>
<button type="button" @click="loadSelectedJob">Load</button>
<button type="button" @click="deleteOutput">Delete</button>
</div>
<div class="transport"> <div class="transport">
<span>{{ transportLabel }}</span> <span>{{ transportLabel }}</span>
<div class="ops"> <div class="ops">
@@ -324,7 +363,7 @@
import {createLoraMemory,loraUse} from '~/shared/studio2/lora-memory.mjs' import {createLoraMemory,loraUse} from '~/shared/studio2/lora-memory.mjs'
import {filterLorasForStudio,filterLorasForUse} from '~/utils/loras' import {filterLorasForStudio,filterLorasForUse} from '~/utils/loras'
import {sectionsFrom} from '~/shared/studio2/contracts.mjs' import {sectionsFrom} from '~/shared/studio2/contracts.mjs'
import {aspectOptions,resolveSize,bindClip,restoreAll,useAsHero,useAsSource} from '~/shared/studio2/input.mjs' import {aspectOptions,resolveSize,bindClip,restoreAll,useAsHero,useAsSource,hydrateStudio2Job} from '~/shared/studio2/input.mjs'
import {folderAssets,newestOutput,assetLabel,reconcileSelection,previewHidden} from '~/shared/studio2/viewer.mjs' import {folderAssets,newestOutput,assetLabel,reconcileSelection,previewHidden} from '~/shared/studio2/viewer.mjs'
import {queueJobs} from '~/shared/studio2/queue-view.mjs' import {queueJobs} from '~/shared/studio2/queue-view.mjs'
import {gpuStatus} from '~/shared/studio2/status.mjs' import {gpuStatus} from '~/shared/studio2/status.mjs'
@@ -333,6 +372,22 @@ import {defaultNegatives,parseBatch} from '~/shared/studio2/styles.mjs'
import {randomSeed} from '~/shared/studio2/seed.mjs' import {randomSeed} from '~/shared/studio2/seed.mjs'
import {DEFAULT_MUSIC_LYRICS,DEFAULT_MUSIC_TAGS} from '~/utils/music' import {DEFAULT_MUSIC_LYRICS,DEFAULT_MUSIC_TAGS} from '~/utils/music'
import {isXaigenStudio} from '~/utils/videoModels' import {isXaigenStudio} from '~/utils/videoModels'
import {LOAD_GENERATION_PAYLOAD_KEY} from '~/utils/queuedJob'
const NEG_STORAGE_KEY='studio2-negative-chips'
function readStoredNegatives(){
if(!import.meta.client)return [...defaultNegatives]
try{
const raw=localStorage.getItem(NEG_STORAGE_KEY)
if(raw===null)return [...defaultNegatives]
const parsed=JSON.parse(raw)
return Array.isArray(parsed)?parsed.map((s:any)=>String(s||'').trim()).filter(Boolean):[...defaultNegatives]
}catch{return [...defaultNegatives]}
}
function writeStoredNegatives(list:string[]){
if(!import.meta.client)return
try{localStorage.setItem(NEG_STORAGE_KEY,JSON.stringify(list))}catch{/* ignore */}
}
const xaigen=isXaigenStudio(useRuntimeConfig().public) const xaigen=isXaigenStudio(useRuntimeConfig().public)
const instanceName=String(useRuntimeConfig().public.instanceName || 'AIGen') const instanceName=String(useRuntimeConfig().public.instanceName || 'AIGen')
@@ -349,7 +404,10 @@ const loraNames=ref<any>({image:[],video:[]}),now=ref(Date.now()),healthState=re
const library=ref<any>({folders:[],stills:[],clips:[],tracks:[]}),jobs=ref<any[]>([]),selected=ref<any>(null) const library=ref<any>({folders:[],stills:[],clips:[],tracks:[]}),jobs=ref<any[]>([]),selected=ref<any>(null)
const studioQueue=ref<any[]>([]) const studioQueue=ref<any[]>([])
const liveMusic=ref<Record<string,any>>({}) const liveMusic=ref<Record<string,any>>({})
const form=reactive<any>({mode:'generate',engine:'flux',imageStyles:{positive:[],negative:[...defaultNegatives]},lockFace:true,lockOutfit:true,hideInput:xaigen,folderId:'',identityStillId:'',imageAId:'',imageBId:'',maskId:'',startClipId:'',endStillId:'',guides:[],promptSections:sectionsFrom({}),settings:{aspect:'16:9',fps:24,steps:24,cfg:4,seed:null,seedMode:'random',duration:5,turbo:true,loraStack:[]}}) const negativeDraft=ref('')
const filmstripEl=ref<HTMLElement|null>(null)
const tileRefs=new Map<string,HTMLElement>()
const form=reactive<any>({mode:'generate',engine:'flux',imageStyles:{positive:[],negative:readStoredNegatives()},lockFace:true,lockOutfit:true,hideInput:xaigen,folderId:'',identityStillId:'',imageAId:'',imageBId:'',maskId:'',startClipId:'',endStillId:'',guides:[],promptSections:sectionsFrom({}),settings:{aspect:'auto',fps:24,steps:24,cfg:4,seed:null,seedMode:'random',duration:5,turbo:true,loraStack:[]}})
const music=reactive({tags:DEFAULT_MUSIC_TAGS,lyrics:DEFAULT_MUSIC_LYRICS,duration:60,seed:'',name:''}) const music=reactive({tags:DEFAULT_MUSIC_TAGS,lyrics:DEFAULT_MUSIC_LYRICS,duration:60,seed:'',name:''})
const tagsPlaceholder=DEFAULT_MUSIC_TAGS,lyricsPlaceholder=DEFAULT_MUSIC_LYRICS const tagsPlaceholder=DEFAULT_MUSIC_TAGS,lyricsPlaceholder=DEFAULT_MUSIC_LYRICS
let loraMemory=createLoraMemory(xaigen) let loraMemory=createLoraMemory(xaigen)
@@ -425,6 +483,21 @@ const sourceAsset=computed(()=>form.startClipId?clips.value.find((c:any)=>c.id==
const sourceSize=computed(()=>measured[sourceAsset.value?.id] || sourceAsset.value) const sourceSize=computed(()=>measured[sourceAsset.value?.id] || sourceAsset.value)
const resolvedSize=computed(()=>resolveSize(form.settings.aspect,sourceSize.value,undefined,form.engine)) const resolvedSize=computed(()=>resolveSize(form.settings.aspect,sourceSize.value,undefined,form.engine))
const negativeChips=computed(()=>form.imageStyles.negative || []) const negativeChips=computed(()=>form.imageStyles.negative || [])
const editSourceRequired=computed(()=>['edit','compose','refine'].includes(form.mode))
const heroSlotLabel=computed(()=>editSourceRequired.value?'Identity / reference':'Hero still')
const sourceSlotLabel=computed(()=>{
if(form.mode==='extend')return 'Previous clip'
if(editSourceRequired.value)return 'Edit source'
return 'Start still'
})
const slotPairHint=computed(()=>{
if(form.mode==='refine')return form.engine==='krea'
? 'Krea Refine: Edit source is the picture; Mask is the refine region.'
: 'Refine: Edit source is the picture; Mask marks the region to change.'
if(!editSourceRequired.value)return ''
if(form.engine==='qwen21')return 'Qwen: Edit source is <image1>. Hero is unused unless the graph maps it to <image2>.'
return 'Klein: Hero = identity encode. Edit source = the picture you are changing.'
})
const queueCards=computed(()=>{ const queueCards=computed(()=>{
const cards:any[]=[] const cards:any[]=[]
for(const job of benchJobs.value){ for(const job of benchJobs.value){
@@ -498,7 +571,106 @@ function toggleFullscreen(){
const el=viewerVideo.value || document.querySelector('.viewer-frame') const el=viewerVideo.value || document.querySelector('.viewer-frame')
if(el && 'requestFullscreen' in el) void (el as HTMLElement).requestFullscreen() if(el && 'requestFullscreen' in el) void (el as HTMLElement).requestFullscreen()
} }
function removeNegative(chip:string){form.imageStyles={...form.imageStyles,negative:(form.imageStyles.negative||[]).filter((c:string)=>c!==chip)}} function removeNegative(chip:string){
form.imageStyles={...form.imageStyles,negative:(form.imageStyles.negative||[]).filter((c:string)=>c!==chip)}
writeStoredNegatives(form.imageStyles.negative)
}
function addNegative(){
const chip=negativeDraft.value.trim()
if(!chip)return
const list=form.imageStyles.negative||[]
if(!list.includes(chip)){
form.imageStyles={...form.imageStyles,negative:[...list,chip]}
writeStoredNegatives(form.imageStyles.negative)
}
negativeDraft.value=''
}
function setTileRef(id:string,el:any){
if(el)tileRefs.set(id,el as HTMLElement)
else tileRefs.delete(id)
}
function scrollFilmstripToNewest(){
nextTick(()=>{
const id=selected.value?.id || family.value.at(-1)?.id
const tile=id?tileRefs.get(id):null
const strip=filmstripEl.value
if(tile?.scrollIntoView)tile.scrollIntoView({inline:'end',block:'nearest',behavior:'smooth'})
else if(strip)strip.scrollLeft=strip.scrollWidth
})
}
function applyUseAsSource(){
if(!selected.value||selected.value.kind==='track')return
Object.assign(form,useAsSource(form,selected.value))
uploadMessage.value=`Using ${assetLabel(selected.value)} as start / input.`
}
function applyUseAsHero(){
if(!selected.value||selected.value.kind==='track')return
Object.assign(form,useAsHero(form,selected.value))
uploadMessage.value=`Using ${assetLabel(selected.value)} as hero.`
}
async function copySelectedPrompt(){
const text=selected.value?.studio2?.promptSections?.action
|| selected.value?.studio2?.compiledPrompt
|| selected.value?.prompt
|| ''
try{
if(import.meta.client && navigator.clipboard)await navigator.clipboard.writeText(String(text||''))
uploadMessage.value=text?'Prompt copied.':'No prompt on this item.'
}catch{error.value='Could not copy prompt.'}
}
function stillIdSet(){return new Set(stills.value.map((s:any)=>s.id))}
function clipIdSet(){return new Set(clips.value.map((c:any)=>c.id))}
function applyHydratedJob(saved:any,output?:any){
const {form:next,missing}=hydrateStudio2Job(form,saved,{stillIds:stillIdSet(),clipIds:clipIdSet(),xaigen,memory:loraMemory})
Object.assign(form,next)
form.guides=form.guides||[]
batchText.value=''
if(output)selected.value=output
else if(form.mode==='music'){/* keep */}
error.value=missing||''
if(missing)uploadMessage.value=missing
else uploadMessage.value='Job loaded into the bench.'
}
function loadSelectedJob(){
if(!selected.value)return
const saved=selected.value.studio2
if(!saved){error.value='No saved job on this still.';return}
applyHydratedJob(saved,selected.value)
}
async function applyIncomingGenerationLoad(){
if(!import.meta.client)return
let raw=''
try{raw=sessionStorage.getItem(LOAD_GENERATION_PAYLOAD_KEY)||''}catch{return}
if(!raw)return
try{sessionStorage.removeItem(LOAD_GENERATION_PAYLOAD_KEY)}catch{/* ignore */}
let loaded:any
try{loaded=JSON.parse(raw)}catch{error.value='Could not read that saved generation.';return}
const payload=loaded?.payload||{}
if(loaded?.kind==='music' || payload.lyrics != null || payload.tags != null){
form.mode='music'
if(payload.folderId)form.folderId=payload.folderId
music.tags=String(payload.tags||payload.prompt||music.tags)
music.lyrics=String(payload.lyrics||music.lyrics)
if(payload.duration!=null)music.duration=Number(payload.duration)||music.duration
if(payload.seed!=null)music.seed=String(payload.seed)
if(payload.name)music.name=String(payload.name)
uploadMessage.value='Music job loaded into the bench.'
return
}
let request=payload.studio2Request
if(!request && payload.studio2Id){
await refresh()
request=jobs.value.find((j:any)=>j.id===payload.studio2Id)?.request
}
if(!request){error.value='That generation has no Studio 2 request to load.';return}
if(request.folderId)form.folderId=request.folderId
await refresh()
const outputs=jobs.value.find((j:any)=>j.id===payload.studio2Id)?.outputs||[]
const outId=outputs.at(-1)?.id
const output=outId?family.value.find((a:any)=>a.id===outId):null
applyHydratedJob(request,output||undefined)
scrollFilmstripToNewest()
}
function focusQueueCard(card:any){ function focusQueueCard(card:any){
focusedJobId.value=card.id focusedJobId.value=card.id
if(card.job?.outputs?.length)focusJob(card.job) if(card.job?.outputs?.length)focusJob(card.job)
@@ -623,6 +795,8 @@ async function refresh(){
let next=existingFresh || reconcileSelection(selected.value,family.value) let next=existingFresh || reconcileSelection(selected.value,family.value)
if(form.mode!=='music' && next?.kind==='track') next=family.value.find((a:any)=>a.kind!=='track') || null if(form.mode!=='music' && next?.kind==='track') next=family.value.find((a:any)=>a.kind!=='track') || null
selected.value=next selected.value=next
if(fresh || existingFresh) scrollFilmstripToNewest()
else if(selected.value?.id===family.value.at(-1)?.id) scrollFilmstripToNewest()
} }
watch(()=>form.folderId,id=>{selected.value=null;form.imageAId='';form.startClipId='';loadRevealed(id);if(import.meta.client)form.identityStillId=localStorage.getItem(`studio2-hero:${id}`)||''},{flush:'sync'}) watch(()=>form.folderId,id=>{selected.value=null;form.imageAId='';form.startClipId='';loadRevealed(id);if(import.meta.client)form.identityStillId=localStorage.getItem(`studio2-hero:${id}`)||''},{flush:'sync'})
@@ -633,11 +807,13 @@ onMounted(async()=>{
loraMemory=createLoraMemory(xaigen,storage);loraMemory.sync(form);form.settings.seed=randomSeed() loraMemory=createLoraMemory(xaigen,storage);loraMemory.sync(form);form.settings.seed=randomSeed()
clock=setInterval(()=>{now.value=Date.now()},1000);void loadLoras(true) clock=setInterval(()=>{now.value=Date.now()},1000);void loadLoras(true)
library.value=await $fetch('/api/studio-2/library');form.folderId=library.value.folders[0]?.id||'';loadRevealed(form.folderId) library.value=await $fetch('/api/studio-2/library');form.folderId=library.value.folders[0]?.id||'';loadRevealed(form.folderId)
await applyIncomingGenerationLoad()
const id=String(route.query.still||route.query.clip||route.query.track||'') const id=String(route.query.still||route.query.clip||route.query.track||'')
const asset=[...library.value.stills,...library.value.clips,...(library.value.tracks||[])].find((a:any)=>a.id===id) const asset=[...library.value.stills,...library.value.clips,...(library.value.tracks||[])].find((a:any)=>a.id===id)
if(asset){selected.value={...asset,kind:route.query.clip?'clip':route.query.track?'track':'still'};if(route.query.use==='1'&&selected.value.kind!=='track')restore(selected.value);if(xaigen)toggleReveal(asset.id)} if(asset){selected.value={...asset,kind:route.query.clip?'clip':route.query.track?'track':'still'};if(route.query.use==='1'&&selected.value.kind!=='track')restore(selected.value);if(xaigen)toggleReveal(asset.id)}
if(route.query.task==='music')form.mode='music' if(route.query.task==='music')form.mode='music'
const poll=async()=>{try{await refresh();const health=await $fetch<any>('/api/health');healthState.value=health}catch(e:any){healthState.value=null;error.value=e.data?.statusMessage||e.message}if(!disposed)timer=setTimeout(poll,3000)};void poll() const poll=async()=>{try{await refresh();const health=await $fetch<any>('/api/health');healthState.value=health}catch(e:any){healthState.value=null;error.value=e.data?.statusMessage||e.message}if(!disposed)timer=setTimeout(poll,3000)};void poll()
scrollFilmstripToNewest()
}) })
onBeforeUnmount(()=>{disposed=true;clearTimeout(timer);clearInterval(clock)}) onBeforeUnmount(()=>{disposed=true;clearTimeout(timer);clearInterval(clock)})
@@ -669,6 +845,11 @@ const mobileCtx=computed(()=>({
error:error.value, error:error.value,
batchText:batchText.value, batchText:batchText.value,
negativeChips:negativeChips.value, negativeChips:negativeChips.value,
negativeDraft:negativeDraft.value,
heroSlotLabel:heroSlotLabel.value,
sourceSlotLabel:sourceSlotLabel.value,
slotPairHint:slotPairHint.value,
editSourceRequired:editSourceRequired.value,
pickerLoras:pickerLoras.value, pickerLoras:pickerLoras.value,
currentLoraUse:currentLoraUse.value, currentLoraUse:currentLoraUse.value,
loraAliases:{...loraNames.value.aliases,...library.value.loraAliases}, loraAliases:{...loraNames.value.aliases,...library.value.loraAliases},
@@ -687,8 +868,14 @@ const mobileCtx=computed(()=>({
focusQueueCard, focusQueueCard,
clearFinished, clearFinished,
removeNegative, removeNegative,
addNegative,
applyUseAsSource,
applyUseAsHero,
copySelectedPrompt,
loadSelectedJob,
randomizeSeed, randomizeSeed,
setPicker, setPicker,
setBatchText:(v:string)=>{batchText.value=v}, setBatchText:(v:string)=>{batchText.value=v},
setNegativeDraft:(v:string)=>{negativeDraft.value=v},
})) }))
</script> </script>
+41 -2
View File
@@ -107,6 +107,14 @@
</div> </div>
</div> </div>
<div v-if="ctx.selected && ctx.selected.kind!=='track'" class="m-strip-actions">
<button type="button" @click="ctx.applyUseAsSource">Use as start</button>
<button type="button" @click="ctx.applyUseAsHero">Use as hero</button>
<button type="button" @click="ctx.copySelectedPrompt">Copy prompt</button>
<button type="button" @click="ctx.loadSelectedJob">Load</button>
<button type="button" @click="ctx.deleteOutput">Delete</button>
</div>
<div class="m-dock"> <div class="m-dock">
<button type="button" :class="{ on: sheet==='slots' }" @click="toggleSheet('slots')">Slots</button> <button type="button" :class="{ on: sheet==='slots' }" @click="toggleSheet('slots')">Slots</button>
<button type="button" :class="{ on: sheet==='prompt' }" @click="toggleSheet('prompt')">Prompt</button> <button type="button" :class="{ on: sheet==='prompt' }" @click="toggleSheet('prompt')">Prompt</button>
@@ -125,8 +133,10 @@
<p class="m-muted">Music uses tags + lyrics in Prompt / Gen — no hero slot.</p> <p class="m-muted">Music uses tags + lyrics in Prompt / Gen — no hero slot.</p>
</template> </template>
<template v-else> <template v-else>
<Studio2AssetSlot size="hero" label="Hero still" :asset="ctx.stills.find((a:any)=>a.id===ctx.form.identityStillId)" :hidden="ctx.slotHidden(ctx.form.identityStillId)" upload :disabled="ctx.uploading" @pick="ctx.setPicker('identityStillId')" @upload="ctx.upload($event,'hero')" /> <Studio2AssetSlot size="hero" :label="ctx.heroSlotLabel" :asset="ctx.stills.find((a:any)=>a.id===ctx.form.identityStillId)" :hidden="ctx.slotHidden(ctx.form.identityStillId)" upload :disabled="ctx.uploading" @pick="ctx.setPicker('identityStillId')" @upload="ctx.upload($event,'hero')" />
<Studio2AssetSlot :label="ctx.form.mode==='extend'?'Previous clip':'Start still'" :asset="ctx.sourceSlot" :hidden="ctx.slotHidden(ctx.form.mode==='extend'?ctx.form.startClipId:ctx.form.imageAId)" upload :disabled="ctx.uploading" @pick="ctx.setPicker(ctx.form.mode==='extend'?'startClipId':'imageAId')" @upload="ctx.upload($event,'source')" /> <Studio2AssetSlot :label="ctx.sourceSlotLabel" :asset="ctx.sourceSlot" :hidden="ctx.slotHidden(ctx.form.mode==='extend'?ctx.form.startClipId:ctx.form.imageAId)" upload :disabled="ctx.uploading" @pick="ctx.setPicker(ctx.form.mode==='extend'?'startClipId':'imageAId')" @upload="ctx.upload($event,'source')" />
<p v-if="ctx.editSourceRequired && !ctx.form.imageAId" class="m-hint">Required for Edit</p>
<p v-if="ctx.slotPairHint" class="m-hint">{{ ctx.slotPairHint }}</p>
<Studio2AssetSlot v-if="ctx.form.mode==='compose'" label="Second still" :asset="ctx.stills.find((a:any)=>a.id===ctx.form.imageBId)" :hidden="ctx.slotHidden(ctx.form.imageBId)" @pick="ctx.setPicker('imageBId')" /> <Studio2AssetSlot v-if="ctx.form.mode==='compose'" label="Second still" :asset="ctx.stills.find((a:any)=>a.id===ctx.form.imageBId)" :hidden="ctx.slotHidden(ctx.form.imageBId)" @pick="ctx.setPicker('imageBId')" />
<Studio2AssetSlot v-if="ctx.form.mode==='refine'" label="Mask" :asset="ctx.stills.find((a:any)=>a.id===ctx.form.maskId)" :hidden="ctx.slotHidden(ctx.form.maskId)" @pick="ctx.setPicker('maskId')" /> <Studio2AssetSlot v-if="ctx.form.mode==='refine'" label="Mask" :asset="ctx.stills.find((a:any)=>a.id===ctx.form.maskId)" :hidden="ctx.slotHidden(ctx.form.maskId)" @pick="ctx.setPicker('maskId')" />
<Studio2AssetSlot v-if="ctx.video" label="End still" :asset="ctx.stills.find((a:any)=>a.id===ctx.form.endStillId)" :hidden="ctx.slotHidden(ctx.form.endStillId)" @pick="ctx.setPicker('endStillId')" /> <Studio2AssetSlot v-if="ctx.video" label="End still" :asset="ctx.stills.find((a:any)=>a.id===ctx.form.endStillId)" :hidden="ctx.slotHidden(ctx.form.endStillId)" @pick="ctx.setPicker('endStillId')" />
@@ -156,6 +166,17 @@
<button type="button" :aria-label="'Remove '+chip" @click="ctx.removeNegative(chip)">×</button> <button type="button" :aria-label="'Remove '+chip" @click="ctx.removeNegative(chip)">×</button>
</span> </span>
</div> </div>
<div class="m-neg-add">
<input
:value="ctx.negativeDraft"
type="text"
class="m-input"
placeholder="Add negative chip"
@input="ctx.setNegativeDraft(($event.target as HTMLInputElement).value)"
@keydown.enter.prevent="ctx.addNegative"
>
<button type="button" @click="ctx.addNegative">Add</button>
</div>
</div> </div>
<Studio2StyleChips v-if="!ctx.video" v-model="ctx.form.imageStyles" /> <Studio2StyleChips v-if="!ctx.video" v-model="ctx.form.imageStyles" />
</template> </template>
@@ -519,6 +540,24 @@ watch(() => props.ctx.form.mode, () => { sheet.value = '' })
font-size: 12px; font-size: 12px;
} }
.m-neg-chip button { border: 0; background: transparent; color: inherit; } .m-neg-chip button { border: 0; background: transparent; color: inherit; }
.m-neg-add { display: flex; gap: 8px; align-items: center; margin-top: 8px; }
.m-neg-add .m-input { flex: 1; margin-top: 0; }
.m-strip-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 8px 10px;
border-top: 1px solid var(--line);
background: var(--panel);
}
.m-strip-actions button {
border: 1px solid var(--line);
border-radius: 6px;
background: transparent;
color: var(--text);
padding: 4px 8px;
font-size: 12px;
}
.m-seed { display: flex; gap: 8px; margin-top: 4px; } .m-seed { display: flex; gap: 8px; margin-top: 4px; }
.m-seed .m-input { margin-top: 0; flex: 1; } .m-seed .m-input { margin-top: 0; flex: 1; }
.m-dice { .m-dice {
+2 -2
View File
@@ -961,8 +961,8 @@ async function loadLog(entry: GenerationLogEntry) {
kind: entry.kind, kind: entry.kind,
payload: entry.payload payload: entry.payload
})) }))
if (entry.kind === 'music') { if (entry.payload?.studio2Id || entry.payload?.studio2Request || entry.kind === 'music') {
await navigateTo('/music') await navigateTo(entry.kind === 'music' ? '/studio-2?task=music' : '/studio-2')
} else { } else {
await navigateTo('/') await navigateTo('/')
} }
+1 -1
View File
@@ -28,7 +28,7 @@ export default defineEventHandler(async (event) => {
await resolveRequestSize(owner,request); await resolveRequestSize(owner,request);
const id = crypto.randomUUID(), record = { id, owner, request, state: 'waiting', queuedAt: Date.now(), familyId: crypto.randomUUID(), outputs: [], purgeResult: 'Not yet saved' }; const id = crypto.randomUUID(), record = { id, owner, request, state: 'waiting', queuedAt: Date.now(), familyId: crypto.randomUUID(), outputs: [], purgeResult: 'Not yet saved' };
saveRecord(record); 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 }); const row = await addStudioJob({ ownerKey: owner, kind: ['video', 'extend'].includes(request.mode) ? 'video' : 'edit', familyId: record.familyId, payload: { ...request.settings, studio2Id: id, studio2Request: structuredClone(request), prompt: request.compiledPrompt, folderId: request.folderId, extensions: [], referenceStillIds: [], useIdentityRefs: false, queueAutoRun: true } as StudioJobPayload });
saveRecord({ ...readRecord(owner, id), queueId: row.id }); saveRecord({ ...readRecord(owner, id), queueId: row.id });
kickStudioQueue(); kickStudioQueue();
return { id, seed:request.settings.seed, width:request.settings.width, height:request.settings.height }; return { id, seed:request.settings.seed, width:request.settings.width, height:request.settings.height };
+34 -1
View File
@@ -139,11 +139,44 @@ export async function updateGenerationLogByStudioJob(
export async function requeueGenerationLog(owner: string, id: string) { export async function requeueGenerationLog(owner: string, id: string) {
const entry = getGenerationLogEntry(owner, id) const entry = getGenerationLogEntry(owner, id)
const { addStudioJob, kickStudioQueue } = await import('~/server/utils/studioQueue') const { addStudioJob, kickStudioQueue } = await import('~/server/utils/studioQueue')
let payload = structuredClone(entry.payload)
if (payload?.studio2Id || payload?.studio2Request) {
const { saveRecord, readRecord } = await import('~/server/utils/studio2/store')
let request = payload.studio2Request
if (!request && payload.studio2Id) {
try { request = structuredClone(readRecord(owner, payload.studio2Id).request) }
catch { request = null }
}
if (!request) throw createError({ statusCode: 400, statusMessage: 'That Studio 2 job has no saved request to re-queue.' })
const studio2Id = crypto.randomUUID()
saveRecord({
id: studio2Id,
owner,
request: structuredClone(request),
state: 'waiting',
queuedAt: Date.now(),
familyId: crypto.randomUUID(),
outputs: [],
purgeResult: 'Not yet saved'
})
payload = {
...payload,
...structuredClone(request.settings || {}),
studio2Id,
studio2Request: structuredClone(request),
prompt: request.compiledPrompt || payload.prompt,
folderId: request.folderId || payload.folderId,
extensions: [],
referenceStillIds: [],
useIdentityRefs: false,
queueAutoRun: true
}
}
const studio = await addStudioJob({ const studio = await addStudioJob({
ownerKey: owner, ownerKey: owner,
familyId: crypto.randomUUID(), familyId: crypto.randomUUID(),
kind: entry.kind, kind: entry.kind,
payload: structuredClone(entry.payload) payload
}) })
await kickStudioQueue() await kickStudioQueue()
return { studioJobId: studio.id, entry: listGenerationLog(owner, 1)[0] || getGenerationLogEntry(owner, id) } return { studioJobId: studio.id, entry: listGenerationLog(owner, 1)[0] || getGenerationLogEntry(owner, id) }
+2
View File
@@ -17,6 +17,8 @@ export type StudioJobKind = 'video' | 'edit' | 'music'
export interface StudioJobPayload { export interface StudioJobPayload {
studio2Id?: string studio2Id?: string
/** Full Studio 2 validateRequest snapshot for Load / Re-queue. */
studio2Request?: Record<string, any>
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
+66 -1
View File
@@ -1,5 +1,6 @@
import {restoreLoras,loraUse} from './lora-memory.mjs' import {restoreLoras,loraUse} from './lora-memory.mjs'
import { restoreInput } from './contracts.mjs' import { restoreInput, sectionsFrom } from './contracts.mjs'
import { normalizeStyles } from './styles.mjs'
export const aspectOptions = [{id:'auto',label:'Automatic',hint:'Match start or hero still'},{id:'16:9',label:'16:9',hint:'1344 × 768 · padded'},{id:'9:16',label:'9:16',hint:'768 × 1344 · padded'},{id:'1:1',label:'1:1',hint:'960 × 960 · padded'}] export const aspectOptions = [{id:'auto',label:'Automatic',hint:'Match start or hero still'},{id:'16:9',label:'16:9',hint:'1344 × 768 · padded'},{id:'9:16',label:'9:16',hint:'768 × 1344 · padded'},{id:'1:1',label:'1:1',hint:'960 × 960 · padded'}]
export function resolveQwen21Size(aspect, source) { export function resolveQwen21Size(aspect, source) {
const presets = {'16:9':[1024,576],'9:16':[576,1024],'1:1':[1024,1024]} const presets = {'16:9':[1024,576],'9:16':[576,1024],'1:1':[1024,1024]}
@@ -70,6 +71,70 @@ export function changeSource(form) {
export function useAsHero(form, item) { return {...form,identityStillId:item.id} } export function useAsHero(form, item) { return {...form,identityStillId:item.id} }
export function useAsSource(form, item) { return {...form,imageAId:item.id} } export function useAsSource(form, item) { return {...form,imageAId:item.id} }
/** Hydrate the live bench from a saved Studio 2 request (Load). Does not enqueue. */
export function hydrateStudio2Job(form, saved, { stillIds = new Set(), clipIds = new Set(), xaigen = false, memory } = {}) {
const src = structuredClone(saved?.studio2 || saved || {})
const missing = []
const keepStill = (id, label) => {
const value = id || ''
if (!value) return ''
if (stillIds.has(value)) return value
missing.push(`${label} missing from library`)
return ''
}
const keepClip = (id, label) => {
const value = id || ''
if (!value) return ''
if (clipIds.has(value)) return value
missing.push(`${label} missing from library`)
return ''
}
const modeRaw = src.mode === 'iterate' ? 'edit' : src.mode
const mode = ['generate','edit','compose','refine','video','extend','music'].includes(modeRaw) ? modeRaw : 'edit'
let engine = src.engine === 'qwen21' ? 'qwen21' : src.engine === 'krea' ? 'krea' : src.engine === 'ltx' ? 'ltx' : src.engine === 'minimax' ? 'minimax' : 'flux'
if (['video','extend'].includes(mode)) engine = xaigen && engine === 'ltx' ? 'ltx' : 'minimax'
else if (engine === 'minimax' || engine === 'ltx') engine = 'flux'
const settings = structuredClone(src.settings || {})
const aspect = ['auto','16:9','9:16','1:1'].includes(settings.aspect) ? settings.aspect : 'auto'
const loraStack = Array.isArray(settings.loraStack) ? settings.loraStack.map(item => ({
name: item.name,
strengthModel: Number(item.strengthModel ?? item.strength ?? 1),
strengthClip: Number(item.strengthClip ?? item.strengthModel ?? item.strength ?? 1)
})) : []
const guides = (Array.isArray(src.guides) ? src.guides : []).map(g => {
const stillId = keepStill(g.stillId, 'guide still')
return stillId ? { stillId, frame: g.frame } : null
}).filter(Boolean)
const next = {
...form,
mode,
engine,
lockFace: src.lockFace !== false,
lockOutfit: src.lockOutfit !== false,
hideInput: !!src.hideInput,
folderId: src.folderId || form.folderId,
identityStillId: keepStill(src.identityStillId, 'hero'),
imageAId: keepStill(src.imageAId, 'start'),
imageBId: keepStill(src.imageBId, 'second still'),
maskId: keepStill(src.maskId, 'mask'),
startClipId: keepClip(src.startClipId, 'start clip'),
endStillId: keepStill(src.endStillId, 'end still'),
guides,
imageStyles: normalizeStyles(src.imageStyles || { positive: [], negative: [] }),
promptSections: sectionsFrom(src.promptSections || { action: src.compiledPrompt || src.prompt || '' }),
settings: {
...form.settings,
...settings,
aspect,
seed: settings.seed ?? form.settings?.seed ?? null,
seedMode: 'locked',
loraStack
}
}
return { form: restoreLoras(next, loraStack, xaigen, memory), missing: missing[0] || '', warnings: missing }
}
export function restoreAll(form,item,xaigen=false,memory) { export function restoreAll(form,item,xaigen=false,memory) {
const restored=restoreInput(item),next={...form,...restored,settings:{...form.settings,...restored.settings,seedMode:'random'}} const restored=restoreInput(item),next={...form,...restored,settings:{...form.settings,...restored.settings,seedMode:'random'}}
if(item.kind==='clip') return {...bindClip(next,item,xaigen,memory),mode:'extend'} if(item.kind==='clip') return {...bindClip(next,item,xaigen,memory),mode:'extend'}
+7 -1
View File
@@ -1,6 +1,12 @@
export const styleGroups={Render:['Photorealistic','Pixar','3D Disney','Comic','Watercolor','Oil painting'],Quality:['Realistic skin','Skin pores','Fine detail','Natural texture'],Lighting:['Golden hour','Soft studio','Window light','Overcast','Cinematic lighting']} export const styleGroups={Render:['Photorealistic','Pixar','3D Disney','Comic','Watercolor','Oil painting'],Quality:['Realistic skin','Skin pores','Fine detail','Natural texture'],Lighting:['Golden hour','Soft studio','Window light','Overcast','Cinematic lighting']}
export const defaultNegatives=['blurry','deformed','low quality','text','watermark'] export const defaultNegatives=['blurry','deformed','low quality','text','watermark']
export function normalizeStyles(value={}) { const known=[...Object.values(styleGroups).flat(),...defaultNegatives];const positive=[...new Set((value.positive||[]).filter(s=>known.includes(s)))];return {positive,negative:[...new Set((value.negative||[]).filter(s=>known.includes(s)&&!positive.includes(s)))]} } export function normalizeStyles(value={}) {
const known=[...Object.values(styleGroups).flat(),...defaultNegatives]
const positive=[...new Set((value.positive||[]).filter(s=>known.includes(s)))]
// Freeform negatives are allowed (sticky custom chips); positives stay on the known style list.
const negative=[...new Set((value.negative||[]).map(s=>String(s||'').trim()).filter(s=>s&&!positive.includes(s)))]
return {positive,negative}
}
export function toggleStyle(value,chip,negative=false) { const next=normalizeStyles(value),key=negative?'negative':'positive',other=negative?'positive':'negative';next[other]=next[other].filter(s=>s!==chip);next[key]=next[key].includes(chip)?next[key].filter(s=>s!==chip):[...next[key],chip];return next } export function toggleStyle(value,chip,negative=false) { const next=normalizeStyles(value),key=negative?'negative':'positive',other=negative?'positive':'negative';next[other]=next[other].filter(s=>s!==chip);next[key]=next[key].includes(chip)?next[key].filter(s=>s!==chip):[...next[key],chip];return next }
export function stylePrompt(value,negative=false){return normalizeStyles(value)[negative?'negative':'positive'].join(', ')} export function stylePrompt(value,negative=false){return normalizeStyles(value)[negative?'negative':'positive'].join(', ')}
export function parseBatch(value){return String(value||'').split(/\r?\n\s*\r?\n/).map(s=>s.trim()).filter(Boolean)} export function parseBatch(value){return String(value||'').split(/\r?\n\s*\r?\n/).map(s=>s.trim()).filter(Boolean)}
+19 -1
View File
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict'
import {readFileSync} from 'node:fs' import {readFileSync} from 'node:fs'
import ts from 'typescript' import ts from 'typescript'
import {folderAssets,reconcileSelection,activeJobs,previewHidden} from '../shared/studio2/viewer.mjs' import {folderAssets,reconcileSelection,activeJobs,previewHidden} from '../shared/studio2/viewer.mjs'
import {useAsHero,useAsSource,restoreAll} from '../shared/studio2/input.mjs' import {useAsHero,useAsSource,restoreAll,hydrateStudio2Job} from '../shared/studio2/input.mjs'
import {toggleStyle,parseBatch,stylePrompt} from '../shared/studio2/styles.mjs' import {toggleStyle,parseBatch,stylePrompt} from '../shared/studio2/styles.mjs'
import {compilePrompt,validateRequest} from '../shared/studio2/contracts.mjs' import {compilePrompt,validateRequest} from '../shared/studio2/contracts.mjs'
import {cachedLoras} from '../shared/studio2/lora-cache.mjs' import {cachedLoras} from '../shared/studio2/lora-cache.mjs'
@@ -88,3 +88,21 @@ test('qwen21 supports Generate and Edit with 25/1 defaults and no locks',()=>{
assert.throws(()=>validateRequest({mode:'compose',engine:'qwen21',folderId:'f',imageAId:'s',imageBId:'b',promptSections:{action:'x'}}),/Generate and Edit/) assert.throws(()=>validateRequest({mode:'compose',engine:'qwen21',folderId:'f',imageAId:'s',imageBId:'b',promptSections:{action:'x'}}),/Generate and Edit/)
assert.throws(()=>validateRequest({mode:'iterate',engine:'qwen21',folderId:'f',imageAId:'s',promptSections:{action:'x'}}),/Generate and Edit/) assert.throws(()=>validateRequest({mode:'iterate',engine:'qwen21',folderId:'f',imageAId:'s',promptSections:{action:'x'}}),/Generate and Edit/)
}) })
test('hydrateStudio2Job restores LoRA strengths, locked seed, and clears missing stills',()=>{
const form={mode:'generate',engine:'flux',identityStillId:'',imageAId:'',promptSections:{action:''},imageStyles:{positive:[],negative:[]},settings:{aspect:'auto',seed:null,seedMode:'random',loraStack:[]},guides:[]}
const saved={mode:'edit',engine:'flux',identityStillId:'hero1',imageAId:'gone',lockFace:true,lockOutfit:false,promptSections:{action:'pokemon trainer'},imageStyles:{positive:[],negative:['blurry','custom bad']},settings:{aspect:'16:9',seed:42,steps:24,cfg:4,loraStack:[{name:'klein-pokemon.safetensors',strengthModel:1.6,strengthClip:1}]}}
const {form:next,missing}=hydrateStudio2Job(form,saved,{stillIds:new Set(['hero1']),clipIds:new Set()})
assert.equal(next.mode,'edit');assert.equal(next.identityStillId,'hero1');assert.equal(next.imageAId,'')
assert.equal(next.settings.seed,42);assert.equal(next.settings.seedMode,'locked');assert.equal(next.settings.aspect,'16:9')
assert.deepEqual(next.settings.loraStack,[{name:'klein-pokemon.safetensors',strengthModel:1.6,strengthClip:1}])
assert.deepEqual(next.imageStyles.negative,['blurry','custom bad'])
assert.match(missing,/start missing/)
})
test('custom negative chips survive normalizeStyles and bench exposes Load actions',()=>{
let styles={positive:[],negative:['blurry','my watermark']}
styles=toggleStyle(styles,'deformed',true)
assert.deepEqual(styles.negative,['blurry','my watermark','deformed'])
const src=readFileSync(new URL('../components/studio2/Bench.vue',import.meta.url),'utf8')
assert.match(src,/loadSelectedJob/);assert.match(src,/hydrateStudio2Job/);assert.match(src,/Edit source/)
assert.match(src,/aspect:'auto'/);assert.match(src,/strip-actions/);assert.match(src,/addNegative/)
})