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:
Towsty
2026-10-01 17:06:25 -05:00
parent 4f40641050
commit 5788d74001
6 changed files with 203 additions and 31 deletions
+88 -21
View File
@@ -124,12 +124,16 @@
ref="promptArea" ref="promptArea"
v-model="form.promptSections.action" v-model="form.promptSections.action"
class="field-area" 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" @select="capturePromptCaret"
@click="capturePromptCaret" @click="capturePromptCaret"
@keyup="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>
<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)]"> <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 <input v-model="form.enhancePrompt" type="checkbox" class="accent-[var(--amber)]">Enhance prompt
@@ -353,6 +357,7 @@
:selected="selected" :selected="selected"
:folder-id="form.folderId" :folder-id="form.folderId"
:preview-hidden="!eyeOpen" :preview-hidden="!eyeOpen"
:is-hidden="isHidden"
@close="libraryOpen=false" @close="libraryOpen=false"
@select="selectOutput" @select="selectOutput"
@use-image="onLibraryUseImage" @use-image="onLibraryUseImage"
@@ -407,7 +412,7 @@
<div class="queue-foot"> <div class="queue-foot">
<button type="button" class="generate-cta" :disabled="submitting" @click="queue"> <button type="button" class="generate-cta" :disabled="submitting" @click="queue">
<span aria-hidden="true">▶</span> <span aria-hidden="true">▶</span>
{{ submitting ? 'Queuing…' : (imagePromptCount > 1 ? `Generate · ${imagePromptCount} prompts` : 'Generate') }} {{ submitting ? 'Queuing…' : (batchMode && imagePromptCount > 1 ? `Generate · ${imagePromptCount} prompts` : 'Generate') }}
</button> </button>
<p v-if="error" class="queue-foot-error">{{ error }}</p> <p v-if="error" class="queue-foot-error">{{ error }}</p>
<p class="tiny"> <p class="tiny">
@@ -481,7 +486,7 @@ import {queueJobs,jobStageLabel,jobProgressView,elapsedSeconds} from '~/shared/s
import {gpuStatus} from '~/shared/studio2/status.mjs' import {gpuStatus} from '~/shared/studio2/status.mjs'
import {CAPTION_STYLES} from '~/shared/caption.mjs' import {CAPTION_STYLES} from '~/shared/caption.mjs'
import {applyUploadedStill} from '~/shared/studio2/upload.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 {randomSeed} from '~/shared/studio2/seed.mjs'
import { import {
photosPlan, photosPlan,
@@ -553,6 +558,8 @@ function benchDraftPayload(){
fps:form.settings?.fps, fps:form.settings?.fps,
loraStack:structuredClone(form.settings?.loraStack||[]) loraStack:structuredClone(form.settings?.loraStack||[])
}, },
engineSampling:structuredClone(engineSampling),
batchMode:!!batchMode.value,
eyeOpen:!!eyeOpen.value, eyeOpen:!!eyeOpen.value,
hideInputs:!!hideInputs.value, hideInputs:!!hideInputs.value,
music:structuredClone({tags:music.tags,lyrics:music.lyrics,duration:music.duration,seed:music.seed,name:music.name}) 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 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.eyeOpen!=null)eyeOpen.value=!!draft.eyeOpen
if(draft.hideInputs!=null)hideInputs.value=!!draft.hideInputs if(draft.hideInputs!=null)hideInputs.value=!!draft.hideInputs
if(draft.music){ if(draft.music){
@@ -644,6 +660,13 @@ watch(()=>[currentLoraUse.value,form.settings.loraStack],()=>loraMemory.sync(for
const route=useRoute() const route=useRoute()
const tasks=['generate','edit','compose','refine','video','extend','music'] const tasks=['generate','edit','compose','refine','video','extend','music']
const settingsOpen=ref(false),detailsOpen=ref(false),submitting=ref(false),error=ref(''),batchText=ref('') 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 viewerVideo=ref<HTMLVideoElement|null>(null)
const viewerMuted=ref(false) const viewerMuted=ref(false)
const videoTime=ref(0) 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'} {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=>{ watch(engineOptions,opts=>{
const current=opts.find(o=>o.value===form.engine) const current=opts.find(o=>o.value===form.engine)
if(!current || current.disabled)form.engine=opts.find(o=>!o.disabled)?.value || form.engine if(!current || current.disabled)form.engine=opts.find(o=>!o.disabled)?.value || form.engine
}) })
watch(()=>form.engine,(engine,prev)=>{ watch(()=>form.engine,(engine,prev)=>{
if(engine==='qwen21' && prev!=='qwen21'){ if(prev && prev!==engine) rememberEngineSampling(prev)
form.settings.steps=form.turbo?6:25 if(['flux','krea','qwen21'].includes(engine)) applyEngineSampling(engine)
form.settings.cfg=1 if(engine==='qwen21'){
form.lockFace=false form.lockFace=false
form.lockOutfit=false form.lockOutfit=false
} }
@@ -683,11 +724,13 @@ watch(()=>form.turbo,on=>{
if(form.engine!=='qwen21')return if(form.engine!=='qwen21')return
form.settings.steps=on?6:25 form.settings.steps=on?6:25
form.settings.cfg=1 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])=>{ watch(()=>[form.mode,form.engine],([mode,engine])=>{
if(engine==='qwen21' && mode==='edit'){ if(engine==='qwen21' && mode==='edit'){
form.settings.steps=form.turbo?6:25
form.settings.cfg=1
form.lockFace=false form.lockFace=false
form.lockOutfit=false form.lockOutfit=false
} }
@@ -708,6 +751,7 @@ watch(()=>[form.mode,form.engine],([mode,engine])=>{
}) })
watch(()=>form.folderId,(id,prev)=>{ watch(()=>form.folderId,(id,prev)=>{
selected.value=null selected.value=null
selectionPinned.value=false
// Wipe Photos only when leaving an existing folder — first restore from '' must keep the draft // Wipe Photos only when leaving an existing folder — first restore from '' must keep the draft
if(prev){ if(prev){
form.imageAId='' form.imageAId=''
@@ -745,10 +789,10 @@ function onStagePreviewError(){
watch(()=>selected.value?.id,()=>{stagePreviewNonce.value=0;stagePreviewRetries.value=0}) watch(()=>selected.value?.id,()=>{stagePreviewNonce.value=0;stagePreviewRetries.value=0})
const folderJobs=computed(()=>jobs.value.filter(j=>j.request.folderId===form.folderId)) const folderJobs=computed(()=>jobs.value.filter(j=>j.request.folderId===form.folderId))
const benchJobs=computed(()=>queueJobs(folderJobs.value)) 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(()=>{ const imagePromptParts=computed(()=>{
if(video.value || form.mode!=='generate') return [] as string[] if(video.value || form.mode!=='generate' || !batchMode.value) return [] as string[]
return parseBatch(form.promptSections?.action) return parseBatchDoubleBlank(form.promptSections?.action)
}) })
const imagePromptCount=computed(()=>imagePromptParts.value.length) const imagePromptCount=computed(()=>imagePromptParts.value.length)
const musicJobs=computed(()=>studioQueue.value const musicJobs=computed(()=>studioQueue.value
@@ -1410,12 +1454,23 @@ function chooseTask(task:string){
} }
return 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 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 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 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 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} 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.'}} 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){ if(fresh){
// 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) const existingFresh=family.value.find(a=>a.id===fresh.id)
if(existingFresh) selected.value=existingFresh if(existingFresh) selected.value=existingFresh
else{ else{
const kind=fresh.kind==='clip'?'clip':'still' const kind=fresh.kind==='clip'?'clip':'still'
selected.value={...fresh,kind,folderId:form.folderId,studio2:fresh.studio2} 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{ }else{
let next=reconcileSelection(selected.value,family.value) selected.value=reconcileSelection(null,family.value)
if(form.mode!=='music' && next?.kind==='track') next=family.value.find((a:any)=>a.kind!=='track') || null
selected.value=next
} }
const newestTrack=(library.value.tracks||[]).filter((t:any)=>t.folderId===form.folderId).sort((a:any,b:any)=>(b.createdAt||0)-(a.createdAt||0))[0] 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)){ if(form.mode==='music' && newestTrack && !seenOutputs.has(newestTrack.id)){
@@ -1528,10 +1591,12 @@ async function queue(){
.filter((p:any)=>p?.stillId) .filter((p:any)=>p?.stillId)
.map((p:any)=>({stillId:String(p.stillId),role:String(p.role||'change')})) .map((p:any)=>({stillId:String(p.stillId),role:String(p.role||'change')}))
const promptAction=String(form.promptSections?.action||'') const promptAction=String(form.promptSections?.action||'')
// Image generate: split the prompt box on blank lines (parseBatch). Video/edit keep batchText. // Image generate + Batch on: double blank line only. Off: whole box is one prompt.
const imageParts=(!video.value && form.mode==='generate') ? parseBatch(promptAction) : null const imageParts=(!video.value && form.mode==='generate' && batchMode.value)
? parseBatchDoubleBlank(promptAction)
: null
const firstAction=imageParts?.length ? imageParts[0] : promptAction 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 const promptSections=video.value
?{ ?{
action:promptAction, action:promptAction,
@@ -1600,6 +1665,7 @@ async function queue(){
syncPhotosBindings() syncPhotosBindings()
if(form.settings.seedMode!=='locked')form.settings.seed=randomSeed() if(form.settings.seedMode!=='locked')form.settings.seed=randomSeed()
selectionPinned.value=false
const body=applyPhotosToRequest({ const body=applyPhotosToRequest({
mode:optimisticRequest.mode==='edit' && parseBatch(batchText.value).length?'iterate':optimisticRequest.mode, mode:optimisticRequest.mode==='edit' && parseBatch(batchText.value).length?'iterate':optimisticRequest.mode,
engine:optimisticRequest.engine, engine:optimisticRequest.engine,
@@ -1686,7 +1752,7 @@ async function fetchHealth(){
watch(()=>[ watch(()=>[
form.mode,form.engine,form.folderId,form.enhancePrompt,form.turbo,form.lockFace,form.lockOutfit, form.mode,form.engine,form.folderId,form.enhancePrompt,form.turbo,form.lockFace,form.lockOutfit,
form.promptSections,form.photos,form.imageStyles,form.settings, 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 music.tags,music.lyrics,music.duration,music.seed,music.name
],()=>saveBenchDraft(),{deep:true}) ],()=>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
@@ -1696,6 +1762,7 @@ onMounted(()=>{
loraMemory=createLoraMemory(xaigen,storage);loraMemory.sync(form);form.settings.seed=randomSeed() 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. // First paint in ~2s: chrome + draft. Do not await library before the prompt works.
restoreBenchDraft() 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 if(!form.folderId && library.value.folders?.[0]?.id) form.folderId=library.value.folders[0].id
loadRevealed(form.folderId) loadRevealed(form.folderId)
clock=setInterval(()=>{now.value=Date.now()},1000) clock=setInterval(()=>{now.value=Date.now()},1000)
+2 -2
View File
@@ -78,14 +78,14 @@
/> />
</div> </div>
<img <img
v-else-if="liveThumbs.has(entry.key) && thumbSrc(entry)" v-else-if="!previewHidden && liveThumbs.has(entry.key) && thumbSrc(entry)"
:src="thumbSrc(entry)" :src="thumbSrc(entry)"
alt="" alt=""
loading="lazy" loading="lazy"
decoding="async" decoding="async"
draggable="false" draggable="false"
> >
<span v-else class="lib-modal-fallback" /> <span v-else class="lib-modal-fallback">{{ previewHidden ? 'Hidden' : '' }}</span>
<span class="lib-modal-kind"> <span class="lib-modal-kind">
{{ entry.kind === 'clip' ? 'Video' : entry.kind === 'track' ? 'Music' : 'Image' }} {{ entry.kind === 'clip' ? 'Video' : entry.kind === 'track' ? 'Music' : 'Image' }}
</span> </span>
+89 -1
View File
@@ -139,6 +139,7 @@
:selected="ctx.selected" :selected="ctx.selected"
:folder-id="ctx.form?.folderId" :folder-id="ctx.form?.folderId"
:preview-hidden="!ctx.eyeOpen" :preview-hidden="!ctx.eyeOpen"
:is-hidden="ctx.isHidden"
@close="ctx.closeLibrary()" @close="ctx.closeLibrary()"
@select="ctx.selectOutput" @select="ctx.selectOutput"
@use-image="ctx.onLibraryUseImage" @use-image="ctx.onLibraryUseImage"
@@ -223,7 +224,17 @@
<label class="m-label">Lyrics<textarea v-model="ctx.music.lyrics" rows="8" class="m-area" /></label> <label class="m-label">Lyrics<textarea v-model="ctx.music.lyrics" rows="8" class="m-area" /></label>
</template> </template>
<template v-else> <template v-else>
<label class="m-label">Prompt<textarea v-model="ctx.form.promptSections.action" rows="5" class="m-area" placeholder="Describe the image or the shot." /></label> <label class="m-label">Prompt
<textarea
:value="ctx.form.promptSections.action"
rows="5"
class="m-area"
placeholder="Describe the image or the shot."
readonly
@focus="openPromptEditor"
@click="openPromptEditor"
/>
</label>
<label v-if="ctx.form.engine==='qwen21' && (ctx.form.mode==='generate' || ctx.form.mode==='edit')" class="m-check"><input v-model="ctx.form.enhancePrompt" type="checkbox"> Enhance prompt</label> <label v-if="ctx.form.engine==='qwen21' && (ctx.form.mode==='generate' || ctx.form.mode==='edit')" class="m-check"><input v-model="ctx.form.enhancePrompt" type="checkbox"> Enhance prompt</label>
<label v-if="ctx.form.engine==='qwen21' && (ctx.form.mode==='generate' || ctx.form.mode==='edit')" class="m-check"><input v-model="ctx.form.turbo" type="checkbox"> Turbo</label> <label v-if="ctx.form.engine==='qwen21' && (ctx.form.mode==='generate' || ctx.form.mode==='edit')" class="m-check"><input v-model="ctx.form.turbo" type="checkbox"> Turbo</label>
<p v-if="ctx.form.engine==='qwen21' && ctx.form.turbo" class="m-muted">6-step Viggle distill. CFG stays 1. Enhance prompt recommended.</p> <p v-if="ctx.form.engine==='qwen21' && ctx.form.turbo" class="m-muted">6-step Viggle distill. CFG stays 1. Enhance prompt recommended.</p>
@@ -355,6 +366,25 @@
<button v-if="ctx.canClearFinished" type="button" class="m-link" @click="ctx.clearFinished">Clear finished</button> <button v-if="ctx.canClearFinished" type="button" class="m-link" @click="ctx.clearFinished">Clear finished</button>
</div> </div>
</aside> </aside>
<div
v-if="promptEditorOpen"
class="m-prompt-editor"
role="dialog"
aria-modal="true"
aria-label="Edit prompt"
>
<header class="m-prompt-editor-head">
<strong>Prompt</strong>
<button type="button" @click="closePromptEditor">Done</button>
</header>
<textarea
ref="promptEditorEl"
v-model="promptEditorDraft"
class="m-prompt-editor-area"
placeholder="Describe the image or the shot."
/>
</div>
</div> </div>
</template> </template>
@@ -364,12 +394,33 @@ export type Studio2MobileCtx = Record<string, any>
const props = defineProps<{ ctx: Studio2MobileCtx }>() const props = defineProps<{ ctx: Studio2MobileCtx }>()
const sheet = ref('') const sheet = ref('')
const touchStart = ref<{ x: number; y: number } | null>(null) const touchStart = ref<{ x: number; y: number } | null>(null)
const promptEditorOpen = ref(false)
const promptEditorDraft = ref('')
const promptEditorEl = ref<HTMLTextAreaElement | null>(null)
function openPromptEditor() {
promptEditorDraft.value = String(props.ctx.form?.promptSections?.action || '')
promptEditorOpen.value = true
nextTick(() => {
promptEditorEl.value?.focus()
const el = promptEditorEl.value
if (el) {
const n = el.value.length
el.setSelectionRange(n, n)
}
})
}
function closePromptEditor() {
if (props.ctx.form?.promptSections) props.ctx.form.promptSections.action = promptEditorDraft.value
promptEditorOpen.value = false
}
function toggleSheet(id: string) { function toggleSheet(id: string) {
sheet.value = sheet.value === id ? '' : id sheet.value = sheet.value === id ? '' : id
} }
function closeMenus() { function closeMenus() {
sheet.value = '' sheet.value = ''
if (promptEditorOpen.value) closePromptEditor()
} }
function runAction(kind: 'use-image' | 'use-prompt' | 'copy' | 'copy-raw' | 'load-input' | 'load-input-raw' | 'delete') { function runAction(kind: 'use-image' | 'use-prompt' | 'copy' | 'copy-raw' | 'load-input' | 'load-input-raw' | 'delete') {
sheet.value = '' sheet.value = ''
@@ -726,4 +777,41 @@ watch(() => props.ctx.form.mode, () => { sheet.value = '' })
.m-job-progress { display: flex; align-items: center; gap: 8px; margin-top: 6px; } .m-job-progress { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
.m-job-progress-bar { flex: 1; height: 4px; accent-color: var(--amber); } .m-job-progress-bar { flex: 1; height: 4px; accent-color: var(--amber); }
.m-link { border: 0; background: transparent; color: var(--amber); text-align: left; } .m-link { border: 0; background: transparent; color: var(--amber); text-align: left; }
.m-prompt-editor {
position: fixed;
inset: 0;
z-index: 90;
display: flex;
flex-direction: column;
background: #0e0e10;
padding: env(safe-area-inset-top, 0) env(safe-area-inset-right, 0) env(safe-area-inset-bottom, 0) env(safe-area-inset-left, 0);
}
.m-prompt-editor-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--line);
}
.m-prompt-editor-head button {
border: 0;
background: transparent;
color: var(--amber);
font-size: 16px;
font-weight: 600;
}
.m-prompt-editor-area {
flex: 1;
width: 100%;
margin: 0;
padding: 16px;
border: 0;
resize: none;
background: #0e0e10;
color: #f3f1ea;
font: inherit;
font-size: 17px;
line-height: 1.45;
outline: none;
}
</style> </style>
+8
View File
@@ -9,5 +9,13 @@ export function normalizeStyles(value={}) {
} }
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(', ')}
/** Single blank line (mobile / video / edit batchText). */
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)}
/**
* Desktop image Batch toggle: split only on a double blank line (two empty lines → \n\n\n+).
* A single newline or a single blank line stays inside the same prompt.
*/
export function parseBatchDoubleBlank(value){
return String(value||'').split(/\r?\n(?:[ \t]*\r?\n){2,}/).map(s=>s.trim()).filter(Boolean)
}
+4 -1
View File
@@ -6,7 +6,10 @@ export function folderAssets(library, jobs, folderId) {
export function newestOutput(jobs, seen, folderId) { export function newestOutput(jobs, seen, folderId) {
return jobs.filter(j=>j.request.folderId===folderId).flatMap(j=>j.outputs || []).filter(a=>!seen.has(a.id)).sort((a,b)=>(a.studio2?.savedAt || 0)-(b.studio2?.savedAt || 0)).at(-1) || null return jobs.filter(j=>j.request.folderId===folderId).flatMap(j=>j.outputs || []).filter(a=>!seen.has(a.id)).sort((a,b)=>(a.studio2?.savedAt || 0)-(b.studio2?.savedAt || 0)).at(-1) || null
} }
export function reconcileSelection(selected, assets) { return assets.find(a=>a.id===selected?.id) || assets.at(-1) || null } export function reconcileSelection(selected, assets) {
if (!selected?.id) return assets.at(-1) || null
return assets.find(a => a.id === selected.id) || selected
}
export function activeJobs(jobs) { return jobs.filter(j=>['waking','enhancing','submitting','rendering','saving'].includes(j.state)) } export function activeJobs(jobs) { return jobs.filter(j=>['waking','enhancing','submitting','rendering','saving'].includes(j.state)) }
export function previewHidden(asset, revealed, xaigen) { export function previewHidden(asset, revealed, xaigen) {
if (!xaigen || !asset?.id) return false if (!xaigen || !asset?.id) return false
+7 -1
View File
@@ -4,7 +4,7 @@ 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,hydrateStudio2Job} 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,parseBatchDoubleBlank,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'
import {resolveQwen21Size} from '../shared/studio2/qwen21-size.mjs' import {resolveQwen21Size} from '../shared/studio2/qwen21-size.mjs'
@@ -79,6 +79,12 @@ test('image chips compile positive and negative independently; video chips do no
assert.match(q.compiledPrompt,/Photorealistic/);assert.doesNotMatch(q.compiledPrompt,/Comic|pores|tripod|hold|VIDEO ONLY/i);assert.deepEqual(q.imageStyles,styles) assert.match(q.compiledPrompt,/Photorealistic/);assert.doesNotMatch(q.compiledPrompt,/Comic|pores|tripod|hold|VIDEO ONLY/i);assert.deepEqual(q.imageStyles,styles)
assert.doesNotMatch(compilePrompt(q.promptSections,false,q),/Picture 1/) assert.doesNotMatch(compilePrompt(q.promptSections,false,q),/Picture 1/)
assert.deepEqual(parseBatch('A wrapped\nsentence\n\nSecond shot\r\n\r\nThird'),['A wrapped\nsentence','Second shot','Third']) assert.deepEqual(parseBatch('A wrapped\nsentence\n\nSecond shot\r\n\r\nThird'),['A wrapped\nsentence','Second shot','Third'])
assert.deepEqual(
parseBatchDoubleBlank('Prompt 1 starts here\nBal blaglas\nsldkjf lkaj\n\nsldkfjs lkdfj\nsdlfkjsdkljf\n\n\nPrompt 2 starts here flaskjflaskdj'),
['Prompt 1 starts here\nBal blaglas\nsldkjf lkaj\n\nsldkfjs lkdfj\nsdlfkjsdkljf','Prompt 2 starts here flaskjflaskdj']
)
assert.deepEqual(parseBatchDoubleBlank('one\n\ntwo'),['one\n\ntwo'])
assert.deepEqual(parseBatchDoubleBlank('one\n\n\ntwo\n\n\n\nthree\n'),['one','two','three'])
}) })
test('qwen21 supports Generate and Edit with 25/1 defaults and no locks',()=>{ test('qwen21 supports Generate and Edit with 25/1 defaults and no locks',()=>{
const q=validateRequest({mode:'generate',engine:'qwen21',folderId:'f',promptSections:{action:'a red bicycle'},settings:{aspect:'1:1'}}) const q=validateRequest({mode:'generate',engine:'qwen21',folderId:'f',promptSections:{action:'a red bicycle'},settings:{aspect:'1:1'}})