Add desktop Batch toggle, fix xAIGen library hide/selection, per-engine steps/CFG, and mobile prompt editor.
Desktop Batch splits only on double blank lines when enabled; library hide actually conceals thumbs and pinned clicks stick; steps/CFG restore per image model; mobile opens a near-fullscreen prompt editor.
This commit is contained in:
@@ -124,12 +124,16 @@
|
||||
ref="promptArea"
|
||||
v-model="form.promptSections.action"
|
||||
class="field-area"
|
||||
placeholder="Describe the image or the shot. Blank line between prompts for a batch."
|
||||
:placeholder="batchMode ? 'Separate prompts with two blank lines.' : 'Describe the image or the shot.'"
|
||||
@select="capturePromptCaret"
|
||||
@click="capturePromptCaret"
|
||||
@keyup="capturePromptCaret"
|
||||
/>
|
||||
<p v-if="imagePromptCount > 1" class="mt-1 text-[var(--type-sm)] text-[var(--muted)]">{{ imagePromptCount }} prompts · queued in order</p>
|
||||
<label v-if="!video && form.mode==='generate'" class="mt-2 inline-flex items-center gap-2 text-[var(--type-sm)] text-[var(--muted)]">
|
||||
<input v-model="batchMode" type="checkbox" class="accent-[var(--amber)]">Batch
|
||||
</label>
|
||||
<p v-if="batchMode && imagePromptCount > 1" class="mt-1 text-[var(--type-sm)] text-[var(--muted)]">{{ imagePromptCount }} prompts · queued in order</p>
|
||||
<p v-else-if="batchMode" class="mt-1 text-[11px] text-[var(--muted)]">Two blank lines between prompts</p>
|
||||
</label>
|
||||
<label v-if="form.engine==='qwen21' && (form.mode==='generate' || form.mode==='edit')" class="inline-flex items-center gap-2 text-[var(--type-sm)] text-[var(--muted)]">
|
||||
<input v-model="form.enhancePrompt" type="checkbox" class="accent-[var(--amber)]">Enhance prompt
|
||||
@@ -353,6 +357,7 @@
|
||||
:selected="selected"
|
||||
:folder-id="form.folderId"
|
||||
:preview-hidden="!eyeOpen"
|
||||
:is-hidden="isHidden"
|
||||
@close="libraryOpen=false"
|
||||
@select="selectOutput"
|
||||
@use-image="onLibraryUseImage"
|
||||
@@ -407,7 +412,7 @@
|
||||
<div class="queue-foot">
|
||||
<button type="button" class="generate-cta" :disabled="submitting" @click="queue">
|
||||
<span aria-hidden="true">▶</span>
|
||||
{{ submitting ? 'Queuing…' : (imagePromptCount > 1 ? `Generate · ${imagePromptCount} prompts` : 'Generate') }}
|
||||
{{ submitting ? 'Queuing…' : (batchMode && imagePromptCount > 1 ? `Generate · ${imagePromptCount} prompts` : 'Generate') }}
|
||||
</button>
|
||||
<p v-if="error" class="queue-foot-error">{{ error }}</p>
|
||||
<p class="tiny">
|
||||
@@ -481,7 +486,7 @@ import {queueJobs,jobStageLabel,jobProgressView,elapsedSeconds} from '~/shared/s
|
||||
import {gpuStatus} from '~/shared/studio2/status.mjs'
|
||||
import {CAPTION_STYLES} from '~/shared/caption.mjs'
|
||||
import {applyUploadedStill} from '~/shared/studio2/upload.mjs'
|
||||
import {defaultNegatives,parseBatch} from '~/shared/studio2/styles.mjs'
|
||||
import {defaultNegatives,parseBatch,parseBatchDoubleBlank} from '~/shared/studio2/styles.mjs'
|
||||
import {randomSeed} from '~/shared/studio2/seed.mjs'
|
||||
import {
|
||||
photosPlan,
|
||||
@@ -553,6 +558,8 @@ function benchDraftPayload(){
|
||||
fps:form.settings?.fps,
|
||||
loraStack:structuredClone(form.settings?.loraStack||[])
|
||||
},
|
||||
engineSampling:structuredClone(engineSampling),
|
||||
batchMode:!!batchMode.value,
|
||||
eyeOpen:!!eyeOpen.value,
|
||||
hideInputs:!!hideInputs.value,
|
||||
music:structuredClone({tags:music.tags,lyrics:music.lyrics,duration:music.duration,seed:music.seed,name:music.name})
|
||||
@@ -597,6 +604,15 @@ function restoreBenchDraft(){
|
||||
loraStack:Array.isArray(draft.settings.loraStack)?draft.settings.loraStack:form.settings.loraStack
|
||||
})
|
||||
}
|
||||
if(draft.engineSampling && typeof draft.engineSampling==='object'){
|
||||
for(const key of ['flux','krea','qwen21'] as const){
|
||||
const row=draft.engineSampling[key]
|
||||
if(row && Number.isFinite(Number(row.steps)) && Number.isFinite(Number(row.cfg))){
|
||||
engineSampling[key]={steps:Number(row.steps),cfg:Number(row.cfg)}
|
||||
}
|
||||
}
|
||||
}
|
||||
if(draft.batchMode!=null)batchMode.value=!!draft.batchMode
|
||||
if(draft.eyeOpen!=null)eyeOpen.value=!!draft.eyeOpen
|
||||
if(draft.hideInputs!=null)hideInputs.value=!!draft.hideInputs
|
||||
if(draft.music){
|
||||
@@ -644,6 +660,13 @@ watch(()=>[currentLoraUse.value,form.settings.loraStack],()=>loraMemory.sync(for
|
||||
const route=useRoute()
|
||||
const tasks=['generate','edit','compose','refine','video','extend','music']
|
||||
const settingsOpen=ref(false),detailsOpen=ref(false),submitting=ref(false),error=ref(''),batchText=ref('')
|
||||
const batchMode=ref(false)
|
||||
const selectionPinned=ref(false)
|
||||
const engineSampling=reactive<Record<string,{steps:number;cfg:number}>>({
|
||||
flux:{steps:24,cfg:4},
|
||||
krea:{steps:24,cfg:4},
|
||||
qwen21:{steps:25,cfg:1}
|
||||
})
|
||||
const viewerVideo=ref<HTMLVideoElement|null>(null)
|
||||
const viewerMuted=ref(false)
|
||||
const videoTime=ref(0)
|
||||
@@ -665,14 +688,32 @@ const engineOptions=computed(()=>{
|
||||
{value:'qwen21',label:'Qwen 2.1',disabled:!qwenAllowed.value,hint:qwenAllowed.value?'':'Generate and Edit only'}
|
||||
]
|
||||
})
|
||||
function defaultSamplingFor(engine:string){
|
||||
if(engine==='qwen21') return {steps:form.turbo?6:25,cfg:1}
|
||||
if(engine==='minimax' || engine==='ltx') return {steps:8,cfg:1.5}
|
||||
return {steps:24,cfg:4}
|
||||
}
|
||||
function rememberEngineSampling(engine:string){
|
||||
if(!['flux','krea','qwen21'].includes(engine)) return
|
||||
engineSampling[engine]={
|
||||
steps:Number(form.settings.steps)||defaultSamplingFor(engine).steps,
|
||||
cfg:Number(form.settings.cfg)||defaultSamplingFor(engine).cfg
|
||||
}
|
||||
}
|
||||
function applyEngineSampling(engine:string){
|
||||
const saved=engineSampling[engine]
|
||||
const fallback=defaultSamplingFor(engine)
|
||||
form.settings.steps=saved?.steps ?? fallback.steps
|
||||
form.settings.cfg=saved?.cfg ?? fallback.cfg
|
||||
}
|
||||
watch(engineOptions,opts=>{
|
||||
const current=opts.find(o=>o.value===form.engine)
|
||||
if(!current || current.disabled)form.engine=opts.find(o=>!o.disabled)?.value || form.engine
|
||||
})
|
||||
watch(()=>form.engine,(engine,prev)=>{
|
||||
if(engine==='qwen21' && prev!=='qwen21'){
|
||||
form.settings.steps=form.turbo?6:25
|
||||
form.settings.cfg=1
|
||||
if(prev && prev!==engine) rememberEngineSampling(prev)
|
||||
if(['flux','krea','qwen21'].includes(engine)) applyEngineSampling(engine)
|
||||
if(engine==='qwen21'){
|
||||
form.lockFace=false
|
||||
form.lockOutfit=false
|
||||
}
|
||||
@@ -683,11 +724,13 @@ watch(()=>form.turbo,on=>{
|
||||
if(form.engine!=='qwen21')return
|
||||
form.settings.steps=on?6:25
|
||||
form.settings.cfg=1
|
||||
rememberEngineSampling('qwen21')
|
||||
})
|
||||
watch(()=>[form.settings.steps,form.settings.cfg],()=>{
|
||||
if(['flux','krea','qwen21'].includes(form.engine)) rememberEngineSampling(form.engine)
|
||||
})
|
||||
watch(()=>[form.mode,form.engine],([mode,engine])=>{
|
||||
if(engine==='qwen21' && mode==='edit'){
|
||||
form.settings.steps=form.turbo?6:25
|
||||
form.settings.cfg=1
|
||||
form.lockFace=false
|
||||
form.lockOutfit=false
|
||||
}
|
||||
@@ -708,6 +751,7 @@ watch(()=>[form.mode,form.engine],([mode,engine])=>{
|
||||
})
|
||||
watch(()=>form.folderId,(id,prev)=>{
|
||||
selected.value=null
|
||||
selectionPinned.value=false
|
||||
// Wipe Photos only when leaving an existing folder — first restore from '' must keep the draft
|
||||
if(prev){
|
||||
form.imageAId=''
|
||||
@@ -745,10 +789,10 @@ function onStagePreviewError(){
|
||||
watch(()=>selected.value?.id,()=>{stagePreviewNonce.value=0;stagePreviewRetries.value=0})
|
||||
const folderJobs=computed(()=>jobs.value.filter(j=>j.request.folderId===form.folderId))
|
||||
const benchJobs=computed(()=>queueJobs(folderJobs.value))
|
||||
/** Desktop image generate: blank-line paragraphs in the prompt box (same parseBatch as mobile batchText). */
|
||||
/** Desktop image Batch: double blank line only, and only when the toggle is on. */
|
||||
const imagePromptParts=computed(()=>{
|
||||
if(video.value || form.mode!=='generate') return [] as string[]
|
||||
return parseBatch(form.promptSections?.action)
|
||||
if(video.value || form.mode!=='generate' || !batchMode.value) return [] as string[]
|
||||
return parseBatchDoubleBlank(form.promptSections?.action)
|
||||
})
|
||||
const imagePromptCount=computed(()=>imagePromptParts.value.length)
|
||||
const musicJobs=computed(()=>studioQueue.value
|
||||
@@ -1410,12 +1454,23 @@ function chooseTask(task:string){
|
||||
}
|
||||
return
|
||||
}
|
||||
const was=video.value;form.mode=task;form.startClipId='';if(video.value!==was){form.engine=video.value?(xaigen && form.engine==='ltx'?'ltx':'minimax'):'flux';form.settings.steps=video.value?8:24;form.settings.cfg=video.value?1.5:4}
|
||||
const was=video.value;form.mode=task;form.startClipId=''
|
||||
if(video.value!==was){
|
||||
if(was && ['flux','krea','qwen21'].includes(form.engine)) rememberEngineSampling(form.engine)
|
||||
form.engine=video.value?(xaigen && form.engine==='ltx'?'ltx':'minimax'):'flux'
|
||||
if(video.value){form.settings.steps=8;form.settings.cfg=1.5}
|
||||
else applyEngineSampling(form.engine)
|
||||
}
|
||||
if(selected.value?.kind==='track' && task!=='music') selected.value=family.value.find((a:any)=>a.kind!=='track') || null
|
||||
}
|
||||
function onTaskSelect(event:Event){chooseTask((event.target as HTMLSelectElement).value)}
|
||||
function chooseClip(id:string){if(!id){form.startClipId='';return}const clip=clips.value.find((c:any)=>c.id===id);if(clip){try{Object.assign(form,bindClip(form,clip,xaigen,loraMemory))}catch(e:any){error.value=e?.message||'Could not copy clip settings.';form.startClipId=clip.id;form.mode='extend';form.engine=xaigen && form.engine==='ltx'?'ltx':'minimax'}}}
|
||||
function selectOutput(item:any){stageMenu.value='';selected.value=item;if(form.mode==='extend' && item?.kind==='clip')chooseClip(item.id)}
|
||||
function selectOutput(item:any){
|
||||
stageMenu.value=''
|
||||
selected.value=item
|
||||
selectionPinned.value=!!item
|
||||
if(form.mode==='extend' && item?.kind==='clip')chooseClip(item.id)
|
||||
}
|
||||
function restore(item:any){Object.assign(form,restoreAll(form,item,xaigen,loraMemory));form.guides=form.guides||[];batchText.value=''}
|
||||
function focusJob(job:any){selectOutput(job.outputs?.at(-1)||null);focusedJobId.value=job.id}
|
||||
async function loadLoras(fresh=false){try{loraNames.value=await $fetch('/api/studio-2/loras'+(fresh?'?fresh=1':''))}catch{error.value='Could not refresh LoRAs. The last loaded list is still available.'}}
|
||||
@@ -1484,16 +1539,24 @@ function ingestNewOutputs(nextJobs:any[]){
|
||||
}
|
||||
}
|
||||
if(fresh){
|
||||
const existingFresh=family.value.find(a=>a.id===fresh.id)
|
||||
if(existingFresh) selected.value=existingFresh
|
||||
else{
|
||||
const kind=fresh.kind==='clip'?'clip':'still'
|
||||
selected.value={...fresh,kind,folderId:form.folderId,studio2:fresh.studio2}
|
||||
// Merge into catalog above; only auto-select when the user has not pinned a pick.
|
||||
if(!selectionPinned.value){
|
||||
const existingFresh=family.value.find(a=>a.id===fresh.id)
|
||||
if(existingFresh) selected.value=existingFresh
|
||||
else{
|
||||
const kind=fresh.kind==='clip'?'clip':'still'
|
||||
selected.value={...fresh,kind,folderId:form.folderId,studio2:fresh.studio2}
|
||||
}
|
||||
}
|
||||
}else if(selected.value){
|
||||
const next=reconcileSelection(selected.value,family.value)
|
||||
if(form.mode!=='music' && next?.kind==='track'){
|
||||
const nonTrack=family.value.find((a:any)=>a.kind!=='track') || null
|
||||
if(nonTrack && !selectionPinned.value) selected.value=nonTrack
|
||||
else if(next?.id===selected.value.id) selected.value=next
|
||||
}else if(next) selected.value=next
|
||||
}else{
|
||||
let next=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
|
||||
selected.value=reconcileSelection(null,family.value)
|
||||
}
|
||||
const newestTrack=(library.value.tracks||[]).filter((t:any)=>t.folderId===form.folderId).sort((a:any,b:any)=>(b.createdAt||0)-(a.createdAt||0))[0]
|
||||
if(form.mode==='music' && newestTrack && !seenOutputs.has(newestTrack.id)){
|
||||
@@ -1528,10 +1591,12 @@ async function queue(){
|
||||
.filter((p:any)=>p?.stillId)
|
||||
.map((p:any)=>({stillId:String(p.stillId),role:String(p.role||'change')}))
|
||||
const promptAction=String(form.promptSections?.action||'')
|
||||
// Image generate: split the prompt box on blank lines (parseBatch). Video/edit keep batchText.
|
||||
const imageParts=(!video.value && form.mode==='generate') ? parseBatch(promptAction) : null
|
||||
// Image generate + Batch on: double blank line only. Off: whole box is one prompt.
|
||||
const imageParts=(!video.value && form.mode==='generate' && batchMode.value)
|
||||
? parseBatchDoubleBlank(promptAction)
|
||||
: null
|
||||
const firstAction=imageParts?.length ? imageParts[0] : promptAction
|
||||
const imageBatch=imageParts?.length ? imageParts.slice(1) : []
|
||||
const imageBatch=imageParts && imageParts.length > 1 ? imageParts.slice(1) : []
|
||||
const promptSections=video.value
|
||||
?{
|
||||
action:promptAction,
|
||||
@@ -1600,6 +1665,7 @@ async function queue(){
|
||||
|
||||
syncPhotosBindings()
|
||||
if(form.settings.seedMode!=='locked')form.settings.seed=randomSeed()
|
||||
selectionPinned.value=false
|
||||
const body=applyPhotosToRequest({
|
||||
mode:optimisticRequest.mode==='edit' && parseBatch(batchText.value).length?'iterate':optimisticRequest.mode,
|
||||
engine:optimisticRequest.engine,
|
||||
@@ -1686,7 +1752,7 @@ async function fetchHealth(){
|
||||
watch(()=>[
|
||||
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,
|
||||
eyeOpen.value,hideInputs.value,batchMode.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
|
||||
@@ -1696,6 +1762,7 @@ onMounted(()=>{
|
||||
loraMemory=createLoraMemory(xaigen,storage);loraMemory.sync(form);form.settings.seed=randomSeed()
|
||||
// First paint in ~2s: chrome + draft. Do not await library before the prompt works.
|
||||
restoreBenchDraft()
|
||||
if(['flux','krea','qwen21'].includes(form.engine)) rememberEngineSampling(form.engine)
|
||||
if(!form.folderId && library.value.folders?.[0]?.id) form.folderId=library.value.folders[0].id
|
||||
loadRevealed(form.folderId)
|
||||
clock=setInterval(()=>{now.value=Date.now()},1000)
|
||||
|
||||
Reference in New Issue
Block a user