Replace Hero/Edit-source chrome with Photos role cards.
Inspector stills use plain-English roles and Mention phrases; Qwen Generate hides Photos entirely. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,24 +1,53 @@
|
||||
<template>
|
||||
<div class="slot-block">
|
||||
<span class="field-label">{{ label }}</span>
|
||||
<div class="photo-card">
|
||||
<button
|
||||
type="button"
|
||||
class="slot-thumb"
|
||||
:class="asset ? (size==='hero' ? 'slot-thumb--hero' : 'slot-thumb--slot') : 'slot-thumb--empty'"
|
||||
:class="asset ? 'slot-thumb--slot' : 'slot-thumb--empty'"
|
||||
:aria-label="asset ? 'Replace photo' : 'Select photo'"
|
||||
@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>
|
||||
<img v-else-if="asset" :src="asset.kind==='clip' ? `/api/library/clips/${asset.id}/thumb` : `/api/library/stills/${asset.id}`" :alt="label">
|
||||
<img v-else-if="asset" :src="thumbSrc" alt="">
|
||||
<span v-else class="inline-flex items-center gap-1">Select <span aria-hidden="true">▾</span></span>
|
||||
</button>
|
||||
<p v-if="asset" class="slot-caption" :title="assetLabel(asset)">{{ assetLabel(asset) }}</p>
|
||||
<label v-if="upload" class="slot-upload">Upload {{ label.toLowerCase() }}
|
||||
<input type="file" accept="image/*" class="sr-only" :disabled="disabled" @change="$emit('upload',$event)">
|
||||
</label>
|
||||
<div class="photo-card-body">
|
||||
<label class="photo-role-label">
|
||||
<span class="field-label">This photo is:</span>
|
||||
<select
|
||||
class="field-control photo-role-select"
|
||||
:value="role"
|
||||
:disabled="readonly"
|
||||
@change="$emit('update:role', ($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option v-for="opt in roleOptions" :key="opt.id" :value="opt.id">{{ opt.label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="slot-actions">
|
||||
<button
|
||||
v-if="!readonly && asset"
|
||||
type="button"
|
||||
class="slot-token-btn"
|
||||
title="Mention this photo in the prompt"
|
||||
@click.stop="$emit('mention')"
|
||||
>Mention in prompt</button>
|
||||
<button v-if="!readonly" type="button" @click.stop="$emit('remove')">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {assetLabel} from '~/shared/studio2/viewer.mjs'
|
||||
withDefaults(defineProps<{label:string;asset?:any;hidden?:boolean;upload?:boolean;disabled?:boolean;size?:'hero'|'slot'}>(),{size:'slot'})
|
||||
defineEmits(['pick','upload'])
|
||||
const props = withDefaults(defineProps<{
|
||||
asset?: any
|
||||
hidden?: boolean
|
||||
role: string
|
||||
roleOptions: { id: string; label: string }[]
|
||||
readonly?: boolean
|
||||
}>(), { readonly: false, hidden: false })
|
||||
defineEmits(['pick', 'remove', 'mention', 'update:role'])
|
||||
const thumbSrc = computed(() => {
|
||||
if (!props.asset) return ''
|
||||
if (props.asset.kind === 'clip') return `/api/library/clips/${props.asset.id}/thumb`
|
||||
return `/api/library/stills/${props.asset.id}`
|
||||
})
|
||||
</script>
|
||||
|
||||
+170
-66
@@ -78,43 +78,49 @@
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<Studio2AssetSlot
|
||||
size="hero"
|
||||
:label="heroSlotLabel"
|
||||
:asset="stills.find((a:any)=>a.id===form.identityStillId)"
|
||||
:hidden="slotHidden(form.identityStillId)"
|
||||
upload
|
||||
:disabled="uploading"
|
||||
@pick="picker='identityStillId'"
|
||||
@upload="upload($event,'hero')"
|
||||
/>
|
||||
<Studio2AssetSlot
|
||||
:label="sourceSlotLabel"
|
||||
:asset="sourceSlot"
|
||||
:hidden="slotHidden(form.mode==='extend'?form.startClipId:form.imageAId)"
|
||||
upload
|
||||
:disabled="uploading"
|
||||
:class="{ 'slot-required': editSourceRequired && !form.imageAId }"
|
||||
@pick="picker=form.mode==='extend'?'startClipId':'imageAId'"
|
||||
@upload="upload($event,'source')"
|
||||
/>
|
||||
<p v-if="editSourceRequired && !form.imageAId" class="text-[11px] text-[var(--amber)]">Required for Edit</p>
|
||||
<p v-if="slotPairHint" class="text-[11px] text-[var(--muted)]">{{ slotPairHint }}</p>
|
||||
<Studio2AssetSlot v-if="form.mode==='compose'" label="Second still" :asset="stills.find((a:any)=>a.id===form.imageBId)" :hidden="slotHidden(form.imageBId)" @pick="picker='imageBId'" />
|
||||
<Studio2AssetSlot v-if="form.mode==='refine'" label="Mask" :asset="stills.find((a:any)=>a.id===form.maskId)" :hidden="slotHidden(form.maskId)" @pick="picker='maskId'" />
|
||||
<Studio2AssetSlot v-if="video" label="End still" :asset="stills.find((a:any)=>a.id===form.endStillId)" :hidden="slotHidden(form.endStillId)" @pick="picker='endStillId'" />
|
||||
<section v-if="photosPlanState.show" class="photos-section" aria-label="Photos">
|
||||
<span class="field-label">Photos</span>
|
||||
<p v-if="photosPlanState.handoffReadonly" class="text-[var(--type-sm)] text-[var(--muted)]">Continues from last frame</p>
|
||||
<template v-else>
|
||||
<p v-if="!form.photos.length && photosPlanState.emptyHint" class="text-[var(--type-sm)] text-[var(--muted)]">{{ photosPlanState.emptyHint }}</p>
|
||||
<Studio2AssetSlot
|
||||
v-for="(card, index) in form.photos"
|
||||
:key="'photo-'+index+'-'+card.stillId"
|
||||
:asset="stillById(card.stillId)"
|
||||
:hidden="slotHidden(card.stillId)"
|
||||
:role="card.role"
|
||||
:role-options="photoRoleOptions"
|
||||
@pick="picker='photo:'+index"
|
||||
@remove="removePhoto(index)"
|
||||
@mention="mentionPhoto(index)"
|
||||
@update:role="setPhotoRole(index, $event)"
|
||||
/>
|
||||
<button
|
||||
v-if="form.photos.length < photosPlanState.max"
|
||||
type="button"
|
||||
class="photos-add"
|
||||
@click="addPhoto"
|
||||
>+ Add photo</button>
|
||||
<p v-if="photoRoleWarning" class="text-[11px] text-[var(--amber)]">{{ photoRoleWarning }}</p>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<div v-if="form.engine!=='qwen21'" class="identity-slots flex flex-col gap-2 text-[var(--type-sm)] text-[var(--muted)]" aria-label="Identity locks">
|
||||
<div v-if="showIdentityLocks" class="identity-slots flex flex-col gap-2 text-[var(--type-sm)] text-[var(--muted)]" aria-label="Identity locks">
|
||||
<label class="inline-flex items-center gap-2"><input v-model="form.lockFace" type="checkbox" class="accent-[var(--amber)]">Lock face</label>
|
||||
<label class="inline-flex items-center gap-2"><input v-model="form.lockOutfit" type="checkbox" class="accent-[var(--amber)]">Lock outfit</label>
|
||||
<p class="text-[11px]">hero encode + lock text — not a MiniMax face/clothes split on stills</p>
|
||||
</div>
|
||||
<p v-else-if="form.mode==='edit'" class="text-[var(--type-sm)] text-[var(--muted)]">Qwen Edit uses Start still as <image1>. Hero is Klein-only and is ignored.</p>
|
||||
<p v-else class="text-[var(--type-sm)] text-[var(--muted)]">Qwen 2.1 — no hero, locks, or LoRA stack.</p>
|
||||
|
||||
<label>
|
||||
<span class="field-label">Prompt</span>
|
||||
<textarea v-model="form.promptSections.action" class="field-area" placeholder="Describe the image or the shot." />
|
||||
<textarea
|
||||
ref="promptArea"
|
||||
v-model="form.promptSections.action"
|
||||
class="field-area"
|
||||
placeholder="Describe the image or the shot."
|
||||
@select="capturePromptCaret"
|
||||
@click="capturePromptCaret"
|
||||
@keyup="capturePromptCaret"
|
||||
/>
|
||||
</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
|
||||
@@ -264,8 +270,7 @@
|
||||
</div>
|
||||
|
||||
<div v-if="selected && selected.kind!=='track'" class="strip-actions" aria-label="Selected still actions">
|
||||
<button type="button" @click="applyUseAsSource">Use as start / input</button>
|
||||
<button type="button" @click="applyUseAsHero">Use as hero</button>
|
||||
<button type="button" @click="applyUseAsSource">Use as photo</button>
|
||||
<button type="button" @click="copySelectedPrompt">Copy prompt</button>
|
||||
<button type="button" @click="loadSelectedJob">Load</button>
|
||||
<button type="button" @click="deleteOutput">Delete</button>
|
||||
@@ -352,7 +357,7 @@
|
||||
|
||||
<div v-if="picker" class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-6" @click.self="closePicker">
|
||||
<section role="dialog" aria-modal="true" aria-label="Choose source asset" class="max-h-[80vh] w-full max-w-4xl overflow-auto rounded-[var(--radius)] border border-[var(--line)] bg-[var(--panel)] p-5">
|
||||
<div class="mb-4 flex justify-between"><h2>Choose {{ picker==='identityStillId'?'hero':'source' }}</h2><button type="button" @click="closePicker">Close</button></div>
|
||||
<div class="mb-4 flex justify-between"><h2>Choose a photo</h2><button type="button" @click="closePicker">Close</button></div>
|
||||
<div class="mb-4 flex flex-wrap items-center gap-3">
|
||||
<button type="button" class="text-[var(--type-sm)] text-[var(--muted)]" @click="pickAsset(null)">Clear slot</button>
|
||||
<button
|
||||
@@ -380,13 +385,22 @@
|
||||
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,hydrateStudio2Job} from '~/shared/studio2/input.mjs'
|
||||
import {aspectOptions,resolveSize,bindClip,restoreAll,useAsSource,hydrateStudio2Job} from '~/shared/studio2/input.mjs'
|
||||
import {folderAssets,newestOutput,assetLabel,reconcileSelection,previewHidden} from '~/shared/studio2/viewer.mjs'
|
||||
import {queueJobs,jobStageLabel,jobProgressView,elapsedSeconds} from '~/shared/studio2/queue-view.mjs'
|
||||
import {gpuStatus} from '~/shared/studio2/status.mjs'
|
||||
import {applyUploadedStill} from '~/shared/studio2/upload.mjs'
|
||||
import {defaultNegatives,parseBatch} from '~/shared/studio2/styles.mjs'
|
||||
import {randomSeed} from '~/shared/studio2/seed.mjs'
|
||||
import {
|
||||
photosPlan,
|
||||
PHOTO_ROLE_LABEL,
|
||||
mentionPhrase,
|
||||
insertMention,
|
||||
duplicatePhotoRole,
|
||||
applyPhotosToRequest,
|
||||
photosFromRequest
|
||||
} from '~/shared/studio2/photos.mjs'
|
||||
import {DEFAULT_MUSIC_LYRICS,DEFAULT_MUSIC_TAGS} from '~/utils/music'
|
||||
import {isXaigenStudio} from '~/utils/videoModels'
|
||||
import {LOAD_GENERATION_PAYLOAD_KEY} from '~/utils/queuedJob'
|
||||
@@ -442,7 +456,9 @@ const liveMusic=ref<Record<string,any>>({})
|
||||
const negativeDraft=ref('')
|
||||
const filmstripEl=ref<HTMLElement|null>(null)
|
||||
const tileRefs=new Map<string,HTMLElement>()
|
||||
const form=reactive<any>({mode:'generate',engine:'flux',enhancePrompt:readStoredEnhancePrompt(),turbo:readStoredTurbo(),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 form=reactive<any>({mode:'generate',engine:'flux',enhancePrompt:readStoredEnhancePrompt(),turbo:readStoredTurbo(),imageStyles:{positive:[],negative:readStoredNegatives()},lockFace:true,lockOutfit:true,hideInput:xaigen,folderId:'',identityStillId:'',imageAId:'',imageBId:'',maskId:'',startClipId:'',endStillId:'',guides:[],photos:[],promptSections:sectionsFrom({}),settings:{aspect:'auto',fps:24,steps:24,cfg:4,seed:null,seedMode:'random',duration:5,turbo:true,loraStack:[]}})
|
||||
const promptArea=ref<HTMLTextAreaElement|null>(null)
|
||||
const promptCaret=ref({start:0,end:0})
|
||||
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)
|
||||
@@ -501,7 +517,17 @@ watch(()=>[form.mode,form.engine],([mode,engine])=>{
|
||||
form.lockOutfit=false
|
||||
form.settings.loraStack=[]
|
||||
}
|
||||
const plan=photosPlan({mode,engine})
|
||||
if(!plan.show){form.photos=[];form.imageAId='';form.imageBId='';if(engine==='qwen21')form.identityStillId='';return}
|
||||
const allowed=new Set(plan.roles)
|
||||
form.photos=form.photos
|
||||
.filter((p:any)=>p?.stillId)
|
||||
.slice(0,plan.max)
|
||||
.map((p:any)=>({stillId:p.stillId,role:allowed.has(p.role)?p.role:plan.defaultRole}))
|
||||
syncPhotosBindings()
|
||||
})
|
||||
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)})
|
||||
const stills=computed(()=>library.value.stills.filter((s:any)=>s.folderId===form.folderId))
|
||||
const clips=computed(()=>library.value.clips.filter((s:any)=>s.folderId===form.folderId))
|
||||
const stillUrl=(id:string)=>`/api/library/stills/${id}`
|
||||
@@ -521,25 +547,17 @@ const sourceSlot=computed(()=>{const clip=clips.value.find((a:any)=>a.id===form.
|
||||
const pickerAssets=computed(()=>picker.value==='startClipId'?clips.value.map((a:any)=>({...a,kind:'clip'})):stills.value.map((a:any)=>({...a,kind:'still'})))
|
||||
const inputStill=computed(()=>stills.value.find((s:any)=>s.id===form.imageAId))
|
||||
const family=computed(()=>folderAssets(library.value,jobs.value,form.folderId))
|
||||
const sourceAsset=computed(()=>form.startClipId?clips.value.find((c:any)=>c.id===form.startClipId):stills.value.find((s:any)=>s.id===(form.imageAId||form.identityStillId)))
|
||||
const sourceAsset=computed(()=>form.startClipId?clips.value.find((c:any)=>c.id===form.startClipId):stills.value.find((s:any)=>s.id===(form.imageAId||form.identityStillId||form.photos?.[0]?.stillId)))
|
||||
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 form.engine==='qwen21'?'Start still':'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 uses Start still as <image1>. Hero is Klein-only and is ignored.'
|
||||
return 'Klein: Hero = identity encode. Edit source = the picture you are changing.'
|
||||
const photosPlanState=computed(()=>photosPlan({mode:form.mode,engine:form.engine}))
|
||||
const photoRoleOptions=computed(()=>(photosPlanState.value.roles||[]).map((id:string)=>({id,label:PHOTO_ROLE_LABEL[id]||id})))
|
||||
const photoRoleWarning=computed(()=>{
|
||||
const dup=duplicatePhotoRole(form.photos)
|
||||
return dup?`Two photos are set to the same job (${dup}). Change one.`:''
|
||||
})
|
||||
const showIdentityLocks=computed(()=>form.engine!=='qwen21' && form.engine!=='krea' && !['music'].includes(form.mode))
|
||||
const queueCards=computed(()=>{
|
||||
const cards:any[]=[]
|
||||
const now=Date.now()
|
||||
@@ -645,15 +663,65 @@ function scrollFilmstripToNewest(){
|
||||
else if(strip)strip.scrollLeft=strip.scrollWidth
|
||||
})
|
||||
}
|
||||
function stillById(id:string){return stills.value.find((s:any)=>s.id===id)||null}
|
||||
function syncPhotosBindings(){
|
||||
const next=applyPhotosToRequest({...form,photos:form.photos})
|
||||
form.photos=next.photos||[]
|
||||
form.imageAId=next.imageAId||''
|
||||
form.imageBId=next.imageBId||''
|
||||
form.identityStillId=form.engine==='qwen21'?'':(next.identityStillId||'')
|
||||
}
|
||||
function addPhoto(){
|
||||
const plan=photosPlanState.value
|
||||
if(!plan.show||form.photos.length>=plan.max)return
|
||||
picker.value='photo-add'
|
||||
}
|
||||
function removePhoto(index:number){
|
||||
form.photos.splice(index,1)
|
||||
syncPhotosBindings()
|
||||
}
|
||||
function setPhotoRole(index:number,role:string){
|
||||
if(!form.photos[index])return
|
||||
form.photos[index].role=role
|
||||
syncPhotosBindings()
|
||||
}
|
||||
function capturePromptCaret(){
|
||||
const el=promptArea.value
|
||||
if(!el)return
|
||||
promptCaret.value={start:el.selectionStart||0,end:el.selectionEnd||0}
|
||||
}
|
||||
function mentionPhoto(index:number){
|
||||
const card=form.photos[index]
|
||||
if(!card?.stillId)return
|
||||
const phrase=mentionPhrase(card.role,index)
|
||||
const el=promptArea.value
|
||||
const start=el?.selectionStart ?? promptCaret.value.start
|
||||
const end=el?.selectionEnd ?? promptCaret.value.end
|
||||
const next=insertMention(form.promptSections.action||'',phrase,start,end)
|
||||
form.promptSections.action=next.text
|
||||
nextTick(()=>{
|
||||
const box=promptArea.value
|
||||
if(!box)return
|
||||
box.focus()
|
||||
box.setSelectionRange(next.selectionStart,next.selectionEnd)
|
||||
promptCaret.value={start:next.selectionStart,end:next.selectionEnd}
|
||||
})
|
||||
}
|
||||
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.`
|
||||
const plan=photosPlanState.value
|
||||
if(!plan.show||plan.handoffReadonly){
|
||||
Object.assign(form,useAsSource(form,selected.value))
|
||||
uploadMessage.value=`Using ${assetLabel(selected.value)}.`
|
||||
return
|
||||
}
|
||||
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'}
|
||||
}else{
|
||||
form.photos.push({stillId:selected.value.id,role:plan.defaultRole||'change'})
|
||||
}
|
||||
syncPhotosBindings()
|
||||
uploadMessage.value=`Added ${assetLabel(selected.value)} to Photos.`
|
||||
}
|
||||
async function copySelectedPrompt(){
|
||||
const text=selected.value?.studio2?.prompt
|
||||
@@ -672,6 +740,8 @@ 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||[]
|
||||
form.photos=photosFromRequest({...next,photos:saved.photos})
|
||||
syncPhotosBindings()
|
||||
batchText.value=''
|
||||
if(output)selected.value=output
|
||||
else if(form.mode==='music'){/* keep */}
|
||||
@@ -781,7 +851,26 @@ function selectOutput(item:any){selected.value=item;if(form.mode==='extend' && i
|
||||
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.'}}
|
||||
function pickAsset(asset:any){const key=picker.value;if(key.startsWith('guide:'))form.guides[Number(key.split(':')[1])].stillId=asset?.id||'';else if(key==='startClipId')chooseClip(asset?.id||'');else form[key]=asset?.id||'';closePicker()}
|
||||
function pickAsset(asset:any){
|
||||
const key=picker.value
|
||||
if(key.startsWith('guide:'))form.guides[Number(key.split(':')[1])].stillId=asset?.id||''
|
||||
else if(key==='startClipId')chooseClip(asset?.id||'')
|
||||
else if(key==='photo-add'){
|
||||
if(asset?.id){
|
||||
const plan=photosPlanState.value
|
||||
form.photos.push({stillId:asset.id,role:plan.defaultRole||'change'})
|
||||
syncPhotosBindings()
|
||||
}
|
||||
}else if(key.startsWith('photo:')){
|
||||
const index=Number(key.slice(6))
|
||||
if(Number.isInteger(index)&&form.photos[index]){
|
||||
if(asset?.id)form.photos[index].stillId=asset.id
|
||||
else form.photos.splice(index,1)
|
||||
syncPhotosBindings()
|
||||
}
|
||||
}else if(key) form[key]=asset?.id||''
|
||||
closePicker()
|
||||
}
|
||||
async function clearFinished(){await $fetch('/api/studio-2/clear-finished',{method:'POST'});await refresh()}
|
||||
async function deleteOutput(){
|
||||
if(!selected.value)return
|
||||
@@ -799,8 +888,19 @@ async function queue(){
|
||||
await refresh()
|
||||
return
|
||||
}
|
||||
if(photoRoleWarning.value){error.value=photoRoleWarning.value;return}
|
||||
syncPhotosBindings()
|
||||
if(form.settings.seedMode!=='locked')form.settings.seed=randomSeed()
|
||||
const queued=await $fetch<any>('/api/studio-2/jobs',{method:'POST',body:{...form,mode:form.mode==='edit' && parseBatch(batchText.value).length?'iterate':form.mode,hideInput:xaigen,hideOutput:false,promptSections:video.value?form.promptSections:{action:form.promptSections.action},settings:{...form.settings,...resolvedSize.value},batch:video.value || form.mode==='edit'?parseBatch(batchText.value):[]}})
|
||||
const body=applyPhotosToRequest({
|
||||
...form,
|
||||
mode:form.mode==='edit' && parseBatch(batchText.value).length?'iterate':form.mode,
|
||||
hideInput:xaigen,
|
||||
hideOutput:false,
|
||||
promptSections:video.value?form.promptSections:{action:form.promptSections.action},
|
||||
settings:{...form.settings,...resolvedSize.value},
|
||||
batch:video.value || form.mode==='edit'?parseBatch(batchText.value):[]
|
||||
})
|
||||
const queued=await $fetch<any>('/api/studio-2/jobs',{method:'POST',body})
|
||||
form.settings.seed=queued.seed;form.settings.width=queued.width;form.settings.height=queued.height
|
||||
await refresh()
|
||||
}catch(e:any){error.value=e.data?.statusMessage||e.message}
|
||||
@@ -847,8 +947,8 @@ async function refresh(){
|
||||
else if(selected.value?.id===family.value.at(-1)?.id) scrollFilmstripToNewest()
|
||||
}
|
||||
|
||||
watch(()=>form.folderId,id=>{selected.value=null;form.imageAId='';form.startClipId='';loadRevealed(id);if(import.meta.client)form.identityStillId=localStorage.getItem(`studio2-hero:${id}`)||''},{flush:'sync'})
|
||||
watch(()=>form.identityStillId,id=>{if(import.meta.client&&form.folderId)localStorage.setItem(`studio2-hero:${form.folderId}`,id)})
|
||||
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)})
|
||||
let timer:ReturnType<typeof setTimeout>,clock:ReturnType<typeof setInterval>,disposed=false
|
||||
onMounted(async()=>{
|
||||
let storage;try{storage=localStorage}catch{}
|
||||
@@ -894,10 +994,10 @@ const mobileCtx=computed(()=>({
|
||||
batchText:batchText.value,
|
||||
negativeChips:negativeChips.value,
|
||||
negativeDraft:negativeDraft.value,
|
||||
heroSlotLabel:heroSlotLabel.value,
|
||||
sourceSlotLabel:sourceSlotLabel.value,
|
||||
slotPairHint:slotPairHint.value,
|
||||
editSourceRequired:editSourceRequired.value,
|
||||
photosPlanState:photosPlanState.value,
|
||||
photoRoleOptions:photoRoleOptions.value,
|
||||
photoRoleWarning:photoRoleWarning.value,
|
||||
showIdentityLocks:showIdentityLocks.value,
|
||||
pickerLoras:pickerLoras.value,
|
||||
currentLoraUse:currentLoraUse.value,
|
||||
loraAliases:{...loraNames.value.aliases,...library.value.loraAliases},
|
||||
@@ -906,6 +1006,7 @@ const mobileCtx=computed(()=>({
|
||||
stillUrl,
|
||||
isHidden,
|
||||
slotHidden,
|
||||
stillById,
|
||||
toggleReveal,
|
||||
chooseTask,
|
||||
selectOutput,
|
||||
@@ -917,8 +1018,11 @@ const mobileCtx=computed(()=>({
|
||||
clearFinished,
|
||||
removeNegative,
|
||||
addNegative,
|
||||
addPhoto,
|
||||
removePhoto,
|
||||
setPhotoRole,
|
||||
mentionPhoto,
|
||||
applyUseAsSource,
|
||||
applyUseAsHero,
|
||||
copySelectedPrompt,
|
||||
loadSelectedJob,
|
||||
randomizeSeed,
|
||||
|
||||
@@ -108,15 +108,14 @@
|
||||
</div>
|
||||
|
||||
<div v-if="ctx.selected && ctx.selected.kind!=='track'" class="m-strip-actions">
|
||||
<button type="button" @click="ctx.applyUseAsSource">Use as start</button>
|
||||
<button type="button" @click="ctx.applyUseAsHero">Use as hero</button>
|
||||
<button type="button" @click="ctx.applyUseAsSource">Use as photo</button>
|
||||
<button type="button" @click="ctx.copySelectedPrompt">Copy prompt</button>
|
||||
<button type="button" @click="ctx.loadSelectedJob">Load</button>
|
||||
<button type="button" @click="ctx.deleteOutput">Delete</button>
|
||||
</div>
|
||||
|
||||
<div class="m-dock">
|
||||
<button type="button" :class="{ on: sheet==='slots' }" @click="toggleSheet('slots')">Slots</button>
|
||||
<button type="button" :class="{ on: sheet==='slots' }" @click="toggleSheet('slots')">Photos</button>
|
||||
<button type="button" :class="{ on: sheet==='prompt' }" @click="toggleSheet('prompt')">Prompt</button>
|
||||
<button type="button" :class="{ on: sheet==='look' }" @click="toggleSheet('look')">Look</button>
|
||||
<button type="button" :class="{ on: sheet==='gen' }" @click="toggleSheet('gen')">Gen</button>
|
||||
@@ -125,24 +124,36 @@
|
||||
|
||||
<!-- Sheets -->
|
||||
<div v-if="sheet" class="m-sheet-backdrop" @click="sheet=''" />
|
||||
<aside v-if="sheet==='slots'" class="m-sheet" role="dialog" aria-label="Slots">
|
||||
<aside v-if="sheet==='slots'" class="m-sheet" role="dialog" aria-label="Photos">
|
||||
<button type="button" class="m-handle" aria-label="Close" @click="sheet=''" />
|
||||
<div class="m-sheet-head"><strong>Slots</strong><button type="button" @click="sheet=''">Done</button></div>
|
||||
<div class="m-sheet-head"><strong>Photos</strong><button type="button" @click="sheet=''">Done</button></div>
|
||||
<div class="m-sheet-body">
|
||||
<template v-if="ctx.form.mode==='music'">
|
||||
<p class="m-muted">Music uses tags + lyrics in Prompt / Gen — no hero slot.</p>
|
||||
<template v-if="ctx.form.mode==='music' || !ctx.photosPlanState?.show">
|
||||
<p class="m-muted">No photos for this task.</p>
|
||||
</template>
|
||||
<template v-else-if="ctx.photosPlanState.handoffReadonly">
|
||||
<p class="m-muted">Continues from last frame</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Studio2AssetSlot size="hero" :label="ctx.heroSlotLabel" :asset="ctx.stills.find((a:any)=>a.id===ctx.form.identityStillId)" :hidden="ctx.slotHidden(ctx.form.identityStillId)" upload :disabled="ctx.uploading" @pick="ctx.setPicker('identityStillId')" @upload="ctx.upload($event,'hero')" />
|
||||
<Studio2AssetSlot :label="ctx.sourceSlotLabel" :asset="ctx.sourceSlot" :hidden="ctx.slotHidden(ctx.form.mode==='extend'?ctx.form.startClipId:ctx.form.imageAId)" upload :disabled="ctx.uploading" @pick="ctx.setPicker(ctx.form.mode==='extend'?'startClipId':'imageAId')" @upload="ctx.upload($event,'source')" />
|
||||
<p v-if="ctx.editSourceRequired && !ctx.form.imageAId" class="m-hint">Required for Edit</p>
|
||||
<p v-if="ctx.slotPairHint" class="m-hint">{{ ctx.slotPairHint }}</p>
|
||||
<Studio2AssetSlot v-if="ctx.form.mode==='compose'" label="Second still" :asset="ctx.stills.find((a:any)=>a.id===ctx.form.imageBId)" :hidden="ctx.slotHidden(ctx.form.imageBId)" @pick="ctx.setPicker('imageBId')" />
|
||||
<Studio2AssetSlot v-if="ctx.form.mode==='refine'" label="Mask" :asset="ctx.stills.find((a:any)=>a.id===ctx.form.maskId)" :hidden="ctx.slotHidden(ctx.form.maskId)" @pick="ctx.setPicker('maskId')" />
|
||||
<Studio2AssetSlot v-if="ctx.video" label="End still" :asset="ctx.stills.find((a:any)=>a.id===ctx.form.endStillId)" :hidden="ctx.slotHidden(ctx.form.endStillId)" @pick="ctx.setPicker('endStillId')" />
|
||||
<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>
|
||||
<p class="m-hint">hero encode + lock text — not a MiniMax face/clothes split on stills</p>
|
||||
<p v-if="!ctx.form.photos?.length && ctx.photosPlanState.emptyHint" class="m-muted">{{ ctx.photosPlanState.emptyHint }}</p>
|
||||
<Studio2AssetSlot
|
||||
v-for="(card, index) in ctx.form.photos"
|
||||
:key="'m-photo-'+index+'-'+card.stillId"
|
||||
:asset="ctx.stillById(card.stillId)"
|
||||
:hidden="ctx.slotHidden(card.stillId)"
|
||||
: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 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.lockOutfit" type="checkbox"> Lock outfit</label>
|
||||
</template>
|
||||
<p v-if="ctx.uploading || ctx.uploadMessage" class="m-amber">{{ ctx.uploading?'Uploading…':ctx.uploadMessage }}</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user