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:
Towsty
2026-09-26 16:00:37 -05:00
co-authored by Cursor
parent 58c5458c87
commit 5c2248a3fd
7 changed files with 690 additions and 105 deletions
+57
View File
@@ -718,6 +718,63 @@
cursor: pointer; 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 { .placeholder-still {
color: var(--muted); color: var(--muted);
font-size: var(--type-sm); font-size: var(--type-sm);
+39 -10
View File
@@ -1,24 +1,53 @@
<template> <template>
<div class="slot-block"> <div class="photo-card">
<span class="field-label">{{ label }}</span>
<button <button
type="button" type="button"
class="slot-thumb" 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')" @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> <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> <span v-else class="inline-flex items-center gap-1">Select <span aria-hidden="true">▾</span></span>
</button> </button>
<p v-if="asset" class="slot-caption" :title="assetLabel(asset)">{{ assetLabel(asset) }}</p> <div class="photo-card-body">
<label v-if="upload" class="slot-upload">Upload {{ label.toLowerCase() }} <label class="photo-role-label">
<input type="file" accept="image/*" class="sr-only" :disabled="disabled" @change="$emit('upload',$event)"> <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> </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> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {assetLabel} from '~/shared/studio2/viewer.mjs' const props = withDefaults(defineProps<{
withDefaults(defineProps<{label:string;asset?:any;hidden?:boolean;upload?:boolean;disabled?:boolean;size?:'hero'|'slot'}>(),{size:'slot'}) asset?: any
defineEmits(['pick','upload']) 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> </script>
+166 -62
View File
@@ -78,43 +78,49 @@
</template> </template>
<template v-else> <template v-else>
<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 <Studio2AssetSlot
size="hero" v-for="(card, index) in form.photos"
:label="heroSlotLabel" :key="'photo-'+index+'-'+card.stillId"
:asset="stills.find((a:any)=>a.id===form.identityStillId)" :asset="stillById(card.stillId)"
:hidden="slotHidden(form.identityStillId)" :hidden="slotHidden(card.stillId)"
upload :role="card.role"
:disabled="uploading" :role-options="photoRoleOptions"
@pick="picker='identityStillId'" @pick="picker='photo:'+index"
@upload="upload($event,'hero')" @remove="removePhoto(index)"
@mention="mentionPhoto(index)"
@update:role="setPhotoRole(index, $event)"
/> />
<Studio2AssetSlot <button
:label="sourceSlotLabel" v-if="form.photos.length < photosPlanState.max"
:asset="sourceSlot" type="button"
:hidden="slotHidden(form.mode==='extend'?form.startClipId:form.imageAId)" class="photos-add"
upload @click="addPhoto"
:disabled="uploading" >+ Add photo</button>
:class="{ 'slot-required': editSourceRequired && !form.imageAId }" <p v-if="photoRoleWarning" class="text-[11px] text-[var(--amber)]">{{ photoRoleWarning }}</p>
@pick="picker=form.mode==='extend'?'startClipId':'imageAId'" </template>
@upload="upload($event,'source')" </section>
/>
<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'" />
<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.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> <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> </div>
<p v-else-if="form.mode==='edit'" class="text-[var(--type-sm)] text-[var(--muted)]">Qwen Edit uses Start still as &lt;image1&gt;. 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> <label>
<span class="field-label">Prompt</span> <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>
<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
@@ -264,8 +270,7 @@
</div> </div>
<div v-if="selected && selected.kind!=='track'" class="strip-actions" aria-label="Selected still actions"> <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="applyUseAsSource">Use as photo</button>
<button type="button" @click="applyUseAsHero">Use as hero</button>
<button type="button" @click="copySelectedPrompt">Copy prompt</button> <button type="button" @click="copySelectedPrompt">Copy prompt</button>
<button type="button" @click="loadSelectedJob">Load</button> <button type="button" @click="loadSelectedJob">Load</button>
<button type="button" @click="deleteOutput">Delete</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"> <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"> <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"> <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 type="button" class="text-[var(--type-sm)] text-[var(--muted)]" @click="pickAsset(null)">Clear slot</button>
<button <button
@@ -380,13 +385,22 @@
import {createLoraMemory,loraUse} from '~/shared/studio2/lora-memory.mjs' import {createLoraMemory,loraUse} from '~/shared/studio2/lora-memory.mjs'
import {filterLorasForStudio,filterLorasForUse} from '~/utils/loras' import {filterLorasForStudio,filterLorasForUse} from '~/utils/loras'
import {sectionsFrom} from '~/shared/studio2/contracts.mjs' 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 {folderAssets,newestOutput,assetLabel,reconcileSelection,previewHidden} from '~/shared/studio2/viewer.mjs'
import {queueJobs,jobStageLabel,jobProgressView,elapsedSeconds} from '~/shared/studio2/queue-view.mjs' import {queueJobs,jobStageLabel,jobProgressView,elapsedSeconds} from '~/shared/studio2/queue-view.mjs'
import {gpuStatus} from '~/shared/studio2/status.mjs' import {gpuStatus} from '~/shared/studio2/status.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} from '~/shared/studio2/styles.mjs'
import {randomSeed} from '~/shared/studio2/seed.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 {DEFAULT_MUSIC_LYRICS,DEFAULT_MUSIC_TAGS} from '~/utils/music'
import {isXaigenStudio} from '~/utils/videoModels' import {isXaigenStudio} from '~/utils/videoModels'
import {LOAD_GENERATION_PAYLOAD_KEY} from '~/utils/queuedJob' import {LOAD_GENERATION_PAYLOAD_KEY} from '~/utils/queuedJob'
@@ -442,7 +456,9 @@ const liveMusic=ref<Record<string,any>>({})
const negativeDraft=ref('') const negativeDraft=ref('')
const filmstripEl=ref<HTMLElement|null>(null) const filmstripEl=ref<HTMLElement|null>(null)
const tileRefs=new Map<string,HTMLElement>() 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 music=reactive({tags:DEFAULT_MUSIC_TAGS,lyrics:DEFAULT_MUSIC_LYRICS,duration:60,seed:'',name:''})
const tagsPlaceholder=DEFAULT_MUSIC_TAGS,lyricsPlaceholder=DEFAULT_MUSIC_LYRICS const tagsPlaceholder=DEFAULT_MUSIC_TAGS,lyricsPlaceholder=DEFAULT_MUSIC_LYRICS
let loraMemory=createLoraMemory(xaigen) let loraMemory=createLoraMemory(xaigen)
@@ -501,7 +517,17 @@ watch(()=>[form.mode,form.engine],([mode,engine])=>{
form.lockOutfit=false form.lockOutfit=false
form.settings.loraStack=[] 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 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 clips=computed(()=>library.value.clips.filter((s:any)=>s.folderId===form.folderId))
const stillUrl=(id:string)=>`/api/library/stills/${id}` 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 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 inputStill=computed(()=>stills.value.find((s:any)=>s.id===form.imageAId))
const family=computed(()=>folderAssets(library.value,jobs.value,form.folderId)) 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 sourceSize=computed(()=>measured[sourceAsset.value?.id] || sourceAsset.value)
const resolvedSize=computed(()=>resolveSize(form.settings.aspect,sourceSize.value,undefined,form.engine)) const resolvedSize=computed(()=>resolveSize(form.settings.aspect,sourceSize.value,undefined,form.engine))
const negativeChips=computed(()=>form.imageStyles.negative || []) const negativeChips=computed(()=>form.imageStyles.negative || [])
const editSourceRequired=computed(()=>['edit','compose','refine'].includes(form.mode)) const photosPlanState=computed(()=>photosPlan({mode:form.mode,engine:form.engine}))
const heroSlotLabel=computed(()=>editSourceRequired.value?'Identity / reference':'Hero still') const photoRoleOptions=computed(()=>(photosPlanState.value.roles||[]).map((id:string)=>({id,label:PHOTO_ROLE_LABEL[id]||id})))
const sourceSlotLabel=computed(()=>{ const photoRoleWarning=computed(()=>{
if(form.mode==='extend')return 'Previous clip' const dup=duplicatePhotoRole(form.photos)
if(editSourceRequired.value)return form.engine==='qwen21'?'Start still':'Edit source' return dup?`Two photos are set to the same job (${dup}). Change one.`:''
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 showIdentityLocks=computed(()=>form.engine!=='qwen21' && form.engine!=='krea' && !['music'].includes(form.mode))
const queueCards=computed(()=>{ const queueCards=computed(()=>{
const cards:any[]=[] const cards:any[]=[]
const now=Date.now() const now=Date.now()
@@ -645,15 +663,65 @@ function scrollFilmstripToNewest(){
else if(strip)strip.scrollLeft=strip.scrollWidth 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(){ function applyUseAsSource(){
if(!selected.value||selected.value.kind==='track')return if(!selected.value||selected.value.kind==='track')return
const plan=photosPlanState.value
if(!plan.show||plan.handoffReadonly){
Object.assign(form,useAsSource(form,selected.value)) Object.assign(form,useAsSource(form,selected.value))
uploadMessage.value=`Using ${assetLabel(selected.value)} as start / input.` uploadMessage.value=`Using ${assetLabel(selected.value)}.`
return
} }
function applyUseAsHero(){ if(form.photos.length>=plan.max){
if(!selected.value||selected.value.kind==='track')return form.photos[form.photos.length-1]={stillId:selected.value.id,role:plan.defaultRole||form.photos.at(-1)?.role||'change'}
Object.assign(form,useAsHero(form,selected.value)) }else{
uploadMessage.value=`Using ${assetLabel(selected.value)} as hero.` form.photos.push({stillId:selected.value.id,role:plan.defaultRole||'change'})
}
syncPhotosBindings()
uploadMessage.value=`Added ${assetLabel(selected.value)} to Photos.`
} }
async function copySelectedPrompt(){ async function copySelectedPrompt(){
const text=selected.value?.studio2?.prompt 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}) const {form:next,missing}=hydrateStudio2Job(form,saved,{stillIds:stillIdSet(),clipIds:clipIdSet(),xaigen,memory:loraMemory})
Object.assign(form,next) Object.assign(form,next)
form.guides=form.guides||[] form.guides=form.guides||[]
form.photos=photosFromRequest({...next,photos:saved.photos})
syncPhotosBindings()
batchText.value='' batchText.value=''
if(output)selected.value=output if(output)selected.value=output
else if(form.mode==='music'){/* keep */} 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 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.'}}
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 clearFinished(){await $fetch('/api/studio-2/clear-finished',{method:'POST'});await refresh()}
async function deleteOutput(){ async function deleteOutput(){
if(!selected.value)return if(!selected.value)return
@@ -799,8 +888,19 @@ async function queue(){
await refresh() await refresh()
return return
} }
if(photoRoleWarning.value){error.value=photoRoleWarning.value;return}
syncPhotosBindings()
if(form.settings.seedMode!=='locked')form.settings.seed=randomSeed() 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 form.settings.seed=queued.seed;form.settings.width=queued.width;form.settings.height=queued.height
await refresh() await refresh()
}catch(e:any){error.value=e.data?.statusMessage||e.message} }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() 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.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)localStorage.setItem(`studio2-hero:${form.folderId}`,id)}) 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 let timer:ReturnType<typeof setTimeout>,clock:ReturnType<typeof setInterval>,disposed=false
onMounted(async()=>{ onMounted(async()=>{
let storage;try{storage=localStorage}catch{} let storage;try{storage=localStorage}catch{}
@@ -894,10 +994,10 @@ const mobileCtx=computed(()=>({
batchText:batchText.value, batchText:batchText.value,
negativeChips:negativeChips.value, negativeChips:negativeChips.value,
negativeDraft:negativeDraft.value, negativeDraft:negativeDraft.value,
heroSlotLabel:heroSlotLabel.value, photosPlanState:photosPlanState.value,
sourceSlotLabel:sourceSlotLabel.value, photoRoleOptions:photoRoleOptions.value,
slotPairHint:slotPairHint.value, photoRoleWarning:photoRoleWarning.value,
editSourceRequired:editSourceRequired.value, showIdentityLocks:showIdentityLocks.value,
pickerLoras:pickerLoras.value, pickerLoras:pickerLoras.value,
currentLoraUse:currentLoraUse.value, currentLoraUse:currentLoraUse.value,
loraAliases:{...loraNames.value.aliases,...library.value.loraAliases}, loraAliases:{...loraNames.value.aliases,...library.value.loraAliases},
@@ -906,6 +1006,7 @@ const mobileCtx=computed(()=>({
stillUrl, stillUrl,
isHidden, isHidden,
slotHidden, slotHidden,
stillById,
toggleReveal, toggleReveal,
chooseTask, chooseTask,
selectOutput, selectOutput,
@@ -917,8 +1018,11 @@ const mobileCtx=computed(()=>({
clearFinished, clearFinished,
removeNegative, removeNegative,
addNegative, addNegative,
addPhoto,
removePhoto,
setPhotoRole,
mentionPhoto,
applyUseAsSource, applyUseAsSource,
applyUseAsHero,
copySelectedPrompt, copySelectedPrompt,
loadSelectedJob, loadSelectedJob,
randomizeSeed, randomizeSeed,
+26 -15
View File
@@ -108,15 +108,14 @@
</div> </div>
<div v-if="ctx.selected && ctx.selected.kind!=='track'" class="m-strip-actions"> <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.applyUseAsSource">Use as photo</button>
<button type="button" @click="ctx.applyUseAsHero">Use as hero</button>
<button type="button" @click="ctx.copySelectedPrompt">Copy prompt</button> <button type="button" @click="ctx.copySelectedPrompt">Copy prompt</button>
<button type="button" @click="ctx.loadSelectedJob">Load</button> <button type="button" @click="ctx.loadSelectedJob">Load</button>
<button type="button" @click="ctx.deleteOutput">Delete</button> <button type="button" @click="ctx.deleteOutput">Delete</button>
</div> </div>
<div class="m-dock"> <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==='prompt' }" @click="toggleSheet('prompt')">Prompt</button>
<button type="button" :class="{ on: sheet==='look' }" @click="toggleSheet('look')">Look</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> <button type="button" :class="{ on: sheet==='gen' }" @click="toggleSheet('gen')">Gen</button>
@@ -125,24 +124,36 @@
<!-- Sheets --> <!-- Sheets -->
<div v-if="sheet" class="m-sheet-backdrop" @click="sheet=''" /> <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=''" /> <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"> <div class="m-sheet-body">
<template v-if="ctx.form.mode==='music'"> <template v-if="ctx.form.mode==='music' || !ctx.photosPlanState?.show">
<p class="m-muted">Music uses tags + lyrics in Prompt / Gen — no hero slot.</p> <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>
<template v-else> <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')" /> <p v-if="!ctx.form.photos?.length && ctx.photosPlanState.emptyHint" class="m-muted">{{ ctx.photosPlanState.emptyHint }}</p>
<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')" /> <Studio2AssetSlot
<p v-if="ctx.editSourceRequired && !ctx.form.imageAId" class="m-hint">Required for Edit</p> v-for="(card, index) in ctx.form.photos"
<p v-if="ctx.slotPairHint" class="m-hint">{{ ctx.slotPairHint }}</p> :key="'m-photo-'+index+'-'+card.stillId"
<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')" /> :asset="ctx.stillById(card.stillId)"
<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')" /> :hidden="ctx.slotHidden(card.stillId)"
<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')" /> :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.lockFace" type="checkbox"> Lock face</label>
<label class="m-check"><input v-model="ctx.form.lockOutfit" type="checkbox"> Lock outfit</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> </template>
<p v-if="ctx.uploading || ctx.uploadMessage" class="m-amber">{{ ctx.uploading?'Uploading…':ctx.uploadMessage }}</p> <p v-if="ctx.uploading || ctx.uploadMessage" class="m-amber">{{ ctx.uploading?'Uploading…':ctx.uploadMessage }}</p>
</template> </template>
</div> </div>
+8 -6
View File
@@ -3,6 +3,7 @@ import { queueSeeds } from './seed.mjs'
import { sectionsFrom, compileSections } from './prompt.mjs' import { sectionsFrom, compileSections } from './prompt.mjs'
import { QWEN21_ASPECT_IDS, resolveQwen21Size } from './qwen21-size.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 { 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 { 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 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.' 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 || '' }) } 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) { export function validateRequest(raw, xaigen = false) {
raw = raw || {} raw = applyPhotosToRequest(raw || {})
const fail = message => { throw Object.assign(new Error(message), {statusCode:400}) } const fail = message => { throw Object.assign(new Error(message), {statusCode:400}) }
const mode = raw.mode || 'generate', engine = raw.engine || 'flux' const mode = raw.mode || 'generate', engine = raw.engine || 'flux'
if (!['generate','edit','compose','refine','iterate','video','extend'].includes(mode)) fail('Unknown task.') 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 (Array.isArray(raw.guides) && raw.guides.length>8) fail('Use up to eight guide stills.')
if (!raw.folderId) fail('Choose a folder.') if (!raw.folderId) fail('Choose a folder.')
if (['edit','compose','refine'].includes(mode) && !raw.imageAId && !(mode==='edit' && engine==='flux' && raw.identityStillId)) { 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.') if (engine === 'qwen21') fail('Add a photo and mark it Photo to change.')
fail('Choose the source still.') 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 ((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 requires a second still.') if (mode === 'compose' && !raw.imageBId) fail('Compose needs a second photo.')
if (mode === 'refine' && !raw.maskId) fail('Refine requires a mask.') 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 allSections = sectionsFrom(raw.promptSections || {action:raw.prompt || ''})
const promptSections = video ? allSections : {action:allSections.action} const promptSections = video ? allSections : {action:allSections.action}
if (!promptSections.action.trim()) fail('Enter an action prompt.') 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 steps = turbo ? QWEN21_TURBO_STEPS : number('steps',defaultSteps,1,100)
const cfg = turbo ? QWEN21_TURBO_CFG : number('cfg',defaultCfg,0,20) 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 || '', 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 || ''}, 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}), 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:[]) }, 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:[]) },
+365
View File
@@ -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
}
+22 -5
View File
@@ -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'}}) 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.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.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',promptSections:{action:'x'}}),/Photo to change/)
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',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:'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.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`) 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) styles=toggleStyle(styles,'deformed',true)
assert.deepEqual(styles.negative,['blurry','my watermark','deformed']) assert.deepEqual(styles.negative,['blurry','my watermark','deformed'])
const src=readFileSync(new URL('../components/studio2/Bench.vue',import.meta.url),'utf8') 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,/aspect:'auto'/);assert.match(src,/strip-actions/);assert.match(src,/addNegative/)
assert.match(src,/Enhance prompt/);assert.match(src,/enhancePrompt/) 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,/\bTurbo\b/);assert.match(src,/form\.turbo/)
assert.match(src,/Qwen Edit uses Start still as/) const photosSrc=readFileSync(new URL('../shared/studio2/photos.mjs',import.meta.url),'utf8')
assert.match(src,/Hero is Klein-only and is ignored/) 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('../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'),/\bTyped\b/)
assert.match(readFileSync(new URL('../utils/libraryGeneration.ts',import.meta.url),'utf8'),/promptRaw/) assert.match(readFileSync(new URL('../utils/libraryGeneration.ts',import.meta.url),'utf8'),/promptRaw/)