@@ -324,7 +363,7 @@
import {createLoraMemory,loraUse} from '~/shared/studio2/lora-memory.mjs'
import {filterLorasForStudio,filterLorasForUse} from '~/utils/loras'
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 {queueJobs} from '~/shared/studio2/queue-view.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 {DEFAULT_MUSIC_LYRICS,DEFAULT_MUSIC_TAGS} from '~/utils/music'
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 instanceName=String(useRuntimeConfig().public.instanceName || 'AIGen')
@@ -349,7 +404,10 @@ const loraNames=ref
({image:[],video:[]}),now=ref(Date.now()),healthState=re
const library=ref({folders:[],stills:[],clips:[],tracks:[]}),jobs=ref([]),selected=ref(null)
const studioQueue=ref([])
const liveMusic=ref>({})
-const form=reactive({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(null)
+const tileRefs=new Map()
+const form=reactive({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 tagsPlaceholder=DEFAULT_MUSIC_TAGS,lyricsPlaceholder=DEFAULT_MUSIC_LYRICS
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 resolvedSize=computed(()=>resolveSize(form.settings.aspect,sourceSize.value,undefined,form.engine))
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 . Hero is unused unless the graph maps it to .'
+ return 'Klein: Hero = identity encode. Edit source = the picture you are changing.'
+})
const queueCards=computed(()=>{
const cards:any[]=[]
for(const job of benchJobs.value){
@@ -498,7 +571,106 @@ function toggleFullscreen(){
const el=viewerVideo.value || document.querySelector('.viewer-frame')
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){
focusedJobId.value=card.id
if(card.job?.outputs?.length)focusJob(card.job)
@@ -623,6 +795,8 @@ async function refresh(){
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
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'})
@@ -633,11 +807,13 @@ onMounted(async()=>{
loraMemory=createLoraMemory(xaigen,storage);loraMemory.sync(form);form.settings.seed=randomSeed()
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)
+ await applyIncomingGenerationLoad()
const id=String(route.query.still||route.query.clip||route.query.track||'')
const asset=[...library.value.stills,...library.value.clips,...(library.value.tracks||[])].find((a:any)=>a.id===id)
if(asset){selected.value={...asset,kind:route.query.clip?'clip':route.query.track?'track':'still'};if(route.query.use==='1'&&selected.value.kind!=='track')restore(selected.value);if(xaigen)toggleReveal(asset.id)}
if(route.query.task==='music')form.mode='music'
const poll=async()=>{try{await refresh();const health=await $fetch('/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)})
@@ -669,6 +845,11 @@ const mobileCtx=computed(()=>({
error:error.value,
batchText:batchText.value,
negativeChips:negativeChips.value,
+ negativeDraft:negativeDraft.value,
+ heroSlotLabel:heroSlotLabel.value,
+ sourceSlotLabel:sourceSlotLabel.value,
+ slotPairHint:slotPairHint.value,
+ editSourceRequired:editSourceRequired.value,
pickerLoras:pickerLoras.value,
currentLoraUse:currentLoraUse.value,
loraAliases:{...loraNames.value.aliases,...library.value.loraAliases},
@@ -687,8 +868,14 @@ const mobileCtx=computed(()=>({
focusQueueCard,
clearFinished,
removeNegative,
+ addNegative,
+ applyUseAsSource,
+ applyUseAsHero,
+ copySelectedPrompt,
+ loadSelectedJob,
randomizeSeed,
setPicker,
setBatchText:(v:string)=>{batchText.value=v},
+ setNegativeDraft:(v:string)=>{negativeDraft.value=v},
}))
diff --git a/components/studio2/Mobile.vue b/components/studio2/Mobile.vue
index 6c231a3..d7fb0e1 100644
--- a/components/studio2/Mobile.vue
+++ b/components/studio2/Mobile.vue
@@ -107,6 +107,14 @@