72 lines
7.5 KiB
JavaScript
72 lines
7.5 KiB
JavaScript
import {normalizeStyles,stylePrompt} from './styles.mjs'
|
||
import { queueSeeds } from './seed.mjs'
|
||
import { sectionsFrom, compileSections } 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 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 function appearanceLock({lockFace = true, lockOutfit = true} = {}) {
|
||
if (lockFace && lockOutfit) return identityLock
|
||
if (lockFace) return '<Picture 1> / hero is identity for face, hair, glasses, and body only — not wardrobe. The start frame is composition, pose, place, and clothing. Do not restore an earlier outfit. Do not reset clothes, straps, or print to the hero still.'
|
||
if (lockOutfit) return '<Picture 1> / hero is wardrobe only. Preserve clothes from the hero. Face, hair, glasses, body, pose, place, and camera follow the start frame.'
|
||
return 'The start frame is the only appearance reference for person and clothing, as well as pose, place, and camera. Do not use the hero as an appearance reference.'
|
||
}
|
||
export function compilePrompt(value, video = false, locks = {}) {
|
||
if (video) return [compileSections(value),appearanceLock(locks)].filter(Boolean).join('\n\n')
|
||
const hasImage=!!(locks.imageAId || locks.identityStillId)
|
||
const attached=hasImage && locks.engine !== 'krea'
|
||
const reference=locks.mode!=='generate' && locks.identityStillId && locks.imageAId && locks.identityStillId!==locks.imageAId ? 'second (hero) reference' : 'image reference'
|
||
return [sectionsFrom(value).action, stylePrompt(locks.imageStyles),
|
||
attached && locks.lockFace !== false ? `Preserve the person’s face, glasses, hair, and body identity from the ${reference}.` : '',
|
||
attached && locks.lockOutfit !== false ? `Preserve the wardrobe from the ${reference}.` : '',
|
||
attached && locks.lockOutfit === false ? 'Clothing may follow the source and requested scene; do not restore the hero outfit.' : ''
|
||
].filter(Boolean).join('\n\n')
|
||
}
|
||
export function restoreInput(item) {
|
||
const saved = structuredClone(item.studio2 || 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 || {}
|
||
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.')
|
||
const video = ['video','extend'].includes(mode)
|
||
if (!(video ? ['minimax','ltx'] : ['flux','krea']).includes(engine)) fail('Engine does not support this task.')
|
||
if (engine === 'ltx' && !xaigen) fail('LTX is available only in xAIGen.')
|
||
if (engine === 'krea' && ['edit','compose','iterate'].includes(mode)) fail('Sticky hero edits require Klein. Krea remains available for Generate and masked Refine; no engine fallback is performed.')
|
||
if (Array.isArray(raw.batch) && raw.batch.length>30) fail('A batch supports up to 31 shots including the first prompt.')
|
||
if (Array.isArray(raw.guides) && raw.guides.length>8) fail('Use up to eight guide stills.')
|
||
if (!raw.folderId) fail('Choose a folder.')
|
||
if ((video || ['edit','compose','iterate'].includes(mode)) && (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 (mode === 'refine' && !raw.maskId) fail('Refine requires a mask.')
|
||
if (['edit','compose','refine'].includes(mode) && !raw.imageAId && !(mode==='edit' && engine==='flux' && raw.identityStillId)) fail('Choose the source still.')
|
||
if (mode === 'extend' && !raw.startClipId) fail('Select the clip in the family strip or Previous clip.')
|
||
const allSections = sectionsFrom(raw.promptSections || {action:raw.prompt || ''})
|
||
const promptSections = video ? allSections : {action:allSections.action}
|
||
if (!promptSections.action.trim()) fail('Enter an action prompt.')
|
||
const number=(key, fallback, min, max) => { const n=Number(raw.settings?.[key] ?? fallback); if (!Number.isFinite(n)||n<min||n>max) fail(`Invalid ${key}.`); return n }
|
||
for (const key of ['steps']) if (!Number.isInteger(number(key,key==='steps'?24:1,0,2147483647))) fail(`Invalid ${key}.`)
|
||
for (const key of ['width','height']) if (number(key,key==='width'?1344:768,256,4096)%32) fail('Dimensions must be multiples of 32.')
|
||
const duration=number('duration',5,1,15)
|
||
if (video) {
|
||
if (promptSections.cameraBeats.slice(0,-1).some(b=>b.durationSec == null)) fail('Only the last camera beat can run to the end.')
|
||
if (promptSections.cameraBeats.reduce((sum,b)=>sum+(b.durationSec || 0),0)>duration) fail('Camera beats exceed the clip duration.')
|
||
if (raw.lockFace === false && raw.lockOutfit === false && !raw.imageAId && !raw.startClipId) fail('Choose a start still or previous clip when both identity locks are off.')
|
||
}
|
||
const frames=Math.ceil((duration*number('fps',24,1,60)-5)/17)*17+5
|
||
const shotSeeds=queueSeeds(raw.settings,1+(Array.isArray(raw.batch)?raw.batch.length:0))
|
||
return { mode, engine, shotSeeds, imageStyles:video?{positive:[],negative:[]}:normalizeStyles(raw.imageStyles), hideOutput:!!raw.hideOutput, lockFace:raw.lockFace !== false, lockOutfit:raw.lockOutfit !== false, hideInput:!!raw.hideInput, folderId:String(raw.folderId), identityStillId:raw.identityStillId || '', imageAId:raw.imageAId || '', imageBId:raw.imageBId || '', maskId:raw.maskId || '', startClipId:raw.startClipId || '', endStillId:raw.endStillId || '',
|
||
startFrameSource: raw.startClipId ? {kind:'previous-last-frame',clipId:raw.startClipId} : {kind:'still',stillId:raw.imageAId || ''},
|
||
promptSections, compiledPrompt:compilePrompt(promptSections,video,raw),
|
||
settings: { aspect:['auto','16:9','9:16','1:1'].includes(raw.settings?.aspect)?raw.settings.aspect:'auto', fps:number('fps',24,1,60), width:number('width',1344,256,4096),height:number('height',768,256,4096),steps:number('steps',video?8:24,1,100),cfg:number('cfg',video?1.5:4,0,20),seedMode:raw.settings?.seedMode==='locked'?'locked':'random',seed:shotSeeds[0],duration:number('duration',5,1,15),turbo:raw.settings?.turbo !== false, loraStack:Array.isArray(raw.settings?.loraStack)?raw.settings.loraStack:[] },
|
||
guides: (Array.isArray(raw.guides)?raw.guides:[]).slice(0,8).map(g=>{ if (!g.stillId || !Number.isInteger(g.frame) || g.frame<1 || g.frame>=frames) fail('Each guide requires a still and positive frame index.'); return {stillId:String(g.stillId),frame:g.frame} }),
|
||
batch: (Array.isArray(raw.batch)?raw.batch:[]).slice(0,30).map(s=>{const next=sectionsFrom({...promptSections,...(typeof s==='string'?{action:s}:s)});if(!next.action.trim())fail('Every shot needs an action prompt.');return video?next:{action:next.action}}) }
|
||
}
|
||
export function scopedFile(file, prefix) {
|
||
const clean = s => String(s || '').replaceAll('\\','/')
|
||
const path = [clean(file.subfolder),clean(file.filename)].filter(Boolean).join('/')
|
||
const p=clean(prefix).replace(/\/$/,'')
|
||
return !!p && !path.split('/').some(s=>s==='..'||s==='.') && !path.startsWith('/') && path.startsWith(p + '/')
|
||
}
|