Compare commits

..
3 Commits
Author SHA1 Message Date
TowstyandCursor 322e5a276e Persist the bench draft and hide input thumbs only.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 08:14:13 -05:00
TowstyandCursor 5f6a7aa9bc Rename library actions and always store promptRaw on ingest.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 08:14:13 -05:00
TowstyandCursor 930bbf7c7b Keep MiniMax hold from injecting amplitude and speed.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 08:14:13 -05:00
7 changed files with 306 additions and 88 deletions
+1 -1
View File
@@ -7,7 +7,7 @@
:aria-label="asset ? 'Replace photo' : 'Choose a photo'" :aria-label="asset ? 'Replace photo' : 'Choose a photo'"
@click="$emit('pick')" @click="$emit('pick')"
> >
<span v-if="asset && hidden" class="p-2 text-center text-[var(--type-sm)] text-[var(--muted)]">Still loaded · preview hidden</span> <span v-if="asset && hidden" class="p-2 text-center text-[var(--type-sm)] text-[var(--muted)]">Photo loaded</span>
<img v-else-if="asset" :src="thumbSrc" alt=""> <img v-else-if="asset" :src="thumbSrc" alt="">
<span v-else class="inline-flex flex-col items-center gap-1 px-2 text-center"> <span v-else class="inline-flex flex-col items-center gap-1 px-2 text-center">
<span>Choose a photo</span> <span>Choose a photo</span>
+253 -45
View File
@@ -89,24 +89,13 @@
>{{ hideInputs ? 'Show inputs' : 'Hide inputs' }}</button> >{{ hideInputs ? 'Show inputs' : 'Hide inputs' }}</button>
</div> </div>
<p v-if="photosPlanState.handoffReadonly" class="text-[var(--type-sm)] text-[var(--muted)]">Continues from last frame</p> <p v-if="photosPlanState.handoffReadonly" class="text-[var(--type-sm)] text-[var(--muted)]">Continues from last frame</p>
<template v-else-if="hideInputs">
<p class="photos-collapsed">
<span>{{ photoFilledCount }} photo{{ photoFilledCount===1 ? '' : 's' }}</span>
<button
v-if="form.photos.length < photosPlanState.max"
type="button"
class="photos-add-link"
@click="hideInputs=false; addPhoto()"
>+ Add photo</button>
</p>
</template>
<template v-else> <template v-else>
<p v-if="!form.photos.length && photosPlanState.emptyHint" class="text-[var(--type-sm)] text-[var(--muted)]">{{ photosPlanState.emptyHint }}</p> <p v-if="!form.photos.length && photosPlanState.emptyHint" class="text-[var(--type-sm)] text-[var(--muted)]">{{ photosPlanState.emptyHint }}</p>
<Studio2AssetSlot <Studio2AssetSlot
v-for="(card, index) in form.photos" v-for="(card, index) in form.photos"
:key="'photo-'+index+'-'+card.stillId" :key="'photo-'+index+'-'+card.stillId"
:asset="stillById(card.stillId)" :asset="stillById(card.stillId)"
:hidden="false" :hidden="hideInputs"
:role="card.role" :role="card.role"
:role-options="photoRoleOptions" :role-options="photoRoleOptions"
@pick="picker='photo:'+index" @pick="picker='photo:'+index"
@@ -171,6 +160,9 @@
</div> </div>
</div> </div>
<Studio2StyleChips v-if="!video && form.mode!=='music'" v-model="form.imageStyles" class="mt-1" />
<Studio2PromptControls v-if="video" v-model="form.promptSections" class="mt-2" />
<div v-if="['flux','krea','qwen21'].includes(form.engine)"> <div v-if="['flux','krea','qwen21'].includes(form.engine)">
<span class="field-label">LoRA</span> <span class="field-label">LoRA</span>
<LoraSelect <LoraSelect
@@ -253,9 +245,12 @@
class="stage-actions" class="stage-actions"
aria-label="Selected still actions" aria-label="Selected still actions"
> >
<button type="button" @click.stop="applyUseAsSource">Use as photo</button> <button type="button" @click.stop="applyUseImage">Use image</button>
<button type="button" @click.stop="applyUsePrompt">Use prompt</button>
<button type="button" @click.stop="copySelectedPrompt">Copy prompt</button> <button type="button" @click.stop="copySelectedPrompt">Copy prompt</button>
<button type="button" @click.stop="loadSelectedJob">Load</button> <button type="button" @click.stop="copySelectedRawPrompt">Copy raw prompt</button>
<button type="button" @click.stop="loadInputUsed">Load input</button>
<button type="button" @click.stop="loadInputRaw">Load input raw</button>
<button type="button" class="stage-action-danger" @click.stop="deleteOutput">Delete</button> <button type="button" class="stage-action-danger" @click.stop="deleteOutput">Delete</button>
</div> </div>
</div> </div>
@@ -318,9 +313,12 @@
:preview-hidden="!eyeOpen" :preview-hidden="!eyeOpen"
@close="libraryOpen=false" @close="libraryOpen=false"
@select="selectOutput" @select="selectOutput"
@use-as-photo="onLibraryUseAsPhoto" @use-image="onLibraryUseImage"
@use-prompt="onLibraryUsePrompt"
@copy-prompt="onLibraryCopyPrompt" @copy-prompt="onLibraryCopyPrompt"
@load="onLibraryLoad" @copy-raw-prompt="onLibraryCopyRawPrompt"
@load-input="onLibraryLoadInput"
@load-input-raw="onLibraryLoadInputRaw"
@delete="onLibraryDelete" @delete="onLibraryDelete"
@toggle-preview="toggleReveal()" @toggle-preview="toggleReveal()"
/> />
@@ -451,6 +449,8 @@ import {LOAD_GENERATION_PAYLOAD_KEY} from '~/utils/queuedJob'
const NEG_STORAGE_KEY='studio2-negative-chips' const NEG_STORAGE_KEY='studio2-negative-chips'
const ENHANCE_STORAGE_KEY='studio2-qwen21-enhance-prompt' const ENHANCE_STORAGE_KEY='studio2-qwen21-enhance-prompt'
const TURBO_STORAGE_KEY='studio2-qwen21-turbo' const TURBO_STORAGE_KEY='studio2-qwen21-turbo'
const BENCH_DRAFT_KEY='aigen:bench-draft'
let draftTimer:ReturnType<typeof setTimeout>|null=null
function readStoredNegatives(){ function readStoredNegatives(){
if(!import.meta.client)return [...defaultNegatives] if(!import.meta.client)return [...defaultNegatives]
try{ try{
@@ -480,6 +480,84 @@ function writeStoredTurbo(on:boolean){
if(!import.meta.client)return if(!import.meta.client)return
try{localStorage.setItem(TURBO_STORAGE_KEY,on?'1':'0')}catch{/* ignore */} try{localStorage.setItem(TURBO_STORAGE_KEY,on?'1':'0')}catch{/* ignore */}
} }
function benchDraftPayload(){
return {
v:1,
mode:form.mode,
engine:form.engine,
folderId:form.folderId,
enhancePrompt:!!form.enhancePrompt,
turbo:!!form.turbo,
lockFace:!!form.lockFace,
lockOutfit:!!form.lockOutfit,
promptSections:structuredClone(form.promptSections||{}),
photos:(form.photos||[]).filter((p:any)=>p?.stillId).map((p:any)=>({stillId:p.stillId,role:p.role})),
imageStyles:structuredClone(form.imageStyles||{positive:[],negative:[]}),
settings:{
aspect:form.settings?.aspect||'auto',
seed:form.settings?.seed ?? null,
seedMode:form.settings?.seedMode||'random',
steps:form.settings?.steps,
cfg:form.settings?.cfg,
duration:form.settings?.duration,
fps:form.settings?.fps,
loraStack:structuredClone(form.settings?.loraStack||[])
},
eyeOpen:!!eyeOpen.value,
hideInputs:!!hideInputs.value,
music:structuredClone({tags:music.tags,lyrics:music.lyrics,duration:music.duration,seed:music.seed,name:music.name})
}
}
function saveBenchDraft(immediate=false){
if(!import.meta.client)return
const write=()=>{
try{localStorage.setItem(BENCH_DRAFT_KEY,JSON.stringify(benchDraftPayload()))}catch{/* ignore */}
}
if(draftTimer)clearTimeout(draftTimer)
if(immediate){write();return}
draftTimer=setTimeout(write,400)
}
function restoreBenchDraft(){
if(!import.meta.client)return
let raw=''
try{raw=localStorage.getItem(BENCH_DRAFT_KEY)||''}catch{return}
if(!raw)return
let draft:any
try{draft=JSON.parse(raw)}catch{return}
if(!draft||draft.v!==1)return
if(draft.mode)form.mode=draft.mode
if(draft.engine)form.engine=draft.engine
if(draft.folderId)form.folderId=draft.folderId
if(draft.enhancePrompt!=null)form.enhancePrompt=!!draft.enhancePrompt
if(draft.turbo!=null)form.turbo=!!draft.turbo
if(draft.lockFace!=null)form.lockFace=!!draft.lockFace
if(draft.lockOutfit!=null)form.lockOutfit=!!draft.lockOutfit
if(draft.promptSections)form.promptSections=sectionsFrom(draft.promptSections)
if(Array.isArray(draft.photos))form.photos=draft.photos.filter((p:any)=>p?.stillId).map((p:any)=>({stillId:String(p.stillId),role:String(p.role||'change')}))
if(draft.imageStyles)form.imageStyles={positive:draft.imageStyles.positive||[],negative:draft.imageStyles.negative||[]}
if(draft.settings){
Object.assign(form.settings,{
aspect:draft.settings.aspect||form.settings.aspect,
seed:draft.settings.seed ?? form.settings.seed,
seedMode:draft.settings.seedMode||form.settings.seedMode,
steps:draft.settings.steps ?? form.settings.steps,
cfg:draft.settings.cfg ?? form.settings.cfg,
duration:draft.settings.duration ?? form.settings.duration,
fps:draft.settings.fps ?? form.settings.fps,
loraStack:Array.isArray(draft.settings.loraStack)?draft.settings.loraStack:form.settings.loraStack
})
}
if(draft.eyeOpen!=null)eyeOpen.value=!!draft.eyeOpen
if(draft.hideInputs!=null)hideInputs.value=!!draft.hideInputs
if(draft.music){
music.tags=String(draft.music.tags||music.tags)
music.lyrics=String(draft.music.lyrics||music.lyrics)
if(draft.music.duration!=null)music.duration=Number(draft.music.duration)||music.duration
if(draft.music.seed!=null)music.seed=String(draft.music.seed)
if(draft.music.name!=null)music.name=String(draft.music.name)
}
syncPhotosBindings()
}
const xaigen=isXaigenStudio(useRuntimeConfig().public) const xaigen=isXaigenStudio(useRuntimeConfig().public)
const instanceName=String(useRuntimeConfig().public.instanceName || 'AIGen') const instanceName=String(useRuntimeConfig().public.instanceName || 'AIGen')
@@ -560,13 +638,19 @@ watch(()=>[form.mode,form.engine],([mode,engine])=>{
form.lockOutfit=false form.lockOutfit=false
} }
const plan=photosPlan({mode,engine}) const plan=photosPlan({mode,engine})
if(!plan.show){form.photos=[];form.imageAId='';form.imageBId='';if(engine==='qwen21')form.identityStillId='';return} if(!plan.show){
if(engine==='qwen21')form.identityStillId=''
// Keep photos + prompt; section may hide for this mode.
saveBenchDraft(true)
return
}
const allowed=new Set(plan.roles) const allowed=new Set(plan.roles)
form.photos=form.photos form.photos=(form.photos||[])
.filter((p:any)=>p?.stillId) .filter((p:any)=>p?.stillId)
.slice(0,plan.max) .slice(0,plan.max)
.map((p:any)=>({stillId:p.stillId,role:allowed.has(p.role)?p.role:plan.defaultRole})) .map((p:any)=>({stillId:p.stillId,role:allowed.has(p.role)?p.role:plan.defaultRole}))
syncPhotosBindings() syncPhotosBindings()
saveBenchDraft(true)
}) })
watch(()=>form.folderId,id=>{selected.value=null;form.imageAId='';form.imageBId='';form.photos=[];form.startClipId='';loadRevealed(id);if(import.meta.client && form.engine!=='qwen21')form.identityStillId=localStorage.getItem(`studio2-hero:${id}`)||''},{flush:'sync'}) watch(()=>form.folderId,id=>{selected.value=null;form.imageAId='';form.imageBId='';form.photos=[];form.startClipId='';loadRevealed(id);if(import.meta.client && form.engine!=='qwen21')form.identityStillId=localStorage.getItem(`studio2-hero:${id}`)||''},{flush:'sync'})
watch(()=>form.identityStillId,id=>{if(import.meta.client&&form.folderId&&form.engine!=='qwen21')localStorage.setItem(`studio2-hero:${form.folderId}`,id)}) watch(()=>form.identityStillId,id=>{if(import.meta.client&&form.folderId&&form.engine!=='qwen21')localStorage.setItem(`studio2-hero:${form.folderId}`,id)})
@@ -718,17 +802,20 @@ function addPhoto(){
if(form.photos.length>=plan.max)return if(form.photos.length>=plan.max)return
form.photos.push({stillId:'',role:plan.defaultRole||'change'}) form.photos.push({stillId:'',role:plan.defaultRole||'change'})
picker.value='photo:'+(form.photos.length-1) picker.value='photo:'+(form.photos.length-1)
saveBenchDraft(true)
} }
function removePhoto(index:number){ function removePhoto(index:number){
if(!Number.isInteger(index)||index<0||index>=form.photos.length)return if(!Number.isInteger(index)||index<0||index>=form.photos.length)return
form.photos.splice(index,1) form.photos.splice(index,1)
// Explicit empty photos array clears imageAId / imageBId / identityStillId. // Explicit empty photos array clears imageAId / imageBId / identityStillId.
syncPhotosBindings() syncPhotosBindings()
saveBenchDraft(true)
} }
function setPhotoRole(index:number,role:string){ function setPhotoRole(index:number,role:string){
if(!form.photos[index])return if(!form.photos[index])return
form.photos[index].role=role form.photos[index].role=role
syncPhotosBindings() syncPhotosBindings()
saveBenchDraft(true)
} }
function setPhotoStill(index:number,stillId:string){ function setPhotoStill(index:number,stillId:string){
if(!Number.isInteger(index)||!form.photos[index])return if(!Number.isInteger(index)||!form.photos[index])return
@@ -738,6 +825,7 @@ function setPhotoStill(index:number,stillId:string){
form.photos[index].stillId=stillId form.photos[index].stillId=stillId
} }
syncPhotosBindings() syncPhotosBindings()
saveBenchDraft(true)
} }
function discardEmptyPhotoDraft(index:number){ function discardEmptyPhotoDraft(index:number){
if(!Number.isInteger(index)||!form.photos[index])return if(!Number.isInteger(index)||!form.photos[index])return
@@ -765,33 +853,118 @@ function mentionPhoto(index:number){
promptCaret.value={start:next.selectionStart,end:next.selectionEnd} promptCaret.value={start:next.selectionStart,end:next.selectionEnd}
}) })
} }
function applyUseAsSource(){ function usedPromptOf(item:any){
if(!selected.value||selected.value.kind==='track')return return String(
item?.studio2?.prompt
|| item?.prompt
|| item?.studio2?.compiledPrompt
|| item?.studio2?.promptSections?.action
|| ''
).trim()
}
function rawPromptOf(item:any){
return String(item?.promptRaw || item?.studio2?.promptRaw || '').trim()
}
function sourceStillIdsOf(item:any){
const meta=item?.studio2 || {}
const ids:string[]=[]
const push=(id:any)=>{const v=String(id||'').trim();if(v && !ids.includes(v))ids.push(v)}
push(meta.sourceStillId)
push(meta.imageAId)
for(const card of meta.photos||[]) push(card?.stillId)
if(meta.imageBId) push(meta.imageBId)
return ids.filter(id=>stillById(id))
}
function useImageRole(){
const plan=photosPlanState.value const plan=photosPlanState.value
if(!plan.show||plan.handoffReadonly){ const filled=(form.photos||[]).filter((p:any)=>p?.stillId)
Object.assign(form,useAsSource(form,selected.value)) if((form.mode==='edit' || video.value) && !filled.length && plan.roles.includes('change')) return 'change'
uploadMessage.value=`Using ${assetLabel(selected.value)}.` if(plan.roles.includes('extra')) return 'extra'
if(plan.roles.includes('outfit')) return 'outfit'
return plan.defaultRole || 'change'
}
function applyUseImage(item?:any){
const asset=item || selected.value
if(!asset||asset.kind==='track')return
const plan=photosPlanState.value
const role=useImageRole()
if(!plan.show){
form.photos=[{stillId:asset.id,role}]
syncPhotosBindings()
uploadMessage.value=`Added ${assetLabel(asset)} to Photos.`
saveBenchDraft(true)
return return
} }
if(form.photos.length>=plan.max){ if(form.photos.length>=plan.max){
form.photos[form.photos.length-1]={stillId:selected.value.id,role:plan.defaultRole||form.photos.at(-1)?.role||'change'} form.photos[form.photos.length-1]={stillId:asset.id,role}
}else{ }else{
form.photos.push({stillId:selected.value.id,role:plan.defaultRole||'change'}) form.photos.push({stillId:asset.id,role})
} }
syncPhotosBindings() syncPhotosBindings()
uploadMessage.value=`Added ${assetLabel(selected.value)} to Photos.` uploadMessage.value=`Added ${assetLabel(asset)} to Photos.`
saveBenchDraft(true)
} }
async function copySelectedPrompt(){ function applyUsePrompt(item?:any){
const text=selected.value?.studio2?.prompt const asset=item || selected.value
|| selected.value?.studio2?.compiledPrompt if(!asset)return
|| selected.value?.prompt const text=usedPromptOf(asset)
|| selected.value?.studio2?.promptSections?.action form.promptSections.action=text
|| '' uploadMessage.value=text?'Prompt set from this item.':'No Used prompt on this item.'
saveBenchDraft(true)
}
async function copySelectedPrompt(item?:any){
const asset=item || selected.value
const text=usedPromptOf(asset)
try{ try{
if(import.meta.client && navigator.clipboard)await navigator.clipboard.writeText(String(text||'')) if(import.meta.client && navigator.clipboard)await navigator.clipboard.writeText(String(text||''))
uploadMessage.value=text?'Prompt copied.':'No prompt on this item.' uploadMessage.value=text?'Prompt copied.':'No prompt on this item.'
}catch{error.value='Could not copy prompt.'} }catch{error.value='Could not copy prompt.'}
} }
async function copySelectedRawPrompt(item?:any){
const asset=item || selected.value
const raw=rawPromptOf(asset)
const used=usedPromptOf(asset)
const text=raw || used
try{
if(import.meta.client && navigator.clipboard)await navigator.clipboard.writeText(String(text||''))
uploadMessage.value=raw?'Raw prompt copied.':(used?'Used prompt copied (no Typed on this item).':'No prompt on this item.')
}catch{error.value='Could not copy prompt.'}
}
function loadInputPhotos(ids:string[]){
if(!ids.length)return
const plan=photosPlanState.value
const roles=plan.roles?.length?plan.roles:['change']
form.photos=ids.slice(0,plan.max || ids.length).map((id,i)=>({
stillId:id,
role:i===0 && roles.includes('change')?'change':(roles.includes('extra')?'extra':roles[Math.min(i,roles.length-1)]||'change')
}))
syncPhotosBindings()
}
function loadInputUsed(item?:any){
const asset=item || selected.value
if(!asset)return
const ids=sourceStillIdsOf(asset)
const text=usedPromptOf(asset)
if(ids.length) loadInputPhotos(ids)
if(text) form.promptSections.action=text
uploadMessage.value=ids.length
?(text?'Input photos and Used prompt loaded.':'Input photos loaded.')
:(text?'Used prompt loaded (no source still on this item).':'Nothing to load from this item.')
saveBenchDraft(true)
}
function loadInputRaw(item?:any){
const asset=item || selected.value
if(!asset)return
const ids=sourceStillIdsOf(asset)
const raw=rawPromptOf(asset)
const used=usedPromptOf(asset)
if(ids.length) loadInputPhotos(ids)
form.promptSections.action=raw || used
uploadMessage.value=ids.length
?(raw?'Input photos and Typed prompt loaded.':(used?'Input photos loaded; Typed missing so Used was applied.':'Input photos loaded.'))
:(raw?'Typed prompt loaded (no source still).':(used?'Used prompt loaded (no Typed / source still).':'Nothing to load from this item.'))
saveBenchDraft(true)
}
function stillIdSet(){return new Set(stills.value.map((s:any)=>s.id))} 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 clipIdSet(){return new Set(clips.value.map((c:any)=>c.id))}
function applyHydratedJob(saved:any,output?:any){ function applyHydratedJob(saved:any,output?:any){
@@ -806,6 +979,7 @@ function applyHydratedJob(saved:any,output?:any){
error.value=missing||'' error.value=missing||''
if(missing)uploadMessage.value=missing if(missing)uploadMessage.value=missing
else uploadMessage.value='Job loaded into the bench.' else uploadMessage.value='Job loaded into the bench.'
saveBenchDraft(true)
} }
function loadSelectedJob(){ function loadSelectedJob(){
if(!selected.value)return if(!selected.value)return
@@ -892,17 +1066,29 @@ function stepFamily(delta:number){
const next=list[(from + delta + list.length) % list.length] const next=list[(from + delta + list.length) % list.length]
if(next) selectOutput(next) if(next) selectOutput(next)
} }
function onLibraryUseAsPhoto(item:any){ function onLibraryUseImage(item:any){
if(item)selectOutput(item) if(item)selectOutput(item)
applyUseAsSource() applyUseImage(item)
}
function onLibraryUsePrompt(item:any){
if(item)selectOutput(item)
applyUsePrompt(item)
} }
function onLibraryCopyPrompt(item:any){ function onLibraryCopyPrompt(item:any){
if(item)selectOutput(item) if(item)selectOutput(item)
void copySelectedPrompt() void copySelectedPrompt(item)
} }
function onLibraryLoad(item:any){ function onLibraryCopyRawPrompt(item:any){
if(item)selectOutput(item) if(item)selectOutput(item)
loadSelectedJob() void copySelectedRawPrompt(item)
}
function onLibraryLoadInput(item:any){
if(item)selectOutput(item)
loadInputUsed(item)
}
function onLibraryLoadInputRaw(item:any){
if(item)selectOutput(item)
loadInputRaw(item)
} }
function onLibraryDelete(item:any){ function onLibraryDelete(item:any){
if(item)selectOutput(item) if(item)selectOutput(item)
@@ -1080,14 +1266,23 @@ async function refresh(){
selected.value=next selected.value=next
} }
watch(()=>form.folderId,id=>{selected.value=null;form.imageAId='';form.imageBId='';form.photos=[];form.startClipId='';loadRevealed(id);if(import.meta.client && form.engine!=='qwen21')form.identityStillId=localStorage.getItem(`studio2-hero:${id}`)||''},{flush:'sync'}) watch(()=>[
watch(()=>form.identityStillId,id=>{if(import.meta.client&&form.folderId&&form.engine!=='qwen21')localStorage.setItem(`studio2-hero:${form.folderId}`,id)}) form.mode,form.engine,form.folderId,form.enhancePrompt,form.turbo,form.lockFace,form.lockOutfit,
form.promptSections,form.photos,form.imageStyles,form.settings,
eyeOpen.value,hideInputs.value,
music.tags,music.lyrics,music.duration,music.seed,music.name
],()=>saveBenchDraft(),{deep:true})
let timer:ReturnType<typeof setTimeout>,clock:ReturnType<typeof setInterval>,disposed=false let timer:ReturnType<typeof setTimeout>,clock:ReturnType<typeof setInterval>,disposed=false
onMounted(async()=>{ onMounted(async()=>{
let storage;try{storage=localStorage}catch{} let storage;try{storage=localStorage}catch{}
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')
restoreBenchDraft()
if(!form.folderId)form.folderId=library.value.folders[0]?.id||''
// Folder watch may have cleared photos when folderId was assigned — re-apply draft photos/prompt.
restoreBenchDraft()
loadRevealed(form.folderId)
await applyIncomingGenerationLoad() 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)
@@ -1098,7 +1293,11 @@ onMounted(async()=>{
}) })
onBeforeUnmount(()=>{ onBeforeUnmount(()=>{
disposed=true;clearTimeout(timer);clearInterval(clock) disposed=true;clearTimeout(timer);clearInterval(clock)
if(import.meta.client)window.removeEventListener('keydown',onStageHotkey) if(draftTimer)clearTimeout(draftTimer)
if(import.meta.client){
saveBenchDraft(true)
window.removeEventListener('keydown',onStageHotkey)
}
}) })
function randomizeSeed(){form.settings.seedMode='random';form.settings.seed=randomSeed()} function randomizeSeed(){form.settings.seedMode='random';form.settings.seed=randomSeed()}
@@ -1128,7 +1327,7 @@ const mobileCtx=computed(()=>({
photoFilledCount:photoFilledCount.value, photoFilledCount:photoFilledCount.value,
openLibrary:()=>{libraryOpen.value=true}, openLibrary:()=>{libraryOpen.value=true},
closeLibrary:()=>{libraryOpen.value=false}, closeLibrary:()=>{libraryOpen.value=false},
toggleHideInputs:()=>{hideInputs.value=!hideInputs.value}, toggleHideInputs:()=>{hideInputs.value=!hideInputs.value;saveBenchDraft(true)},
showInputsAndAddPhoto:()=>{hideInputs.value=false;addPhoto()}, showInputsAndAddPhoto:()=>{hideInputs.value=false;addPhoto()},
sourceSlot:sourceSlot.value, sourceSlot:sourceSlot.value,
video:video.value, video:video.value,
@@ -1168,12 +1367,21 @@ const mobileCtx=computed(()=>({
removePhoto, removePhoto,
setPhotoRole, setPhotoRole,
mentionPhoto, mentionPhoto,
applyUseAsSource, applyUseAsSource:applyUseImage,
applyUseImage,
applyUsePrompt,
copySelectedPrompt, copySelectedPrompt,
copySelectedRawPrompt,
loadInputUsed,
loadInputRaw,
loadSelectedJob, loadSelectedJob,
onLibraryUseAsPhoto, onLibraryUseAsPhoto:onLibraryUseImage,
onLibraryUseImage,
onLibraryUsePrompt,
onLibraryCopyPrompt, onLibraryCopyPrompt,
onLibraryLoad, onLibraryCopyRawPrompt,
onLibraryLoadInput,
onLibraryLoadInputRaw,
onLibraryDelete, onLibraryDelete,
randomizeSeed, randomizeSeed,
setPicker, setPicker,
+11 -5
View File
@@ -97,9 +97,12 @@
<aside v-if="detailAsset" class="lib-modal-detail"> <aside v-if="detailAsset" class="lib-modal-detail">
<p class="lib-modal-detail-name">{{ detailLabel }}</p> <p class="lib-modal-detail-name">{{ detailLabel }}</p>
<div v-if="detailAsset.kind !== 'track'" class="lib-modal-actions"> <div v-if="detailAsset.kind !== 'track'" class="lib-modal-actions">
<button type="button" @click="$emit('use-as-photo', detailAsset)">Use as photo</button> <button type="button" @click="$emit('use-image', detailAsset)">Use image</button>
<button type="button" @click="$emit('use-prompt', detailAsset)">Use prompt</button>
<button type="button" @click="$emit('copy-prompt', detailAsset)">Copy prompt</button> <button type="button" @click="$emit('copy-prompt', detailAsset)">Copy prompt</button>
<button type="button" @click="$emit('load', detailAsset)">Load</button> <button type="button" @click="$emit('copy-raw-prompt', detailAsset)">Copy raw prompt</button>
<button type="button" @click="$emit('load-input', detailAsset)">Load input</button>
<button type="button" @click="$emit('load-input-raw', detailAsset)">Load input raw</button>
<button type="button" @click="$emit('toggle-preview')">{{ previewHidden ? 'Show output' : 'Hide output' }}</button> <button type="button" @click="$emit('toggle-preview')">{{ previewHidden ? 'Show output' : 'Hide output' }}</button>
<button type="button" class="danger" @click="onDeleteClick">Delete</button> <button type="button" class="danger" @click="onDeleteClick">Delete</button>
<button type="button" class="primary" @click="useAndClose">Use and close</button> <button type="button" class="primary" @click="useAndClose">Use and close</button>
@@ -139,9 +142,12 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
close: [] close: []
select: [asset: any] select: [asset: any]
'use-as-photo': [asset: any] 'use-image': [asset: any]
'use-prompt': [asset: any]
'copy-prompt': [asset: any] 'copy-prompt': [asset: any]
load: [asset: any] 'copy-raw-prompt': [asset: any]
'load-input': [asset: any]
'load-input-raw': [asset: any]
delete: [asset: any] delete: [asset: any]
'toggle-preview': [] 'toggle-preview': []
}>() }>()
@@ -234,7 +240,7 @@ function pickEntry(entry: LibraryEntry) {
function close() { emit('close') } function close() { emit('close') }
function useAndClose() { function useAndClose() {
if (detailAsset.value) emit('select', detailAsset.value) if (detailAsset.value) emit('use-image', detailAsset.value)
close() close()
} }
+31 -35
View File
@@ -129,9 +129,12 @@
:preview-hidden="!ctx.eyeOpen" :preview-hidden="!ctx.eyeOpen"
@close="ctx.closeLibrary()" @close="ctx.closeLibrary()"
@select="ctx.selectOutput" @select="ctx.selectOutput"
@use-as-photo="ctx.onLibraryUseAsPhoto" @use-image="ctx.onLibraryUseImage"
@use-prompt="ctx.onLibraryUsePrompt"
@copy-prompt="ctx.onLibraryCopyPrompt" @copy-prompt="ctx.onLibraryCopyPrompt"
@load="ctx.onLibraryLoad" @copy-raw-prompt="ctx.onLibraryCopyRawPrompt"
@load-input="ctx.onLibraryLoadInput"
@load-input-raw="ctx.onLibraryLoadInputRaw"
@delete="ctx.onLibraryDelete" @delete="ctx.onLibraryDelete"
@toggle-preview="ctx.toggleReveal()" @toggle-preview="ctx.toggleReveal()"
/> />
@@ -150,9 +153,12 @@
<button type="button" class="m-handle" aria-label="Close" @click="sheet=''" /> <button type="button" class="m-handle" aria-label="Close" @click="sheet=''" />
<div class="m-sheet-head"><strong>Actions</strong><button type="button" @click="sheet=''">Done</button></div> <div class="m-sheet-head"><strong>Actions</strong><button type="button" @click="sheet=''">Done</button></div>
<div class="m-sheet-body m-actions-list"> <div class="m-sheet-body m-actions-list">
<button type="button" @click="runAction('use')">Use as photo</button> <button type="button" @click="runAction('use-image')">Use image</button>
<button type="button" @click="runAction('use-prompt')">Use prompt</button>
<button type="button" @click="runAction('copy')">Copy prompt</button> <button type="button" @click="runAction('copy')">Copy prompt</button>
<button type="button" @click="runAction('load')">Load</button> <button type="button" @click="runAction('copy-raw')">Copy raw prompt</button>
<button type="button" @click="runAction('load-input')">Load input</button>
<button type="button" @click="runAction('load-input-raw')">Load input raw</button>
<button type="button" class="m-danger" @click="runAction('delete')">Delete</button> <button type="button" class="m-danger" @click="runAction('delete')">Delete</button>
</div> </div>
</aside> </aside>
@@ -172,34 +178,21 @@
{{ ctx.hideInputs ? 'Show inputs' : 'Hide inputs' }} {{ ctx.hideInputs ? 'Show inputs' : 'Hide inputs' }}
</button> </button>
</div> </div>
<template v-if="ctx.hideInputs"> <p v-if="!ctx.form.photos?.length && ctx.photosPlanState.emptyHint" class="m-muted">{{ ctx.photosPlanState.emptyHint }}</p>
<p class="m-muted"> <Studio2AssetSlot
{{ ctx.photoFilledCount || 0 }} photo{{ (ctx.photoFilledCount||0)===1 ? '' : 's' }} v-for="(card, index) in ctx.form.photos"
<button :key="'m-photo-'+index+'-'+card.stillId"
v-if="(ctx.form.photos?.length||0) < ctx.photosPlanState.max" :asset="ctx.stillById(card.stillId)"
type="button" :hidden="!!ctx.hideInputs"
class="m-amber" :role="card.role"
@click="ctx.showInputsAndAddPhoto()" :role-options="ctx.photoRoleOptions"
>+ Add photo</button> @pick="ctx.setPicker('photo:'+index)"
</p> @remove="ctx.removePhoto(index)"
</template> @mention="ctx.mentionPhoto(index)"
<template v-else> @update:role="ctx.setPhotoRole(index, $event)"
<p v-if="!ctx.form.photos?.length && ctx.photosPlanState.emptyHint" class="m-muted">{{ ctx.photosPlanState.emptyHint }}</p> />
<Studio2AssetSlot <button v-if="(ctx.form.photos?.length||0) < ctx.photosPlanState.max" type="button" class="m-amber" @click="ctx.addPhoto">+ Add photo</button>
v-for="(card, index) in ctx.form.photos" <p v-if="ctx.photoRoleWarning" class="m-hint">{{ ctx.photoRoleWarning }}</p>
:key="'m-photo-'+index+'-'+card.stillId"
:asset="ctx.stillById(card.stillId)"
:hidden="false"
:role="card.role"
:role-options="ctx.photoRoleOptions"
@pick="ctx.setPicker('photo:'+index)"
@remove="ctx.removePhoto(index)"
@mention="ctx.mentionPhoto(index)"
@update:role="ctx.setPhotoRole(index, $event)"
/>
<button v-if="(ctx.form.photos?.length||0) < ctx.photosPlanState.max" type="button" class="m-amber" @click="ctx.addPhoto">+ Add photo</button>
<p v-if="ctx.photoRoleWarning" class="m-hint">{{ ctx.photoRoleWarning }}</p>
</template>
<template v-if="ctx.showIdentityLocks"> <template v-if="ctx.showIdentityLocks">
<label class="m-check"><input v-model="ctx.form.lockFace" type="checkbox"> Lock face</label> <label class="m-check"><input v-model="ctx.form.lockFace" type="checkbox"> Lock face</label>
<label class="m-check"><input v-model="ctx.form.lockOutfit" type="checkbox"> Lock outfit</label> <label class="m-check"><input v-model="ctx.form.lockOutfit" type="checkbox"> Lock outfit</label>
@@ -360,11 +353,14 @@ function toggleSheet(id: string) {
function closeMenus() { function closeMenus() {
sheet.value = '' sheet.value = ''
} }
function runAction(kind: 'use' | 'copy' | 'load' | 'delete') { function runAction(kind: 'use-image' | 'use-prompt' | 'copy' | 'copy-raw' | 'load-input' | 'load-input-raw' | 'delete') {
sheet.value = '' sheet.value = ''
if (kind === 'use') props.ctx.applyUseAsSource?.() if (kind === 'use-image') props.ctx.applyUseImage?.()
else if (kind === 'use-prompt') props.ctx.applyUsePrompt?.()
else if (kind === 'copy') props.ctx.copySelectedPrompt?.() else if (kind === 'copy') props.ctx.copySelectedPrompt?.()
else if (kind === 'load') props.ctx.loadSelectedJob?.() else if (kind === 'copy-raw') props.ctx.copySelectedRawPrompt?.()
else if (kind === 'load-input') props.ctx.loadInputUsed?.()
else if (kind === 'load-input-raw') props.ctx.loadInputRaw?.()
else if (kind === 'delete') props.ctx.deleteOutput?.() else if (kind === 'delete') props.ctx.deleteOutput?.()
} }
function onStageTouchStart(e: TouchEvent) { function onStageTouchStart(e: TouchEvent) {
+3 -1
View File
@@ -4,7 +4,8 @@
<div class="flex flex-wrap gap-2"><button v-for="(beat,i) in model.cameraBeats" :key="i" type="button" class="rounded-lg border px-3 py-2" :class="active===i?'border-amber-300 text-amber-200':'border-white/20'" @click="active=i">{{ i+1 }} · {{ beat.moves.join(' + ') }} · {{ beat.durationSec == null ? 'to end' : `${beat.durationSec}s` }}</button></div> <div class="flex flex-wrap gap-2"><button v-for="(beat,i) in model.cameraBeats" :key="i" type="button" class="rounded-lg border px-3 py-2" :class="active===i?'border-amber-300 text-amber-200':'border-white/20'" @click="active=i">{{ i+1 }} · {{ beat.moves.join(' + ') }} · {{ beat.durationSec == null ? 'to end' : `${beat.durationSec}s` }}</button></div>
<div v-if="current" class="space-y-3 rounded-xl border border-white/15 p-3"> <div v-if="current" class="space-y-3 rounded-xl border border-white/15 p-3">
<div class="flex flex-wrap gap-2"><button v-for="move in cameraMoves" :key="move" type="button" :aria-pressed="current.moves.includes(move)" class="rounded-full border px-3 py-1 text-sm" :class="current.moves.includes(move)?'border-amber-300 bg-amber-300/10 text-amber-200':'border-white/15'" @click="model.cameraBeats[active]=toggleMove(current,move)">{{ move.replaceAll('-',' ') }}</button></div> <div class="flex flex-wrap gap-2"><button v-for="move in cameraMoves" :key="move" type="button" :aria-pressed="current.moves.includes(move)" class="rounded-full border px-3 py-1 text-sm" :class="current.moves.includes(move)?'border-amber-300 bg-amber-300/10 text-amber-200':'border-white/15'" @click="model.cameraBeats[active]=toggleMove(current,move)">{{ move.replaceAll('-',' ') }}</button></div>
<div class="flex flex-wrap gap-3"><label>Amplitude<select v-model="current.amplitude" class="block rounded bg-zinc-800 p-2"><option v-for="v in ['subtle','moderate','large']" :key="v">{{ v }}</option></select></label><label>Speed<select v-model="current.speed" class="block rounded bg-zinc-800 p-2"><option v-for="v in ['slow','medium','fast']" :key="v">{{ v }}</option></select></label><label>Duration<select v-model="current.durationSec" class="block rounded bg-zinc-800 p-2"><option :value="null">To end</option><option v-for="n in 30" :key="n" :value="n/2">{{ n/2 }}s</option></select></label><button @click="model.cameraBeats.splice(active,1);active=Math.max(0,active-1)">Remove beat</button></div> <div v-if="!isHold" class="flex flex-wrap gap-3"><label>Amplitude<select v-model="current.amplitude" class="block rounded bg-zinc-800 p-2"><option v-for="v in ['subtle','moderate','large']" :key="v">{{ v }}</option></select></label><label>Speed<select v-model="current.speed" class="block rounded bg-zinc-800 p-2"><option v-for="v in ['slow','medium','fast']" :key="v">{{ v }}</option></select></label><label>Duration<select v-model="current.durationSec" class="block rounded bg-zinc-800 p-2"><option :value="null">To end</option><option v-for="n in 30" :key="n" :value="n/2">{{ n/2 }}s</option></select></label><button @click="model.cameraBeats.splice(active,1);active=Math.max(0,active-1)">Remove beat</button></div>
<div v-else class="flex flex-wrap gap-3"><button @click="model.cameraBeats.splice(active,1);active=Math.max(0,active-1)">Remove beat</button></div>
</div> </div>
<div class="flex gap-4 text-sm text-amber-200"><button @click="addBeat">+ Camera beat</button><button @click="model.cameraBeats=[];model.camera='';active=0">Clear camera</button></div> <div class="flex gap-4 text-sm text-amber-200"><button @click="addBeat">+ Camera beat</button><button @click="model.cameraBeats=[];model.camera='';active=0">Clear camera</button></div>
</fieldset> </fieldset>
@@ -17,6 +18,7 @@
import {cameraMoves,soundscapeOptions,normalizeBeat,toggleMove} from '~/shared/studio2/prompt.mjs' import {cameraMoves,soundscapeOptions,normalizeBeat,toggleMove} from '~/shared/studio2/prompt.mjs'
const model=defineModel<any>({required:true}),active=ref(0) const model=defineModel<any>({required:true}),active=ref(0)
const current=computed(()=>model.value.cameraBeats[active.value]) const current=computed(()=>model.value.cameraBeats[active.value])
const isHold=computed(()=>!!current.value?.moves?.includes?.('hold'))
function addBeat(){const last=model.value.cameraBeats.at(-1);if(last?.durationSec==null&&last)last.durationSec=2;model.value.cameraBeats.push(normalizeBeat());active.value=model.value.cameraBeats.length-1} function addBeat(){const last=model.value.cameraBeats.at(-1);if(last?.durationSec==null&&last)last.durationSec=2;model.value.cameraBeats.push(normalizeBeat());active.value=model.value.cameraBeats.length-1}
function toggleSound(sound:string){const chips=model.value.soundscapeChips;model.value.soundscapeChips=chips.includes(sound)?chips.filter((s:string)=>s!==sound):[...chips,sound]} function toggleSound(sound:string){const chips=model.value.soundscapeChips;model.value.soundscapeChips=chips.includes(sound)?chips.filter((s:string)=>s!==sound):[...chips,sound]}
</script> </script>
+2
View File
@@ -452,6 +452,8 @@ async function run(r: any, job: Job) {
r.progress = null; r.progress = null;
r.request.promptSections = prompts[r.index]; r.request.promptSections = prompts[r.index];
r.request.compiledPrompt = compilePrompt(prompts[r.index], ['video', 'extend'].includes(r.request.mode), r.request); r.request.compiledPrompt = compilePrompt(prompts[r.index], ['video', 'extend'].includes(r.request.mode), r.request);
// Typed brief before PE / TextEncode — Copy raw / Load input raw need this even when Enhance is off.
r.request.promptRaw = String(r.request.compiledPrompt || '');
update(r, 'waking'); update(r, 'waking');
await ensureComfyReady(message => emitJob(job, { type: 'status', message })); await ensureComfyReady(message => emitJob(job, { type: 'status', message }));
if (job.status === 'cancelled') if (job.status === 'cancelled')
+5 -1
View File
@@ -28,7 +28,11 @@ export function sectionsFrom(value = {}) {
export function compileSections(value) { export function compileSections(value) {
const s = sectionsFrom(value) const s = sectionsFrom(value)
const camera = s.cameraBeats.length ? ['One continuous take.', ...s.cameraBeats.map((b,i) => { const camera = s.cameraBeats.length ? ['One continuous take.', ...s.cameraBeats.map((b,i) => {
const move = b.moves[0] === 'hold' ? 'hold the camera locked on a tripod' : b.moves.map(m=>m.replaceAll('-',' ')).join(' and ') + (b.moves.length > 1 ? ' simultaneously' : '') const hold = b.moves[0] === 'hold' || b.moves.includes('hold')
if (hold) {
return `${i ? 'Then' : 'First'}, hold the camera locked on a tripod.`
}
const move = b.moves.map(m=>m.replaceAll('-',' ')).join(' and ') + (b.moves.length > 1 ? ' simultaneously' : '')
const timing = b.durationSec == null ? 'through the end of the remaining clip' : i === 0 ? `for the first ${b.durationSec} seconds` : `for ${b.durationSec} seconds` const timing = b.durationSec == null ? 'through the end of the remaining clip' : i === 0 ? `for the first ${b.durationSec} seconds` : `for ${b.durationSec} seconds`
return `${i ? 'Then' : 'First'}, ${move}, ${{subtle:'small',moderate:'medium',large:'large'}[b.amplitude]} amplitude, ${b.speed} speed, ${timing}.` return `${i ? 'Then' : 'First'}, ${move}, ${{subtle:'small',moderate:'medium',large:'large'}[b.amplitude]} amplitude, ${b.speed} speed, ${timing}.`
}), 'No cuts. No new setup.'].join(' ') : s.camera }), 'No cuts. No new setup.'].join(' ') : s.camera