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:
@@ -718,6 +718,63 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.photos-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.photos-add {
|
||||
align-self: flex-start;
|
||||
color: var(--amber);
|
||||
font-size: var(--type-sm);
|
||||
}
|
||||
|
||||
.photo-card {
|
||||
display: grid;
|
||||
grid-template-columns: 88px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.photo-card .slot-thumb--slot { height: 88px; }
|
||||
.photo-card .slot-thumb--empty { height: 88px; }
|
||||
|
||||
.photo-card-body {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.photo-role-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.photo-role-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.slot-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.slot-actions button,
|
||||
.slot-token-btn {
|
||||
color: var(--amber);
|
||||
font-size: var(--type-sm);
|
||||
}
|
||||
|
||||
.slot-token-btn {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.placeholder-still {
|
||||
color: var(--muted);
|
||||
font-size: var(--type-sm);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { queueSeeds } from './seed.mjs'
|
||||
import { sectionsFrom, compileSections } from './prompt.mjs'
|
||||
import { QWEN21_ASPECT_IDS, resolveQwen21Size } from './qwen21-size.mjs'
|
||||
import { QWEN21_TURBO_CFG, QWEN21_TURBO_LORA, QWEN21_TURBO_SIGMAS, QWEN21_TURBO_STEPS } from './qwen21-turbo.mjs'
|
||||
import { applyPhotosToRequest } from './photos.mjs'
|
||||
export { sectionsFrom } from './prompt.mjs'
|
||||
export const cameraFamilies = ['static','dolly in','dolly out','pan left','pan right','tilt up','tilt down','zoom in','zoom out','orbit left','orbit right']
|
||||
export const identityLock = '<Picture 1> / hero is identity for face, hair, glasses, body, and wardrobe. Keep the person and clothes fully preserved. Start frame is composition, pose, place, and camera only. Do not copy facial drift from the start frame.'
|
||||
@@ -28,7 +29,7 @@ export function restoreInput(item) {
|
||||
return { ...saved, lockFace:saved.lockFace !== false, lockOutfit:saved.lockOutfit !== false, hideInput:!!saved.hideInput, settings:structuredClone(saved.settings || {}), promptSections: sectionsFrom(saved.promptSections || { action: item.prompt || saved.compiledPrompt || '' }) }
|
||||
}
|
||||
export function validateRequest(raw, xaigen = false) {
|
||||
raw = raw || {}
|
||||
raw = applyPhotosToRequest(raw || {})
|
||||
const fail = message => { throw Object.assign(new Error(message), {statusCode:400}) }
|
||||
const mode = raw.mode || 'generate', engine = raw.engine || 'flux'
|
||||
if (!['generate','edit','compose','refine','iterate','video','extend'].includes(mode)) fail('Unknown task.')
|
||||
@@ -41,13 +42,13 @@ export function validateRequest(raw, xaigen = false) {
|
||||
if (Array.isArray(raw.guides) && raw.guides.length>8) fail('Use up to eight guide stills.')
|
||||
if (!raw.folderId) fail('Choose a folder.')
|
||||
if (['edit','compose','refine'].includes(mode) && !raw.imageAId && !(mode==='edit' && engine==='flux' && raw.identityStillId)) {
|
||||
if (engine === 'qwen21') fail('Qwen Edit requires Start still (image_1). Hero is ignored.')
|
||||
fail('Choose the source still.')
|
||||
if (engine === 'qwen21') fail('Add a photo and mark it Photo to change.')
|
||||
fail('Add a photo to change.')
|
||||
}
|
||||
if ((video || ['edit','compose','iterate'].includes(mode)) && engine !== 'qwen21' && (raw.lockFace !== false || raw.lockOutfit !== false) && !raw.identityStillId && (video || !raw.imageAId)) fail('Choose a sticky hero still.')
|
||||
if (mode === 'compose' && !raw.imageBId) fail('Compose requires a second still.')
|
||||
if ((video || ['edit','compose','iterate'].includes(mode)) && engine !== 'qwen21' && (raw.lockFace !== false || raw.lockOutfit !== false) && !raw.identityStillId && (video || !raw.imageAId)) fail('Add a Person to keep photo.')
|
||||
if (mode === 'compose' && !raw.imageBId) fail('Compose needs a second photo.')
|
||||
if (mode === 'refine' && !raw.maskId) fail('Refine requires a mask.')
|
||||
if (mode === 'extend' && !raw.startClipId) fail('Select the clip in the family strip or Previous clip.')
|
||||
if (mode === 'extend' && !raw.startClipId) fail('Select the clip in the family strip.')
|
||||
const allSections = sectionsFrom(raw.promptSections || {action:raw.prompt || ''})
|
||||
const promptSections = video ? allSections : {action:allSections.action}
|
||||
if (!promptSections.action.trim()) fail('Enter an action prompt.')
|
||||
@@ -75,6 +76,7 @@ export function validateRequest(raw, xaigen = false) {
|
||||
const steps = turbo ? QWEN21_TURBO_STEPS : number('steps',defaultSteps,1,100)
|
||||
const cfg = turbo ? QWEN21_TURBO_CFG : number('cfg',defaultCfg,0,20)
|
||||
return { mode, engine, shotSeeds, imageStyles:video?{positive:[],negative:[]}:normalizeStyles(raw.imageStyles), hideOutput:!!raw.hideOutput, enhancePrompt, turbo, ...(turbo ? { lora: QWEN21_TURBO_LORA, sigmas: QWEN21_TURBO_SIGMAS } : {}), lockFace:qwen ? false : raw.lockFace !== false, lockOutfit:qwen ? false : raw.lockOutfit !== false, hideInput:!!raw.hideInput, folderId:String(raw.folderId), identityStillId:qwen ? '' : (raw.identityStillId || ''), imageAId:raw.imageAId || '', imageBId:raw.imageBId || '', maskId:raw.maskId || '', startClipId:raw.startClipId || '', endStillId:raw.endStillId || '',
|
||||
photos: Array.isArray(raw.photos) ? raw.photos : [],
|
||||
startFrameSource: raw.startClipId ? {kind:'previous-last-frame',clipId:raw.startClipId} : {kind:'still',stillId:raw.imageAId || ''},
|
||||
promptSections, compiledPrompt:compilePrompt(promptSections,video,{...raw,engine,lockFace:qwen?false:raw.lockFace,lockOutfit:qwen?false:raw.lockOutfit}),
|
||||
settings: { aspect, fps:number('fps',24,1,60), width, height, steps, cfg, seedMode:raw.settings?.seedMode==='locked'?'locked':'random',seed:shotSeeds[0],duration:number('duration',5,1,15),turbo:raw.settings?.turbo !== false, loraStack:qwen?[]:(Array.isArray(raw.settings?.loraStack)?raw.settings.loraStack:[]) },
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
/**
|
||||
* Inspector Photos: role cards (plain English). The runner maps roles → graph sockets.
|
||||
* UI never teaches <imageN> / Hero / Edit source.
|
||||
*/
|
||||
|
||||
export const PHOTO_ROLE = {
|
||||
person: 'person',
|
||||
change: 'change',
|
||||
outfit: 'outfit',
|
||||
look: 'look',
|
||||
first: 'first',
|
||||
extra: 'extra'
|
||||
}
|
||||
|
||||
export const PHOTO_ROLE_LABEL = {
|
||||
person: 'Person to keep',
|
||||
change: 'Photo to change',
|
||||
outfit: 'Outfit / object',
|
||||
look: 'Look / style',
|
||||
first: 'First frame',
|
||||
extra: 'Extra photo'
|
||||
}
|
||||
|
||||
/** Human phrase inserted by Mention in prompt (not a Comfy token). */
|
||||
export function mentionPhrase(role, index = 0) {
|
||||
const r = String(role || '')
|
||||
if (r === PHOTO_ROLE.change) return 'this photo'
|
||||
if (r === PHOTO_ROLE.outfit) return 'the outfit photo'
|
||||
if (r === PHOTO_ROLE.person) return 'the face photo'
|
||||
if (r === PHOTO_ROLE.look) return 'the look photo'
|
||||
if (r === PHOTO_ROLE.first) return 'the first frame'
|
||||
return `Photo ${Number(index) + 1}`
|
||||
}
|
||||
|
||||
export function insertMention(text, phrase, selectionStart = 0, selectionEnd = 0) {
|
||||
const value = String(text || '')
|
||||
const chip = String(phrase || '')
|
||||
if (!chip) return { text: value, selectionStart: 0, selectionEnd: 0 }
|
||||
const existing = value.indexOf(chip)
|
||||
if (existing >= 0) {
|
||||
return { text: value, selectionStart: existing, selectionEnd: existing + chip.length }
|
||||
}
|
||||
const start = Math.max(0, Math.min(Number(selectionStart) || 0, value.length))
|
||||
const end = Math.max(start, Math.min(Number(selectionEnd) || start, value.length))
|
||||
const next = `${value.slice(0, start)}${chip}${value.slice(end)}`
|
||||
return { text: next, selectionStart: start, selectionEnd: start + chip.length }
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {{
|
||||
* show: boolean,
|
||||
* max: number,
|
||||
* roles: string[],
|
||||
* defaultRole: string,
|
||||
* emptyHint: string|null,
|
||||
* handoffReadonly: boolean
|
||||
* }}
|
||||
*/
|
||||
export function photosPlan({ mode, engine } = {}) {
|
||||
const task = String(mode || 'generate')
|
||||
const eng = String(engine || 'flux')
|
||||
|
||||
if (task === 'music') {
|
||||
return { show: false, max: 0, roles: [], defaultRole: '', emptyHint: null, handoffReadonly: false }
|
||||
}
|
||||
|
||||
if (eng === 'qwen21') {
|
||||
if (task === 'generate') {
|
||||
return { show: false, max: 0, roles: [], defaultRole: '', emptyHint: null, handoffReadonly: false }
|
||||
}
|
||||
if (task === 'edit') {
|
||||
return {
|
||||
show: true,
|
||||
max: 3,
|
||||
roles: [PHOTO_ROLE.change, PHOTO_ROLE.outfit, PHOTO_ROLE.person, PHOTO_ROLE.extra],
|
||||
defaultRole: PHOTO_ROLE.change,
|
||||
emptyHint: 'Add a photo, then say what it is.',
|
||||
handoffReadonly: false
|
||||
}
|
||||
}
|
||||
return { show: false, max: 0, roles: [], defaultRole: '', emptyHint: null, handoffReadonly: false }
|
||||
}
|
||||
|
||||
if (eng === 'krea') {
|
||||
if (task === 'generate') {
|
||||
return { show: false, max: 0, roles: [], defaultRole: '', emptyHint: null, handoffReadonly: false }
|
||||
}
|
||||
if (task === 'refine') {
|
||||
return {
|
||||
show: true,
|
||||
max: 1,
|
||||
roles: [PHOTO_ROLE.change],
|
||||
defaultRole: PHOTO_ROLE.change,
|
||||
emptyHint: 'Add a photo, then say what it is.',
|
||||
handoffReadonly: false
|
||||
}
|
||||
}
|
||||
return { show: false, max: 0, roles: [], defaultRole: '', emptyHint: null, handoffReadonly: false }
|
||||
}
|
||||
|
||||
if (task === 'extend') {
|
||||
return {
|
||||
show: true,
|
||||
max: 0,
|
||||
roles: [],
|
||||
defaultRole: '',
|
||||
emptyHint: null,
|
||||
handoffReadonly: true
|
||||
}
|
||||
}
|
||||
|
||||
if (task === 'video') {
|
||||
if (eng === 'minimax') {
|
||||
return {
|
||||
show: true,
|
||||
max: 2,
|
||||
roles: [PHOTO_ROLE.first, PHOTO_ROLE.person, PHOTO_ROLE.look],
|
||||
defaultRole: PHOTO_ROLE.first,
|
||||
emptyHint: 'Add a photo, then say what it is.',
|
||||
handoffReadonly: false
|
||||
}
|
||||
}
|
||||
// LTX: first frame only — existing start binding
|
||||
return {
|
||||
show: true,
|
||||
max: 1,
|
||||
roles: [PHOTO_ROLE.first],
|
||||
defaultRole: PHOTO_ROLE.first,
|
||||
emptyHint: 'Add a photo, then say what it is.',
|
||||
handoffReadonly: false
|
||||
}
|
||||
}
|
||||
|
||||
// Klein (flux)
|
||||
if (task === 'generate') {
|
||||
return {
|
||||
show: true,
|
||||
max: 1,
|
||||
roles: [PHOTO_ROLE.person],
|
||||
defaultRole: PHOTO_ROLE.person,
|
||||
emptyHint: 'Add a photo, then say what it is.',
|
||||
handoffReadonly: false
|
||||
}
|
||||
}
|
||||
if (task === 'edit' || task === 'iterate') {
|
||||
return {
|
||||
show: true,
|
||||
max: 2,
|
||||
roles: [PHOTO_ROLE.change, PHOTO_ROLE.person],
|
||||
defaultRole: PHOTO_ROLE.change,
|
||||
emptyHint: 'Add a photo, then say what it is.',
|
||||
handoffReadonly: false
|
||||
}
|
||||
}
|
||||
if (task === 'compose') {
|
||||
return {
|
||||
show: true,
|
||||
max: 3,
|
||||
roles: [PHOTO_ROLE.change, PHOTO_ROLE.person, PHOTO_ROLE.extra],
|
||||
defaultRole: PHOTO_ROLE.change,
|
||||
emptyHint: 'Add a photo, then say what it is.',
|
||||
handoffReadonly: false
|
||||
}
|
||||
}
|
||||
if (task === 'refine') {
|
||||
return {
|
||||
show: true,
|
||||
max: 1,
|
||||
roles: [PHOTO_ROLE.change],
|
||||
defaultRole: PHOTO_ROLE.change,
|
||||
emptyHint: 'Add a photo, then say what it is.',
|
||||
handoffReadonly: false
|
||||
}
|
||||
}
|
||||
|
||||
return { show: false, max: 0, roles: [], defaultRole: '', emptyHint: null, handoffReadonly: false }
|
||||
}
|
||||
|
||||
export function normalizePhotos(raw, plan) {
|
||||
const list = Array.isArray(raw) ? raw : []
|
||||
const roles = new Set(plan?.roles || [])
|
||||
const max = Math.max(0, Number(plan?.max) || 0)
|
||||
const out = []
|
||||
for (const row of list) {
|
||||
if (out.length >= max) break
|
||||
const stillId = String(row?.stillId || row?.id || '').trim()
|
||||
if (!stillId) continue
|
||||
let role = String(row?.role || plan?.defaultRole || '').trim()
|
||||
if (!roles.has(role)) role = plan?.defaultRole || [...roles][0] || PHOTO_ROLE.extra
|
||||
out.push({ stillId, role })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** First card wins when two share a role. Returns duplicate role label or null. */
|
||||
export function duplicatePhotoRole(photos) {
|
||||
const seen = new Set()
|
||||
for (const row of photos || []) {
|
||||
const role = String(row?.role || '')
|
||||
if (!role) continue
|
||||
if (seen.has(role)) return PHOTO_ROLE_LABEL[role] || role
|
||||
seen.add(role)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Photos cards → legacy request fields the graphs already consume.
|
||||
* Qwen Edit: change→imageAId, outfit|extra→imageBId, person→imageBId if empty.
|
||||
* Klein: person→identityStillId, change→imageAId.
|
||||
*/
|
||||
export function bindPhotosToRequest(photos, { mode, engine } = {}) {
|
||||
const task = String(mode || 'generate')
|
||||
const eng = String(engine || 'flux')
|
||||
const list = Array.isArray(photos) ? photos.filter(p => p?.stillId) : []
|
||||
const find = (...roles) => list.find(p => roles.includes(p.role))
|
||||
|
||||
let imageAId = ''
|
||||
let imageBId = ''
|
||||
let identityStillId = ''
|
||||
|
||||
if (eng === 'qwen21' && task === 'edit') {
|
||||
let change = find(PHOTO_ROLE.change)
|
||||
if (!change && list[0]) change = { ...list[0], role: PHOTO_ROLE.change }
|
||||
const outfit = find(PHOTO_ROLE.outfit, PHOTO_ROLE.extra)
|
||||
const person = find(PHOTO_ROLE.person)
|
||||
imageAId = change?.stillId || ''
|
||||
imageBId = outfit?.stillId || ''
|
||||
if (!imageBId && person) imageBId = person.stillId
|
||||
// Person with outfit already in image_2: leave as imageBId only (2-input graph).
|
||||
identityStillId = ''
|
||||
return { imageAId, imageBId, identityStillId, photos: list }
|
||||
}
|
||||
|
||||
if (eng === 'krea' && task === 'refine') {
|
||||
imageAId = find(PHOTO_ROLE.change)?.stillId || list[0]?.stillId || ''
|
||||
return { imageAId, imageBId: '', identityStillId: '', photos: list }
|
||||
}
|
||||
|
||||
if (task === 'video' || (task === 'extend' && eng === 'minimax')) {
|
||||
imageAId = find(PHOTO_ROLE.first)?.stillId || list[0]?.stillId || ''
|
||||
identityStillId = find(PHOTO_ROLE.person, PHOTO_ROLE.look)?.stillId || ''
|
||||
return { imageAId, imageBId: '', identityStillId, photos: list }
|
||||
}
|
||||
|
||||
if (eng === 'ltx' && task === 'video') {
|
||||
imageAId = find(PHOTO_ROLE.first)?.stillId || list[0]?.stillId || ''
|
||||
return { imageAId, imageBId: '', identityStillId: '', photos: list }
|
||||
}
|
||||
|
||||
// Klein / flux
|
||||
if (task === 'generate') {
|
||||
identityStillId = find(PHOTO_ROLE.person)?.stillId || list[0]?.stillId || ''
|
||||
return { imageAId: '', imageBId: '', identityStillId, photos: list }
|
||||
}
|
||||
if (task === 'edit' || task === 'iterate' || task === 'compose' || task === 'refine') {
|
||||
imageAId = find(PHOTO_ROLE.change)?.stillId || list[0]?.stillId || ''
|
||||
identityStillId = find(PHOTO_ROLE.person)?.stillId || ''
|
||||
imageBId = find(PHOTO_ROLE.extra)?.stillId || ''
|
||||
return { imageAId, imageBId, identityStillId, photos: list }
|
||||
}
|
||||
|
||||
return { imageAId, imageBId, identityStillId, photos: list }
|
||||
}
|
||||
|
||||
/** Hydrate Photos cards from legacy hero/source fields when loading a job. */
|
||||
export function photosFromRequest(request = {}) {
|
||||
if (Array.isArray(request.photos) && request.photos.length) {
|
||||
return request.photos.map(p => ({
|
||||
stillId: String(p.stillId || p.id || ''),
|
||||
role: String(p.role || PHOTO_ROLE.change)
|
||||
})).filter(p => p.stillId)
|
||||
}
|
||||
const mode = String(request.mode || 'generate')
|
||||
const engine = String(request.engine || 'flux')
|
||||
const out = []
|
||||
if (engine === 'qwen21' && mode === 'edit') {
|
||||
if (request.imageAId) out.push({ stillId: request.imageAId, role: PHOTO_ROLE.change })
|
||||
if (request.imageBId) out.push({ stillId: request.imageBId, role: PHOTO_ROLE.outfit })
|
||||
return out
|
||||
}
|
||||
if (mode === 'generate' && engine === 'flux' && request.identityStillId) {
|
||||
out.push({ stillId: request.identityStillId, role: PHOTO_ROLE.person })
|
||||
return out
|
||||
}
|
||||
if (['edit', 'iterate', 'compose', 'refine'].includes(mode)) {
|
||||
if (request.imageAId) out.push({ stillId: request.imageAId, role: PHOTO_ROLE.change })
|
||||
if (request.identityStillId && request.identityStillId !== request.imageAId) {
|
||||
out.push({ stillId: request.identityStillId, role: PHOTO_ROLE.person })
|
||||
}
|
||||
if (request.imageBId) out.push({ stillId: request.imageBId, role: PHOTO_ROLE.extra })
|
||||
return out
|
||||
}
|
||||
if (mode === 'video') {
|
||||
if (request.imageAId) out.push({ stillId: request.imageAId, role: PHOTO_ROLE.first })
|
||||
if (request.identityStillId) out.push({ stillId: request.identityStillId, role: PHOTO_ROLE.person })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply Photos → request fields. Call before validate / queue.
|
||||
* If Edit+Qwen has cards but no Photo to change, promote the first card.
|
||||
*/
|
||||
export function applyPhotosToRequest(raw = {}) {
|
||||
const next = { ...raw }
|
||||
const plan = photosPlan({ mode: next.mode, engine: next.engine })
|
||||
if (!plan.show || plan.handoffReadonly) {
|
||||
if (!Array.isArray(next.photos)) next.photos = []
|
||||
return next
|
||||
}
|
||||
let photos = normalizePhotos(next.photos, plan)
|
||||
if (!photos.length && (next.imageAId || next.identityStillId || next.imageBId)) {
|
||||
photos = normalizePhotos(photosFromRequest(next), plan)
|
||||
}
|
||||
// Promote first card to Photo to change when Edit needs it.
|
||||
if (
|
||||
plan.roles.includes(PHOTO_ROLE.change)
|
||||
&& photos.length
|
||||
&& !photos.some(p => p.role === PHOTO_ROLE.change)
|
||||
) {
|
||||
photos = [{ ...photos[0], role: PHOTO_ROLE.change }, ...photos.slice(1)]
|
||||
}
|
||||
const bound = bindPhotosToRequest(photos, { mode: next.mode, engine: next.engine })
|
||||
next.photos = bound.photos
|
||||
next.imageAId = bound.imageAId
|
||||
next.imageBId = bound.imageBId
|
||||
next.identityStillId = bound.identityStillId
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* Qwen Edit: map mention phrases → <imageN> for TextEncode.
|
||||
* Photo to change → <image1>; outfit/extra/person-on-2 → <image2>.
|
||||
*/
|
||||
export function injectQwenEditMentionTags(prompt, photos = []) {
|
||||
let text = String(prompt || '')
|
||||
const list = Array.isArray(photos) ? photos.filter(p => p?.stillId) : []
|
||||
let change = list.find(p => p.role === PHOTO_ROLE.change)
|
||||
if (!change && list[0]) change = { ...list[0], role: PHOTO_ROLE.change }
|
||||
const outfit = list.find(p => p.role === PHOTO_ROLE.outfit || p.role === PHOTO_ROLE.extra)
|
||||
const person = list.find(p => p.role === PHOTO_ROLE.person)
|
||||
const image2 = outfit || (!outfit ? person : null)
|
||||
|
||||
const pairs = []
|
||||
if (change) {
|
||||
const idx = list.indexOf(list.find(p => p.stillId === change.stillId && p.role === change.role) || change)
|
||||
pairs.push({ phrase: mentionPhrase(PHOTO_ROLE.change, Math.max(0, idx)), tag: '<image1>' })
|
||||
// Also map generic Photo 1 if they used chip style
|
||||
pairs.push({ phrase: 'Photo 1', tag: '<image1>' })
|
||||
}
|
||||
if (image2) {
|
||||
const idx = list.findIndex(p => p.stillId === image2.stillId)
|
||||
pairs.push({ phrase: mentionPhrase(image2.role, Math.max(0, idx)), tag: '<image2>' })
|
||||
pairs.push({ phrase: 'Photo 2', tag: '<image2>' })
|
||||
}
|
||||
|
||||
// Longer phrases first so "the outfit photo" wins over fragments.
|
||||
pairs.sort((a, b) => b.phrase.length - a.phrase.length)
|
||||
for (const { phrase, tag } of pairs) {
|
||||
if (!phrase || text.includes(tag) && !text.includes(phrase)) continue
|
||||
if (text.includes(phrase)) text = text.split(phrase).join(tag)
|
||||
}
|
||||
return text
|
||||
}
|
||||
@@ -100,8 +100,14 @@ test('qwen21 supports Generate and Edit with 25/1 defaults and no locks',()=>{
|
||||
const edit=validateRequest({mode:'edit',engine:'qwen21',folderId:'f',imageAId:'s',promptSections:{action:'change the sky'},settings:{aspect:'auto'}})
|
||||
assert.equal(edit.mode,'edit');assert.equal(edit.settings.steps,25);assert.equal(edit.settings.cfg,1)
|
||||
assert.equal(edit.lockFace,false);assert.deepEqual(edit.settings.loraStack,[])
|
||||
assert.throws(()=>validateRequest({mode:'edit',engine:'qwen21',folderId:'f',promptSections:{action:'x'}}),/Start still/)
|
||||
assert.throws(()=>validateRequest({mode:'edit',engine:'qwen21',folderId:'f',identityStillId:'hero',promptSections:{action:'x'}}),/Start still/)
|
||||
assert.throws(()=>validateRequest({mode:'edit',engine:'qwen21',folderId:'f',promptSections:{action:'x'}}),/Photo to change/)
|
||||
assert.throws(()=>validateRequest({mode:'edit',engine:'qwen21',folderId:'f',identityStillId:'hero',promptSections:{action:'x'}}),/Photo to change/)
|
||||
const qwenPhotos=validateRequest({mode:'edit',engine:'qwen21',folderId:'f',photos:[{stillId:'person',role:'change'},{stillId:'dress',role:'outfit'}],promptSections:{action:'Put her in that dress.'},settings:{aspect:'auto'}})
|
||||
assert.equal(qwenPhotos.imageAId,'person')
|
||||
assert.equal(qwenPhotos.imageBId,'dress')
|
||||
assert.equal(qwenPhotos.identityStillId,'')
|
||||
assert.match(qwenPhotos.compiledPrompt,/Put her in that dress/)
|
||||
assert.doesNotMatch(String(qwenPhotos.promptSections.action),/<image1>/)
|
||||
assert.throws(()=>validateRequest({mode:'compose',engine:'qwen21',folderId:'f',imageAId:'s',imageBId:'b',promptSections:{action:'x'}}),/Generate and Edit/)
|
||||
assert.throws(()=>validateRequest({mode:'iterate',engine:'qwen21',folderId:'f',imageAId:'s',promptSections:{action:'x'}}),/Generate and Edit/)
|
||||
assert.equal(ensureQwen21EditPrompt('put her in a red leather jacket'),`${QWEN21_EDIT_KEEP_CHANGE} put her in a red leather jacket`)
|
||||
@@ -152,12 +158,23 @@ test('custom negative chips survive normalizeStyles and bench exposes Load actio
|
||||
styles=toggleStyle(styles,'deformed',true)
|
||||
assert.deepEqual(styles.negative,['blurry','my watermark','deformed'])
|
||||
const src=readFileSync(new URL('../components/studio2/Bench.vue',import.meta.url),'utf8')
|
||||
assert.match(src,/loadSelectedJob/);assert.match(src,/hydrateStudio2Job/);assert.match(src,/Edit source/)
|
||||
const slot=readFileSync(new URL('../components/studio2/AssetSlot.vue',import.meta.url),'utf8')
|
||||
assert.match(src,/loadSelectedJob/);assert.match(src,/hydrateStudio2Job/);assert.match(src,/Photos/)
|
||||
assert.match(src,/photosPlanState\.emptyHint/)
|
||||
assert.match(src,/aspect:'auto'/);assert.match(src,/strip-actions/);assert.match(src,/addNegative/)
|
||||
assert.match(src,/Enhance prompt/);assert.match(src,/enhancePrompt/)
|
||||
assert.match(slot,/Mention in prompt/);assert.match(slot,/This photo is/)
|
||||
assert.doesNotMatch(src,/Hero still/)
|
||||
assert.doesNotMatch(src,/Edit source/)
|
||||
assert.doesNotMatch(src,/Identity \/ reference/)
|
||||
assert.doesNotMatch(src,/Qwen Edit uses Start still/)
|
||||
assert.doesNotMatch(src,/Hero is Klein-only/)
|
||||
assert.doesNotMatch(slot,/<image1>/)
|
||||
assert.match(src,/\bTurbo\b/);assert.match(src,/form\.turbo/)
|
||||
assert.match(src,/Qwen Edit uses Start still as/)
|
||||
assert.match(src,/Hero is Klein-only and is ignored/)
|
||||
const photosSrc=readFileSync(new URL('../shared/studio2/photos.mjs',import.meta.url),'utf8')
|
||||
assert.match(photosSrc,/Photo to change/)
|
||||
assert.match(photosSrc,/mentionPhrase/)
|
||||
assert.match(photosSrc,/Add a photo, then say what it is/)
|
||||
assert.match(readFileSync(new URL('../components/studio2/Details.vue',import.meta.url),'utf8'),/\bTyped\b/)
|
||||
assert.match(readFileSync(new URL('../utils/libraryGeneration.ts',import.meta.url),'utf8'),/\bTyped\b/)
|
||||
assert.match(readFileSync(new URL('../utils/libraryGeneration.ts',import.meta.url),'utf8'),/promptRaw/)
|
||||
|
||||
Reference in New Issue
Block a user