Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50319e03a9 | ||
|
|
5c2248a3fd |
@@ -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)">
|
||||
<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>
|
||||
|
||||
+166
-62
@@ -78,43 +78,49 @@
|
||||
</template>
|
||||
|
||||
<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
|
||||
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')"
|
||||
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)"
|
||||
/>
|
||||
<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'" />
|
||||
<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
|
||||
const plan=photosPlanState.value
|
||||
if(!plan.show||plan.handoffReadonly){
|
||||
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(!selected.value||selected.value.kind==='track')return
|
||||
Object.assign(form,useAsHero(form,selected.value))
|
||||
uploadMessage.value=`Using ${assetLabel(selected.value)} as hero.`
|
||||
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')" />
|
||||
<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>
|
||||
<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>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ Restart Comfy **only when idle** (`COMFY_CONTROL_URL/status` → `gpu.busy=false
|
||||
|
||||
- Engine key: `qwen21` · UI label: **Qwen 2.1**
|
||||
- **Generate** (T2I) and **Edit** (same checkpoint, second graph). Compose / Iterate / Video / Extend / Music stay disabled unless a graph exists.
|
||||
- Edit requires Start still → `images.image_1`; prompt slots use `<image1>` / `<image2>`. If the user omits `<image1>`, the runner prepends `Keep the identity, face, body, and pose of the person in <image1>. Apply only this change:` then their text. Hero is Klein-only and is ignored (never image_1).
|
||||
- Edit maps Photos roles → graph sockets: Photo to change → `images.image_1`, Outfit / object or Extra → `image_2`. The inspector never shows `<image1>`; the runner injects tags into the TextEncode string on submit (and expands Mention phrases like “this photo” / “the outfit photo”). If `<image1>` is still missing, prepend the keep-identity stanza. Klein hero-ref is off for Qwen.
|
||||
- Face / outfit locks stay Klein semantics — they do not drive Qwen slots.
|
||||
- Sampler defaults: euler / simple / cfg **1** / steps **25**. Edit uses `QwenImage21Cache` (device auto, dtype int8). T2I uses `ModelSamplingAuraFlow` shift **3.1**.
|
||||
- Default Generate canvas follows the bench Aspect control on the Qwen-safe ~1 MP grid (`EmptyLatentImage`): 1:1 → 1024×1024, 16:9 → 1536×864, 9:16 → 864×1536 (and the other table rows). PE `wh_ratio` is advisory only and never sizes the canvas. Do not use native 2K bins on 16 GB.
|
||||
|
||||
@@ -18,6 +18,7 @@ import { nativeVideoGraph, attachHeroReference, applyResolvedImageSize } from '~
|
||||
import { compilePrompt, scopedFile } from '~/shared/studio2/contracts.mjs';
|
||||
import { resolveQwen21Size } from '~/shared/studio2/qwen21-size.mjs';
|
||||
import { ensureQwen21EditPrompt, stitchQwen21PeEditPrompt } from '~/shared/studio2/qwen21-edit.mjs';
|
||||
import { applyPhotosToRequest, injectQwenEditMentionTags } from '~/shared/studio2/photos.mjs';
|
||||
import { qwen21PeRefusal } from '~/shared/studio2/qwen21-pe.mjs';
|
||||
import { QWEN21_TURBO_LORA, QWEN21_TURBO_SIGMAS, QWEN21_TURBO_STEPS } from '~/shared/studio2/qwen21-turbo.mjs';
|
||||
import { createJob, restoreJob, getJob, emitJob, type Job } from '../jobs';
|
||||
@@ -96,25 +97,38 @@ async function waitPromptHistory(r: any, job: Job, promptId: string) {
|
||||
return history
|
||||
}
|
||||
|
||||
function finalizeQwen21EditSample(rawPrompt: string, request: any) {
|
||||
const photos = Array.isArray(request?.photos) ? request.photos : []
|
||||
const tagged = injectQwenEditMentionTags(String(rawPrompt || ''), photos)
|
||||
return ensureQwen21EditPrompt(tagged, { hasImage2: !!request?.imageBId })
|
||||
}
|
||||
|
||||
async function runQwen21PromptEnhance(r: any, job: Job) {
|
||||
const q = r.request, s = q.settings
|
||||
if (!q.enhancePrompt || q.engine !== 'qwen21' || !['generate', 'edit'].includes(q.mode)) return
|
||||
const edit = q.mode === 'edit'
|
||||
if (Array.isArray(q.photos) && q.photos.length) {
|
||||
const bound = applyPhotosToRequest(q)
|
||||
q.imageAId = bound.imageAId
|
||||
q.imageBId = bound.imageBId
|
||||
q.identityStillId = bound.identityStillId
|
||||
q.photos = bound.photos
|
||||
}
|
||||
// Edit → PE-I2I only (pe_i2i). Generate → PE-T2I only. Never cross-feed.
|
||||
if (edit && !q.imageAId)
|
||||
throw new Error('Qwen Edit requires Start still (image_1). Hero is ignored.')
|
||||
throw new Error('Add a photo and mark it Photo to change.')
|
||||
update(r, 'enhancing')
|
||||
emitJob(job, { type: 'status', message: 'Enhancing prompt…' })
|
||||
// promptRaw = exactly what they typed (compiled from sections before PE).
|
||||
const userPrompt = String(q.compiledPrompt || '')
|
||||
r.request.promptRaw = userPrompt
|
||||
// Edit PE gets the raw edit instruction + Start still — not the keep stanza.
|
||||
// Edit PE gets the raw edit instruction + canvas still — not the keep stanza.
|
||||
const peInput = userPrompt
|
||||
const graph = structuredClone(edit ? qwen21PeEditTemplate : qwen21PeT2iTemplate)
|
||||
graph['9'].inputs.prompt = peInput
|
||||
graph['9'].inputs.seed = s.seed
|
||||
if (edit) {
|
||||
// Same Start still pixels as Edit image_1 — never Hero.
|
||||
// Photo to change → image_1. Outfit/extra → image_2 when present.
|
||||
const a = await upload(r, 'pe-source', readFileSync(stillPath(r.owner, getStill(r.owner, q.imageAId).id)))
|
||||
graph['10'].inputs.image = a
|
||||
graph['9'].inputs.image_1 = ['10', 0]
|
||||
@@ -143,7 +157,7 @@ async function runQwen21PromptEnhance(r: any, job: Job) {
|
||||
const refusal = qwen21PeRefusal(userPrompt, result)
|
||||
if (refusal.refused) {
|
||||
// Fail open: keep the job, send the raw brief (Edit keep-identity stanza still applies).
|
||||
const compiled = edit ? ensureQwen21EditPrompt(userPrompt) : userPrompt
|
||||
const compiled = edit ? finalizeQwen21EditSample(userPrompt, q) : userPrompt
|
||||
r.request.compiledPrompt = compiled
|
||||
r.request.prompt = compiled
|
||||
r.request.enhance = {
|
||||
@@ -160,8 +174,9 @@ async function runQwen21PromptEnhance(r: any, job: Job) {
|
||||
// Edit only: stanza + raw first; PE chunk only if it is an edit directive (never PE-alone).
|
||||
// Generate keeps the long observer paragraph as the product — no describe sanitize.
|
||||
const stitched = stitchQwen21PeEditPrompt(userPrompt, result.positive_prompt)
|
||||
r.request.compiledPrompt = stitched.prompt
|
||||
r.request.prompt = stitched.prompt
|
||||
const compiled = finalizeQwen21EditSample(stitched.prompt, q)
|
||||
r.request.compiledPrompt = compiled
|
||||
r.request.prompt = compiled
|
||||
r.request.enhance = {
|
||||
used: !stitched.skippedAsDescribe,
|
||||
wh_ratio: result.wh_ratio || '',
|
||||
@@ -223,10 +238,18 @@ async function upload(r: any, name: string, data: Buffer): Promise<string> {
|
||||
}
|
||||
async function prepareGraph(r: any) {
|
||||
const q = r.request, s = q.settings, prefix = String(useRuntimeConfig().comfyFilenamePrefix).replace(/\/$/, '') + `/studio2/${r.id}/${r.index}`;
|
||||
// Photos roles → graph sockets (imageAId / imageBId / identityStillId). Payload is source of truth.
|
||||
if (Array.isArray(q.photos) && q.photos.length) {
|
||||
const bound = applyPhotosToRequest(q)
|
||||
q.imageAId = bound.imageAId
|
||||
q.imageBId = bound.imageBId
|
||||
q.identityStillId = bound.identityStillId
|
||||
q.photos = bound.photos
|
||||
}
|
||||
const load = async (id: string, label: string) => id ? upload(r, label, readFileSync(stillPath(r.owner, getStill(r.owner, id).id))) : '';
|
||||
const video=['video','extend'].includes(q.mode);
|
||||
const identityPlan=kleinIdentityPlan(q);
|
||||
// Qwen Edit: Start still (imageAId) only — never Hero / identityStillId.
|
||||
// Qwen Edit: Photo to change (imageAId) → image_1. Never Klein hero-ref.
|
||||
const sourceId = q.engine === 'qwen21'
|
||||
? (q.imageAId || '')
|
||||
: (identityPlan ? identityPlan.sourceId : q.imageAId);
|
||||
@@ -304,12 +327,12 @@ async function prepareGraph(r: any) {
|
||||
}
|
||||
const negative = turbo ? '' : stylePrompt(q.imageStyles, true)
|
||||
if (q.mode === 'edit') {
|
||||
// Start still → image_1 only. Never Hero. Never T2I / EmptyLatent.
|
||||
// Photo to change → image_1. Outfit/extra → image_2. Never T2I / EmptyLatent.
|
||||
if (!q.imageAId || !a)
|
||||
throw new Error('Qwen Edit requires Start still (image_1). Hero is ignored.')
|
||||
// promptRaw stays typed; prompt/compiledPrompt = what TextEncode samples.
|
||||
throw new Error('Add a photo and mark it Photo to change.')
|
||||
// promptRaw stays typed; prompt/compiledPrompt = what TextEncode samples (tags injected silently).
|
||||
if (q.promptRaw == null) q.promptRaw = String(q.compiledPrompt || '')
|
||||
const prompt = ensureQwen21EditPrompt(String(q.compiledPrompt || ''))
|
||||
const prompt = finalizeQwen21EditSample(String(q.compiledPrompt || ''), q)
|
||||
q.compiledPrompt = prompt
|
||||
q.prompt = prompt
|
||||
q.task = 'edit'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -20,11 +20,16 @@ export function buildQwen21EditKeepStanza(rawInstruction) {
|
||||
}
|
||||
|
||||
/** Enhance off / refusal: ensure the sample prompt is the keep stanza, never PE-only. */
|
||||
export function ensureQwen21EditPrompt(prompt) {
|
||||
export function ensureQwen21EditPrompt(prompt, options = {}) {
|
||||
const text = String(prompt || '').trim()
|
||||
if (!text) return text
|
||||
if (/Keep the identity, face, body, pose, and likeness of the person in <image1>/i.test(text)) return text
|
||||
return buildQwen21EditKeepStanza(text)
|
||||
let next = /Keep the identity, face, body, pose, and likeness of the person in <image1>/i.test(text)
|
||||
? text
|
||||
: buildQwen21EditKeepStanza(text)
|
||||
if (options.hasImage2 && !/<image2>/i.test(next)) {
|
||||
next = `${next}\nUse the garment or object in <image2>.`.trim()
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
/** PE rewrite looks like a T2I observer caption, not an edit directive. */
|
||||
|
||||
@@ -9,6 +9,7 @@ import {compilePrompt,validateRequest} from '../shared/studio2/contracts.mjs'
|
||||
import {cachedLoras} from '../shared/studio2/lora-cache.mjs'
|
||||
import {resolveQwen21Size} from '../shared/studio2/qwen21-size.mjs'
|
||||
import {ensureQwen21EditPrompt,stitchQwen21PeEditPrompt,QWEN21_EDIT_KEEP_CHANGE} from '../shared/studio2/qwen21-edit.mjs'
|
||||
import {injectQwenEditMentionTags} from '../shared/studio2/photos.mjs'
|
||||
import {qwen21PeRefusal} from '../shared/studio2/qwen21-pe.mjs'
|
||||
|
||||
test('deleted library files never return from job outputs; selection falls back or clears',()=>{
|
||||
@@ -100,13 +101,22 @@ 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`)
|
||||
assert.match(ensureQwen21EditPrompt('put her in a red leather jacket'),/<image1>/)
|
||||
assert.match(ensureQwen21EditPrompt('put her in that dress',{hasImage2:true}),/<image2>/)
|
||||
assert.equal(ensureQwen21EditPrompt('Keep the identity, face, body, pose, and likeness of the person in <image1>.\nDo not invent a different person.\nApply only this change: x'),'Keep the identity, face, body, pose, and likeness of the person in <image1>.\nDo not invent a different person.\nApply only this change: x')
|
||||
assert.match(injectQwenEditMentionTags('Put her in the outfit photo',[{stillId:'a',role:'change'},{stillId:'b',role:'outfit'}]),/<image2>/)
|
||||
assert.match(injectQwenEditMentionTags('Change this photo',[{stillId:'a',role:'change'}]),/<image1>/)
|
||||
const stitchedKeep=stitchQwen21PeEditPrompt('put her in a red leather jacket','Change the outfit on the person in <image1> to a red leather jacket')
|
||||
assert.match(stitchedKeep.prompt,/Keep the identity/)
|
||||
assert.match(stitchedKeep.prompt,/<image1>/)
|
||||
@@ -152,12 +162,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/)
|
||||
@@ -175,6 +196,8 @@ test('custom negative chips survive normalizeStyles and bench exposes Load actio
|
||||
const runner=readFileSync(new URL('../server/utils/studio2/runner.ts',import.meta.url),'utf8')
|
||||
assert.match(runner,/ensureQwen21EditPrompt/)
|
||||
assert.match(runner,/stitchQwen21PeEditPrompt/)
|
||||
assert.match(runner,/injectQwenEditMentionTags/)
|
||||
assert.match(runner,/applyPhotosToRequest/)
|
||||
assert.match(runner,/skippedAsDescribe/)
|
||||
assert.match(runner,/promptRaw/)
|
||||
assert.match(runner,/images\.image_1/)
|
||||
|
||||
Reference in New Issue
Block a user