Replace Hero/Edit-source chrome with Photos role cards.
Inspector stills use plain-English roles and Mention phrases; Qwen Generate hides Photos entirely. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -718,6 +718,63 @@
|
|||||||
cursor: pointer;
|
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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+167
-63
@@ -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 <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>
|
<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(!selected.value||selected.value.kind==='track')return
|
if(form.photos.length>=plan.max){
|
||||||
Object.assign(form,useAsHero(form,selected.value))
|
form.photos[form.photos.length-1]={stillId:selected.value.id,role:plan.defaultRole||form.photos.at(-1)?.role||'change'}
|
||||||
uploadMessage.value=`Using ${assetLabel(selected.value)} as hero.`
|
}else{
|
||||||
|
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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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:[]) },
|
||||||
|
|||||||
@@ -0,0 +1,365 @@
|
|||||||
|
/**
|
||||||
|
* Inspector Photos: role cards (plain English). The runner maps roles → graph sockets.
|
||||||
|
* UI never teaches <imageN> / Hero / Edit source.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const PHOTO_ROLE = {
|
||||||
|
person: 'person',
|
||||||
|
change: 'change',
|
||||||
|
outfit: 'outfit',
|
||||||
|
look: 'look',
|
||||||
|
first: 'first',
|
||||||
|
extra: 'extra'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PHOTO_ROLE_LABEL = {
|
||||||
|
person: 'Person to keep',
|
||||||
|
change: 'Photo to change',
|
||||||
|
outfit: 'Outfit / object',
|
||||||
|
look: 'Look / style',
|
||||||
|
first: 'First frame',
|
||||||
|
extra: 'Extra photo'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human phrase inserted by Mention in prompt (not a Comfy token). */
|
||||||
|
export function mentionPhrase(role, index = 0) {
|
||||||
|
const r = String(role || '')
|
||||||
|
if (r === PHOTO_ROLE.change) return 'this photo'
|
||||||
|
if (r === PHOTO_ROLE.outfit) return 'the outfit photo'
|
||||||
|
if (r === PHOTO_ROLE.person) return 'the face photo'
|
||||||
|
if (r === PHOTO_ROLE.look) return 'the look photo'
|
||||||
|
if (r === PHOTO_ROLE.first) return 'the first frame'
|
||||||
|
return `Photo ${Number(index) + 1}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function insertMention(text, phrase, selectionStart = 0, selectionEnd = 0) {
|
||||||
|
const value = String(text || '')
|
||||||
|
const chip = String(phrase || '')
|
||||||
|
if (!chip) return { text: value, selectionStart: 0, selectionEnd: 0 }
|
||||||
|
const existing = value.indexOf(chip)
|
||||||
|
if (existing >= 0) {
|
||||||
|
return { text: value, selectionStart: existing, selectionEnd: existing + chip.length }
|
||||||
|
}
|
||||||
|
const start = Math.max(0, Math.min(Number(selectionStart) || 0, value.length))
|
||||||
|
const end = Math.max(start, Math.min(Number(selectionEnd) || start, value.length))
|
||||||
|
const next = `${value.slice(0, start)}${chip}${value.slice(end)}`
|
||||||
|
return { text: next, selectionStart: start, selectionEnd: start + chip.length }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {{
|
||||||
|
* show: boolean,
|
||||||
|
* max: number,
|
||||||
|
* roles: string[],
|
||||||
|
* defaultRole: string,
|
||||||
|
* emptyHint: string|null,
|
||||||
|
* handoffReadonly: boolean
|
||||||
|
* }}
|
||||||
|
*/
|
||||||
|
export function photosPlan({ mode, engine } = {}) {
|
||||||
|
const task = String(mode || 'generate')
|
||||||
|
const eng = String(engine || 'flux')
|
||||||
|
|
||||||
|
if (task === 'music') {
|
||||||
|
return { show: false, max: 0, roles: [], defaultRole: '', emptyHint: null, handoffReadonly: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eng === 'qwen21') {
|
||||||
|
if (task === 'generate') {
|
||||||
|
return { show: false, max: 0, roles: [], defaultRole: '', emptyHint: null, handoffReadonly: false }
|
||||||
|
}
|
||||||
|
if (task === 'edit') {
|
||||||
|
return {
|
||||||
|
show: true,
|
||||||
|
max: 3,
|
||||||
|
roles: [PHOTO_ROLE.change, PHOTO_ROLE.outfit, PHOTO_ROLE.person, PHOTO_ROLE.extra],
|
||||||
|
defaultRole: PHOTO_ROLE.change,
|
||||||
|
emptyHint: 'Add a photo, then say what it is.',
|
||||||
|
handoffReadonly: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { show: false, max: 0, roles: [], defaultRole: '', emptyHint: null, handoffReadonly: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eng === 'krea') {
|
||||||
|
if (task === 'generate') {
|
||||||
|
return { show: false, max: 0, roles: [], defaultRole: '', emptyHint: null, handoffReadonly: false }
|
||||||
|
}
|
||||||
|
if (task === 'refine') {
|
||||||
|
return {
|
||||||
|
show: true,
|
||||||
|
max: 1,
|
||||||
|
roles: [PHOTO_ROLE.change],
|
||||||
|
defaultRole: PHOTO_ROLE.change,
|
||||||
|
emptyHint: 'Add a photo, then say what it is.',
|
||||||
|
handoffReadonly: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { show: false, max: 0, roles: [], defaultRole: '', emptyHint: null, handoffReadonly: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (task === 'extend') {
|
||||||
|
return {
|
||||||
|
show: true,
|
||||||
|
max: 0,
|
||||||
|
roles: [],
|
||||||
|
defaultRole: '',
|
||||||
|
emptyHint: null,
|
||||||
|
handoffReadonly: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (task === 'video') {
|
||||||
|
if (eng === 'minimax') {
|
||||||
|
return {
|
||||||
|
show: true,
|
||||||
|
max: 2,
|
||||||
|
roles: [PHOTO_ROLE.first, PHOTO_ROLE.person, PHOTO_ROLE.look],
|
||||||
|
defaultRole: PHOTO_ROLE.first,
|
||||||
|
emptyHint: 'Add a photo, then say what it is.',
|
||||||
|
handoffReadonly: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// LTX: first frame only — existing start binding
|
||||||
|
return {
|
||||||
|
show: true,
|
||||||
|
max: 1,
|
||||||
|
roles: [PHOTO_ROLE.first],
|
||||||
|
defaultRole: PHOTO_ROLE.first,
|
||||||
|
emptyHint: 'Add a photo, then say what it is.',
|
||||||
|
handoffReadonly: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Klein (flux)
|
||||||
|
if (task === 'generate') {
|
||||||
|
return {
|
||||||
|
show: true,
|
||||||
|
max: 1,
|
||||||
|
roles: [PHOTO_ROLE.person],
|
||||||
|
defaultRole: PHOTO_ROLE.person,
|
||||||
|
emptyHint: 'Add a photo, then say what it is.',
|
||||||
|
handoffReadonly: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (task === 'edit' || task === 'iterate') {
|
||||||
|
return {
|
||||||
|
show: true,
|
||||||
|
max: 2,
|
||||||
|
roles: [PHOTO_ROLE.change, PHOTO_ROLE.person],
|
||||||
|
defaultRole: PHOTO_ROLE.change,
|
||||||
|
emptyHint: 'Add a photo, then say what it is.',
|
||||||
|
handoffReadonly: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (task === 'compose') {
|
||||||
|
return {
|
||||||
|
show: true,
|
||||||
|
max: 3,
|
||||||
|
roles: [PHOTO_ROLE.change, PHOTO_ROLE.person, PHOTO_ROLE.extra],
|
||||||
|
defaultRole: PHOTO_ROLE.change,
|
||||||
|
emptyHint: 'Add a photo, then say what it is.',
|
||||||
|
handoffReadonly: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (task === 'refine') {
|
||||||
|
return {
|
||||||
|
show: true,
|
||||||
|
max: 1,
|
||||||
|
roles: [PHOTO_ROLE.change],
|
||||||
|
defaultRole: PHOTO_ROLE.change,
|
||||||
|
emptyHint: 'Add a photo, then say what it is.',
|
||||||
|
handoffReadonly: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { show: false, max: 0, roles: [], defaultRole: '', emptyHint: null, handoffReadonly: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePhotos(raw, plan) {
|
||||||
|
const list = Array.isArray(raw) ? raw : []
|
||||||
|
const roles = new Set(plan?.roles || [])
|
||||||
|
const max = Math.max(0, Number(plan?.max) || 0)
|
||||||
|
const out = []
|
||||||
|
for (const row of list) {
|
||||||
|
if (out.length >= max) break
|
||||||
|
const stillId = String(row?.stillId || row?.id || '').trim()
|
||||||
|
if (!stillId) continue
|
||||||
|
let role = String(row?.role || plan?.defaultRole || '').trim()
|
||||||
|
if (!roles.has(role)) role = plan?.defaultRole || [...roles][0] || PHOTO_ROLE.extra
|
||||||
|
out.push({ stillId, role })
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First card wins when two share a role. Returns duplicate role label or null. */
|
||||||
|
export function duplicatePhotoRole(photos) {
|
||||||
|
const seen = new Set()
|
||||||
|
for (const row of photos || []) {
|
||||||
|
const role = String(row?.role || '')
|
||||||
|
if (!role) continue
|
||||||
|
if (seen.has(role)) return PHOTO_ROLE_LABEL[role] || role
|
||||||
|
seen.add(role)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map Photos cards → legacy request fields the graphs already consume.
|
||||||
|
* Qwen Edit: change→imageAId, outfit|extra→imageBId, person→imageBId if empty.
|
||||||
|
* Klein: person→identityStillId, change→imageAId.
|
||||||
|
*/
|
||||||
|
export function bindPhotosToRequest(photos, { mode, engine } = {}) {
|
||||||
|
const task = String(mode || 'generate')
|
||||||
|
const eng = String(engine || 'flux')
|
||||||
|
const list = Array.isArray(photos) ? photos.filter(p => p?.stillId) : []
|
||||||
|
const find = (...roles) => list.find(p => roles.includes(p.role))
|
||||||
|
|
||||||
|
let imageAId = ''
|
||||||
|
let imageBId = ''
|
||||||
|
let identityStillId = ''
|
||||||
|
|
||||||
|
if (eng === 'qwen21' && task === 'edit') {
|
||||||
|
let change = find(PHOTO_ROLE.change)
|
||||||
|
if (!change && list[0]) change = { ...list[0], role: PHOTO_ROLE.change }
|
||||||
|
const outfit = find(PHOTO_ROLE.outfit, PHOTO_ROLE.extra)
|
||||||
|
const person = find(PHOTO_ROLE.person)
|
||||||
|
imageAId = change?.stillId || ''
|
||||||
|
imageBId = outfit?.stillId || ''
|
||||||
|
if (!imageBId && person) imageBId = person.stillId
|
||||||
|
// Person with outfit already in image_2: leave as imageBId only (2-input graph).
|
||||||
|
identityStillId = ''
|
||||||
|
return { imageAId, imageBId, identityStillId, photos: list }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eng === 'krea' && task === 'refine') {
|
||||||
|
imageAId = find(PHOTO_ROLE.change)?.stillId || list[0]?.stillId || ''
|
||||||
|
return { imageAId, imageBId: '', identityStillId: '', photos: list }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (task === 'video' || (task === 'extend' && eng === 'minimax')) {
|
||||||
|
imageAId = find(PHOTO_ROLE.first)?.stillId || list[0]?.stillId || ''
|
||||||
|
identityStillId = find(PHOTO_ROLE.person, PHOTO_ROLE.look)?.stillId || ''
|
||||||
|
return { imageAId, imageBId: '', identityStillId, photos: list }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eng === 'ltx' && task === 'video') {
|
||||||
|
imageAId = find(PHOTO_ROLE.first)?.stillId || list[0]?.stillId || ''
|
||||||
|
return { imageAId, imageBId: '', identityStillId: '', photos: list }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Klein / flux
|
||||||
|
if (task === 'generate') {
|
||||||
|
identityStillId = find(PHOTO_ROLE.person)?.stillId || list[0]?.stillId || ''
|
||||||
|
return { imageAId: '', imageBId: '', identityStillId, photos: list }
|
||||||
|
}
|
||||||
|
if (task === 'edit' || task === 'iterate' || task === 'compose' || task === 'refine') {
|
||||||
|
imageAId = find(PHOTO_ROLE.change)?.stillId || list[0]?.stillId || ''
|
||||||
|
identityStillId = find(PHOTO_ROLE.person)?.stillId || ''
|
||||||
|
imageBId = find(PHOTO_ROLE.extra)?.stillId || ''
|
||||||
|
return { imageAId, imageBId, identityStillId, photos: list }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { imageAId, imageBId, identityStillId, photos: list }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hydrate Photos cards from legacy hero/source fields when loading a job. */
|
||||||
|
export function photosFromRequest(request = {}) {
|
||||||
|
if (Array.isArray(request.photos) && request.photos.length) {
|
||||||
|
return request.photos.map(p => ({
|
||||||
|
stillId: String(p.stillId || p.id || ''),
|
||||||
|
role: String(p.role || PHOTO_ROLE.change)
|
||||||
|
})).filter(p => p.stillId)
|
||||||
|
}
|
||||||
|
const mode = String(request.mode || 'generate')
|
||||||
|
const engine = String(request.engine || 'flux')
|
||||||
|
const out = []
|
||||||
|
if (engine === 'qwen21' && mode === 'edit') {
|
||||||
|
if (request.imageAId) out.push({ stillId: request.imageAId, role: PHOTO_ROLE.change })
|
||||||
|
if (request.imageBId) out.push({ stillId: request.imageBId, role: PHOTO_ROLE.outfit })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if (mode === 'generate' && engine === 'flux' && request.identityStillId) {
|
||||||
|
out.push({ stillId: request.identityStillId, role: PHOTO_ROLE.person })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if (['edit', 'iterate', 'compose', 'refine'].includes(mode)) {
|
||||||
|
if (request.imageAId) out.push({ stillId: request.imageAId, role: PHOTO_ROLE.change })
|
||||||
|
if (request.identityStillId && request.identityStillId !== request.imageAId) {
|
||||||
|
out.push({ stillId: request.identityStillId, role: PHOTO_ROLE.person })
|
||||||
|
}
|
||||||
|
if (request.imageBId) out.push({ stillId: request.imageBId, role: PHOTO_ROLE.extra })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if (mode === 'video') {
|
||||||
|
if (request.imageAId) out.push({ stillId: request.imageAId, role: PHOTO_ROLE.first })
|
||||||
|
if (request.identityStillId) out.push({ stillId: request.identityStillId, role: PHOTO_ROLE.person })
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply Photos → request fields. Call before validate / queue.
|
||||||
|
* If Edit+Qwen has cards but no Photo to change, promote the first card.
|
||||||
|
*/
|
||||||
|
export function applyPhotosToRequest(raw = {}) {
|
||||||
|
const next = { ...raw }
|
||||||
|
const plan = photosPlan({ mode: next.mode, engine: next.engine })
|
||||||
|
if (!plan.show || plan.handoffReadonly) {
|
||||||
|
if (!Array.isArray(next.photos)) next.photos = []
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
let photos = normalizePhotos(next.photos, plan)
|
||||||
|
if (!photos.length && (next.imageAId || next.identityStillId || next.imageBId)) {
|
||||||
|
photos = normalizePhotos(photosFromRequest(next), plan)
|
||||||
|
}
|
||||||
|
// Promote first card to Photo to change when Edit needs it.
|
||||||
|
if (
|
||||||
|
plan.roles.includes(PHOTO_ROLE.change)
|
||||||
|
&& photos.length
|
||||||
|
&& !photos.some(p => p.role === PHOTO_ROLE.change)
|
||||||
|
) {
|
||||||
|
photos = [{ ...photos[0], role: PHOTO_ROLE.change }, ...photos.slice(1)]
|
||||||
|
}
|
||||||
|
const bound = bindPhotosToRequest(photos, { mode: next.mode, engine: next.engine })
|
||||||
|
next.photos = bound.photos
|
||||||
|
next.imageAId = bound.imageAId
|
||||||
|
next.imageBId = bound.imageBId
|
||||||
|
next.identityStillId = bound.identityStillId
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Qwen Edit: map mention phrases → <imageN> for TextEncode.
|
||||||
|
* Photo to change → <image1>; outfit/extra/person-on-2 → <image2>.
|
||||||
|
*/
|
||||||
|
export function injectQwenEditMentionTags(prompt, photos = []) {
|
||||||
|
let text = String(prompt || '')
|
||||||
|
const list = Array.isArray(photos) ? photos.filter(p => p?.stillId) : []
|
||||||
|
let change = list.find(p => p.role === PHOTO_ROLE.change)
|
||||||
|
if (!change && list[0]) change = { ...list[0], role: PHOTO_ROLE.change }
|
||||||
|
const outfit = list.find(p => p.role === PHOTO_ROLE.outfit || p.role === PHOTO_ROLE.extra)
|
||||||
|
const person = list.find(p => p.role === PHOTO_ROLE.person)
|
||||||
|
const image2 = outfit || (!outfit ? person : null)
|
||||||
|
|
||||||
|
const pairs = []
|
||||||
|
if (change) {
|
||||||
|
const idx = list.indexOf(list.find(p => p.stillId === change.stillId && p.role === change.role) || change)
|
||||||
|
pairs.push({ phrase: mentionPhrase(PHOTO_ROLE.change, Math.max(0, idx)), tag: '<image1>' })
|
||||||
|
// Also map generic Photo 1 if they used chip style
|
||||||
|
pairs.push({ phrase: 'Photo 1', tag: '<image1>' })
|
||||||
|
}
|
||||||
|
if (image2) {
|
||||||
|
const idx = list.findIndex(p => p.stillId === image2.stillId)
|
||||||
|
pairs.push({ phrase: mentionPhrase(image2.role, Math.max(0, idx)), tag: '<image2>' })
|
||||||
|
pairs.push({ phrase: 'Photo 2', tag: '<image2>' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Longer phrases first so "the outfit photo" wins over fragments.
|
||||||
|
pairs.sort((a, b) => b.phrase.length - a.phrase.length)
|
||||||
|
for (const { phrase, tag } of pairs) {
|
||||||
|
if (!phrase || text.includes(tag) && !text.includes(phrase)) continue
|
||||||
|
if (text.includes(phrase)) text = text.split(phrase).join(tag)
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
@@ -100,8 +100,14 @@ test('qwen21 supports Generate and Edit with 25/1 defaults and no locks',()=>{
|
|||||||
const edit=validateRequest({mode:'edit',engine:'qwen21',folderId:'f',imageAId:'s',promptSections:{action:'change the sky'},settings:{aspect:'auto'}})
|
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/)
|
||||||
|
|||||||
Reference in New Issue
Block a user