export const identityLock = ' is identity. Face, hair, glasses, outfit fully preserved. Start frame is composition only. Do not copy facial drift from the start frame.' 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 function sectionsFrom(value = {}) { if (typeof value === 'string') value = { action: value } const sections = Object.fromEntries(['action','camera','soundscape','dialogue','music','locks'].map(key => [key, String(value[key] || '')])) const chip = value.cameraChip if (chip && cameraFamilies.includes(chip.family)) sections.cameraChip = { family: chip.family, amplitude: ['subtle','moderate','large'].includes(chip.amplitude) ? chip.amplitude : 'subtle', speed: ['slow','medium','fast'].includes(chip.speed) ? chip.speed : 'slow' } return sections } export function compilePrompt(value, video = false) { const s = sectionsFrom(value), c = s.cameraChip const camera = c ? (c.family === 'static' ? 'Static shot. Camera locked on a tripod.' : `Camera ${c.family}, ${c.amplitude} movement, ${c.speed} speed.`) : s.camera return [s.action, camera && `Camera: ${camera}`, s.soundscape && `Soundscape: ${s.soundscape}`, s.dialogue && `Dialogue: (S1) [English]${s.dialogue}`, s.music && `Music: ${s.music}`, s.locks && `Locks: ${s.locks}`, video ? identityLock : ''].filter(Boolean).join('\n\n') } export function restoreInput(item) { const saved = item.studio2 || item return { ...saved, promptSections: sectionsFrom(saved.promptSections || { action: item.prompt || saved.compiledPrompt || '' }) } } export function validateRequest(raw, xaigen = false) { 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 (!raw.folderId) fail('Choose a folder.') if ((video || ['edit','compose','iterate'].includes(mode)) && !raw.identityStillId) 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) fail('Choose the source still.') if (mode === 'extend' && !raw.startClipId) fail('Choose the video to extend.') const promptSections = sectionsFrom(raw.promptSections || {action:raw.prompt || ''}) 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)||nmax) fail(`Invalid ${key}.`); return n } return { mode, engine, 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), settings: { 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),seed:number('seed',Math.floor(Math.random()*2147483647),0,2147483647),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) 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=>sectionsFrom(typeof s==='string'?{action:s}:s)) } } 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 + '/') }