From bb27af506e2b84b1beae9a24438ddba551ee9b4c Mon Sep 17 00:00:00 2001 From: Towsty Date: Mon, 21 Sep 2026 12:23:20 -0500 Subject: [PATCH] Restore Studio 2 Load hydrate, sticky negatives, and strip actions. Co-authored-by: Cursor --- assets/css/display.css | 36 ++++++ components/studio2/Bench.vue | 199 ++++++++++++++++++++++++++++++- components/studio2/Mobile.vue | 43 ++++++- pages/queue.vue | 4 +- server/api/studio-2/jobs.post.ts | 2 +- server/utils/generationLog.ts | 35 +++++- server/utils/studioQueue.ts | 2 + shared/studio2/input.mjs | 67 ++++++++++- shared/studio2/styles.mjs | 8 +- tests/studio2-bench.test.mjs | 20 +++- 10 files changed, 401 insertions(+), 15 deletions(-) diff --git a/assets/css/display.css b/assets/css/display.css index d159749..d7b59a8 100644 --- a/assets/css/display.css +++ b/assets/css/display.css @@ -437,6 +437,42 @@ .tile .tile-trash { right: 22px; } +.strip-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + flex-shrink: 0; + border-top: 1px solid var(--line); + padding: 8px 10px; + background: var(--panel); +} + +.strip-actions button { + border: 1px solid var(--line); + border-radius: 6px; + background: transparent; + color: var(--text); + padding: 4px 10px; + font-size: var(--type-sm); + cursor: pointer; +} + +.strip-actions button:hover { + border-color: var(--amber); + color: var(--amber); +} + +.neg-add-row { + display: flex; + gap: 8px; + align-items: center; + margin-top: 8px; +} + +.neg-add-row .field-control { + flex: 1; +} + .transport { display: flex; height: 28px; diff --git a/components/studio2/Bench.vue b/components/studio2/Bench.vue index 7934d6d..6595d56 100644 --- a/components/studio2/Bench.vue +++ b/components/studio2/Bench.vue @@ -78,8 +78,28 @@ -
+
+
+ + + + + +
+
{{ transportLabel }}
@@ -324,7 +363,7 @@ 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} from '~/shared/studio2/input.mjs' +import {aspectOptions,resolveSize,bindClip,restoreAll,useAsHero,useAsSource,hydrateStudio2Job} from '~/shared/studio2/input.mjs' import {folderAssets,newestOutput,assetLabel,reconcileSelection,previewHidden} from '~/shared/studio2/viewer.mjs' import {queueJobs} from '~/shared/studio2/queue-view.mjs' import {gpuStatus} from '~/shared/studio2/status.mjs' @@ -333,6 +372,22 @@ import {defaultNegatives,parseBatch} from '~/shared/studio2/styles.mjs' import {randomSeed} from '~/shared/studio2/seed.mjs' import {DEFAULT_MUSIC_LYRICS,DEFAULT_MUSIC_TAGS} from '~/utils/music' import {isXaigenStudio} from '~/utils/videoModels' +import {LOAD_GENERATION_PAYLOAD_KEY} from '~/utils/queuedJob' + +const NEG_STORAGE_KEY='studio2-negative-chips' +function readStoredNegatives(){ + if(!import.meta.client)return [...defaultNegatives] + try{ + const raw=localStorage.getItem(NEG_STORAGE_KEY) + if(raw===null)return [...defaultNegatives] + const parsed=JSON.parse(raw) + return Array.isArray(parsed)?parsed.map((s:any)=>String(s||'').trim()).filter(Boolean):[...defaultNegatives] + }catch{return [...defaultNegatives]} +} +function writeStoredNegatives(list:string[]){ + if(!import.meta.client)return + try{localStorage.setItem(NEG_STORAGE_KEY,JSON.stringify(list))}catch{/* ignore */} +} const xaigen=isXaigenStudio(useRuntimeConfig().public) const instanceName=String(useRuntimeConfig().public.instanceName || 'AIGen') @@ -349,7 +404,10 @@ const loraNames=ref({image:[],video:[]}),now=ref(Date.now()),healthState=re const library=ref({folders:[],stills:[],clips:[],tracks:[]}),jobs=ref([]),selected=ref(null) const studioQueue=ref([]) const liveMusic=ref>({}) -const form=reactive({mode:'generate',engine:'flux',imageStyles:{positive:[],negative:[...defaultNegatives]},lockFace:true,lockOutfit:true,hideInput:xaigen,folderId:'',identityStillId:'',imageAId:'',imageBId:'',maskId:'',startClipId:'',endStillId:'',guides:[],promptSections:sectionsFrom({}),settings:{aspect:'16:9',fps:24,steps:24,cfg:4,seed:null,seedMode:'random',duration:5,turbo:true,loraStack:[]}}) +const negativeDraft=ref('') +const filmstripEl=ref(null) +const tileRefs=new Map() +const form=reactive({mode:'generate',engine:'flux',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 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) @@ -425,6 +483,21 @@ const sourceAsset=computed(()=>form.startClipId?clips.value.find((c:any)=>c.id== 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 '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 source is . Hero is unused unless the graph maps it to .' + return 'Klein: Hero = identity encode. Edit source = the picture you are changing.' +}) const queueCards=computed(()=>{ const cards:any[]=[] for(const job of benchJobs.value){ @@ -498,7 +571,106 @@ function toggleFullscreen(){ const el=viewerVideo.value || document.querySelector('.viewer-frame') if(el && 'requestFullscreen' in el) void (el as HTMLElement).requestFullscreen() } -function removeNegative(chip:string){form.imageStyles={...form.imageStyles,negative:(form.imageStyles.negative||[]).filter((c:string)=>c!==chip)}} +function removeNegative(chip:string){ + form.imageStyles={...form.imageStyles,negative:(form.imageStyles.negative||[]).filter((c:string)=>c!==chip)} + writeStoredNegatives(form.imageStyles.negative) +} +function addNegative(){ + const chip=negativeDraft.value.trim() + if(!chip)return + const list=form.imageStyles.negative||[] + if(!list.includes(chip)){ + form.imageStyles={...form.imageStyles,negative:[...list,chip]} + writeStoredNegatives(form.imageStyles.negative) + } + negativeDraft.value='' +} +function setTileRef(id:string,el:any){ + if(el)tileRefs.set(id,el as HTMLElement) + else tileRefs.delete(id) +} +function scrollFilmstripToNewest(){ + nextTick(()=>{ + const id=selected.value?.id || family.value.at(-1)?.id + const tile=id?tileRefs.get(id):null + const strip=filmstripEl.value + if(tile?.scrollIntoView)tile.scrollIntoView({inline:'end',block:'nearest',behavior:'smooth'}) + else if(strip)strip.scrollLeft=strip.scrollWidth + }) +} +function applyUseAsSource(){ + if(!selected.value||selected.value.kind==='track')return + Object.assign(form,useAsSource(form,selected.value)) + uploadMessage.value=`Using ${assetLabel(selected.value)} as start / input.` +} +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.` +} +async function copySelectedPrompt(){ + const text=selected.value?.studio2?.promptSections?.action + || selected.value?.studio2?.compiledPrompt + || selected.value?.prompt + || '' + try{ + if(import.meta.client && navigator.clipboard)await navigator.clipboard.writeText(String(text||'')) + uploadMessage.value=text?'Prompt copied.':'No prompt on this item.' + }catch{error.value='Could not copy prompt.'} +} +function stillIdSet(){return new Set(stills.value.map((s:any)=>s.id))} +function clipIdSet(){return new Set(clips.value.map((c:any)=>c.id))} +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||[] + batchText.value='' + if(output)selected.value=output + else if(form.mode==='music'){/* keep */} + error.value=missing||'' + if(missing)uploadMessage.value=missing + else uploadMessage.value='Job loaded into the bench.' +} +function loadSelectedJob(){ + if(!selected.value)return + const saved=selected.value.studio2 + if(!saved){error.value='No saved job on this still.';return} + applyHydratedJob(saved,selected.value) +} +async function applyIncomingGenerationLoad(){ + if(!import.meta.client)return + let raw='' + try{raw=sessionStorage.getItem(LOAD_GENERATION_PAYLOAD_KEY)||''}catch{return} + if(!raw)return + try{sessionStorage.removeItem(LOAD_GENERATION_PAYLOAD_KEY)}catch{/* ignore */} + let loaded:any + try{loaded=JSON.parse(raw)}catch{error.value='Could not read that saved generation.';return} + const payload=loaded?.payload||{} + if(loaded?.kind==='music' || payload.lyrics != null || payload.tags != null){ + form.mode='music' + if(payload.folderId)form.folderId=payload.folderId + music.tags=String(payload.tags||payload.prompt||music.tags) + music.lyrics=String(payload.lyrics||music.lyrics) + if(payload.duration!=null)music.duration=Number(payload.duration)||music.duration + if(payload.seed!=null)music.seed=String(payload.seed) + if(payload.name)music.name=String(payload.name) + uploadMessage.value='Music job loaded into the bench.' + return + } + let request=payload.studio2Request + if(!request && payload.studio2Id){ + await refresh() + request=jobs.value.find((j:any)=>j.id===payload.studio2Id)?.request + } + if(!request){error.value='That generation has no Studio 2 request to load.';return} + if(request.folderId)form.folderId=request.folderId + await refresh() + const outputs=jobs.value.find((j:any)=>j.id===payload.studio2Id)?.outputs||[] + const outId=outputs.at(-1)?.id + const output=outId?family.value.find((a:any)=>a.id===outId):null + applyHydratedJob(request,output||undefined) + scrollFilmstripToNewest() +} function focusQueueCard(card:any){ focusedJobId.value=card.id if(card.job?.outputs?.length)focusJob(card.job) @@ -623,6 +795,8 @@ async function refresh(){ let next=existingFresh || reconcileSelection(selected.value,family.value) if(form.mode!=='music' && next?.kind==='track') next=family.value.find((a:any)=>a.kind!=='track') || null selected.value=next + if(fresh || existingFresh) 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'}) @@ -633,11 +807,13 @@ onMounted(async()=>{ loraMemory=createLoraMemory(xaigen,storage);loraMemory.sync(form);form.settings.seed=randomSeed() clock=setInterval(()=>{now.value=Date.now()},1000);void loadLoras(true) library.value=await $fetch('/api/studio-2/library');form.folderId=library.value.folders[0]?.id||'';loadRevealed(form.folderId) + await applyIncomingGenerationLoad() const id=String(route.query.still||route.query.clip||route.query.track||'') const asset=[...library.value.stills,...library.value.clips,...(library.value.tracks||[])].find((a:any)=>a.id===id) if(asset){selected.value={...asset,kind:route.query.clip?'clip':route.query.track?'track':'still'};if(route.query.use==='1'&&selected.value.kind!=='track')restore(selected.value);if(xaigen)toggleReveal(asset.id)} if(route.query.task==='music')form.mode='music' const poll=async()=>{try{await refresh();const health=await $fetch('/api/health');healthState.value=health}catch(e:any){healthState.value=null;error.value=e.data?.statusMessage||e.message}if(!disposed)timer=setTimeout(poll,3000)};void poll() + scrollFilmstripToNewest() }) onBeforeUnmount(()=>{disposed=true;clearTimeout(timer);clearInterval(clock)}) @@ -669,6 +845,11 @@ const mobileCtx=computed(()=>({ error:error.value, batchText:batchText.value, negativeChips:negativeChips.value, + negativeDraft:negativeDraft.value, + heroSlotLabel:heroSlotLabel.value, + sourceSlotLabel:sourceSlotLabel.value, + slotPairHint:slotPairHint.value, + editSourceRequired:editSourceRequired.value, pickerLoras:pickerLoras.value, currentLoraUse:currentLoraUse.value, loraAliases:{...loraNames.value.aliases,...library.value.loraAliases}, @@ -687,8 +868,14 @@ const mobileCtx=computed(()=>({ focusQueueCard, clearFinished, removeNegative, + addNegative, + applyUseAsSource, + applyUseAsHero, + copySelectedPrompt, + loadSelectedJob, randomizeSeed, setPicker, setBatchText:(v:string)=>{batchText.value=v}, + setNegativeDraft:(v:string)=>{negativeDraft.value=v}, })) diff --git a/components/studio2/Mobile.vue b/components/studio2/Mobile.vue index 6c231a3..d7fb0e1 100644 --- a/components/studio2/Mobile.vue +++ b/components/studio2/Mobile.vue @@ -107,6 +107,14 @@
+
+ + + + + +
+
@@ -125,8 +133,10 @@

Music uses tags + lyrics in Prompt / Gen — no hero slot.

@@ -519,6 +540,24 @@ watch(() => props.ctx.form.mode, () => { sheet.value = '' }) font-size: 12px; } .m-neg-chip button { border: 0; background: transparent; color: inherit; } +.m-neg-add { display: flex; gap: 8px; align-items: center; margin-top: 8px; } +.m-neg-add .m-input { flex: 1; margin-top: 0; } +.m-strip-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 8px 10px; + border-top: 1px solid var(--line); + background: var(--panel); +} +.m-strip-actions button { + border: 1px solid var(--line); + border-radius: 6px; + background: transparent; + color: var(--text); + padding: 4px 8px; + font-size: 12px; +} .m-seed { display: flex; gap: 8px; margin-top: 4px; } .m-seed .m-input { margin-top: 0; flex: 1; } .m-dice { diff --git a/pages/queue.vue b/pages/queue.vue index 90db6a0..2e07ed8 100644 --- a/pages/queue.vue +++ b/pages/queue.vue @@ -961,8 +961,8 @@ async function loadLog(entry: GenerationLogEntry) { kind: entry.kind, payload: entry.payload })) - if (entry.kind === 'music') { - await navigateTo('/music') + if (entry.payload?.studio2Id || entry.payload?.studio2Request || entry.kind === 'music') { + await navigateTo(entry.kind === 'music' ? '/studio-2?task=music' : '/studio-2') } else { await navigateTo('/') } diff --git a/server/api/studio-2/jobs.post.ts b/server/api/studio-2/jobs.post.ts index 5210183..3cc413c 100644 --- a/server/api/studio-2/jobs.post.ts +++ b/server/api/studio-2/jobs.post.ts @@ -28,7 +28,7 @@ export default defineEventHandler(async (event) => { await resolveRequestSize(owner,request); const id = crypto.randomUUID(), record = { id, owner, request, state: 'waiting', queuedAt: Date.now(), familyId: crypto.randomUUID(), outputs: [], purgeResult: 'Not yet saved' }; saveRecord(record); - const row = await addStudioJob({ ownerKey: owner, kind: ['video', 'extend'].includes(request.mode) ? 'video' : 'edit', familyId: record.familyId, payload: { ...request.settings, studio2Id: id, prompt: request.compiledPrompt, folderId: request.folderId, extensions: [], referenceStillIds: [], useIdentityRefs: false, queueAutoRun: true } as StudioJobPayload }); + const row = await addStudioJob({ ownerKey: owner, kind: ['video', 'extend'].includes(request.mode) ? 'video' : 'edit', familyId: record.familyId, payload: { ...request.settings, studio2Id: id, studio2Request: structuredClone(request), prompt: request.compiledPrompt, folderId: request.folderId, extensions: [], referenceStillIds: [], useIdentityRefs: false, queueAutoRun: true } as StudioJobPayload }); saveRecord({ ...readRecord(owner, id), queueId: row.id }); kickStudioQueue(); return { id, seed:request.settings.seed, width:request.settings.width, height:request.settings.height }; diff --git a/server/utils/generationLog.ts b/server/utils/generationLog.ts index 0f42fef..c809b85 100644 --- a/server/utils/generationLog.ts +++ b/server/utils/generationLog.ts @@ -139,11 +139,44 @@ export async function updateGenerationLogByStudioJob( export async function requeueGenerationLog(owner: string, id: string) { const entry = getGenerationLogEntry(owner, id) const { addStudioJob, kickStudioQueue } = await import('~/server/utils/studioQueue') + let payload = structuredClone(entry.payload) + if (payload?.studio2Id || payload?.studio2Request) { + const { saveRecord, readRecord } = await import('~/server/utils/studio2/store') + let request = payload.studio2Request + if (!request && payload.studio2Id) { + try { request = structuredClone(readRecord(owner, payload.studio2Id).request) } + catch { request = null } + } + if (!request) throw createError({ statusCode: 400, statusMessage: 'That Studio 2 job has no saved request to re-queue.' }) + const studio2Id = crypto.randomUUID() + saveRecord({ + id: studio2Id, + owner, + request: structuredClone(request), + state: 'waiting', + queuedAt: Date.now(), + familyId: crypto.randomUUID(), + outputs: [], + purgeResult: 'Not yet saved' + }) + payload = { + ...payload, + ...structuredClone(request.settings || {}), + studio2Id, + studio2Request: structuredClone(request), + prompt: request.compiledPrompt || payload.prompt, + folderId: request.folderId || payload.folderId, + extensions: [], + referenceStillIds: [], + useIdentityRefs: false, + queueAutoRun: true + } + } const studio = await addStudioJob({ ownerKey: owner, familyId: crypto.randomUUID(), kind: entry.kind, - payload: structuredClone(entry.payload) + payload }) await kickStudioQueue() return { studioJobId: studio.id, entry: listGenerationLog(owner, 1)[0] || getGenerationLogEntry(owner, id) } diff --git a/server/utils/studioQueue.ts b/server/utils/studioQueue.ts index 1228c7f..ba8214a 100644 --- a/server/utils/studioQueue.ts +++ b/server/utils/studioQueue.ts @@ -17,6 +17,8 @@ export type StudioJobKind = 'video' | 'edit' | 'music' export interface StudioJobPayload { studio2Id?: string + /** Full Studio 2 validateRequest snapshot for Load / Re-queue. */ + studio2Request?: Record upscale?: { sourceId: string; scale: 2 | 4; target: string; fps: string | number; enhance: string } prompt: string promptMid?: string diff --git a/shared/studio2/input.mjs b/shared/studio2/input.mjs index 60c3381..59c0aa6 100644 --- a/shared/studio2/input.mjs +++ b/shared/studio2/input.mjs @@ -1,5 +1,6 @@ import {restoreLoras,loraUse} from './lora-memory.mjs' -import { restoreInput } from './contracts.mjs' +import { restoreInput, sectionsFrom } from './contracts.mjs' +import { normalizeStyles } from './styles.mjs' export const aspectOptions = [{id:'auto',label:'Automatic',hint:'Match start or hero still'},{id:'16:9',label:'16:9',hint:'1344 × 768 · padded'},{id:'9:16',label:'9:16',hint:'768 × 1344 · padded'},{id:'1:1',label:'1:1',hint:'960 × 960 · padded'}] export function resolveQwen21Size(aspect, source) { const presets = {'16:9':[1024,576],'9:16':[576,1024],'1:1':[1024,1024]} @@ -70,6 +71,70 @@ export function changeSource(form) { export function useAsHero(form, item) { return {...form,identityStillId:item.id} } export function useAsSource(form, item) { return {...form,imageAId:item.id} } + +/** Hydrate the live bench from a saved Studio 2 request (Load). Does not enqueue. */ +export function hydrateStudio2Job(form, saved, { stillIds = new Set(), clipIds = new Set(), xaigen = false, memory } = {}) { + const src = structuredClone(saved?.studio2 || saved || {}) + const missing = [] + const keepStill = (id, label) => { + const value = id || '' + if (!value) return '' + if (stillIds.has(value)) return value + missing.push(`${label} missing from library`) + return '' + } + const keepClip = (id, label) => { + const value = id || '' + if (!value) return '' + if (clipIds.has(value)) return value + missing.push(`${label} missing from library`) + return '' + } + const modeRaw = src.mode === 'iterate' ? 'edit' : src.mode + const mode = ['generate','edit','compose','refine','video','extend','music'].includes(modeRaw) ? modeRaw : 'edit' + let engine = src.engine === 'qwen21' ? 'qwen21' : src.engine === 'krea' ? 'krea' : src.engine === 'ltx' ? 'ltx' : src.engine === 'minimax' ? 'minimax' : 'flux' + if (['video','extend'].includes(mode)) engine = xaigen && engine === 'ltx' ? 'ltx' : 'minimax' + else if (engine === 'minimax' || engine === 'ltx') engine = 'flux' + const settings = structuredClone(src.settings || {}) + const aspect = ['auto','16:9','9:16','1:1'].includes(settings.aspect) ? settings.aspect : 'auto' + const loraStack = Array.isArray(settings.loraStack) ? settings.loraStack.map(item => ({ + name: item.name, + strengthModel: Number(item.strengthModel ?? item.strength ?? 1), + strengthClip: Number(item.strengthClip ?? item.strengthModel ?? item.strength ?? 1) + })) : [] + const guides = (Array.isArray(src.guides) ? src.guides : []).map(g => { + const stillId = keepStill(g.stillId, 'guide still') + return stillId ? { stillId, frame: g.frame } : null + }).filter(Boolean) + const next = { + ...form, + mode, + engine, + lockFace: src.lockFace !== false, + lockOutfit: src.lockOutfit !== false, + hideInput: !!src.hideInput, + folderId: src.folderId || form.folderId, + identityStillId: keepStill(src.identityStillId, 'hero'), + imageAId: keepStill(src.imageAId, 'start'), + imageBId: keepStill(src.imageBId, 'second still'), + maskId: keepStill(src.maskId, 'mask'), + startClipId: keepClip(src.startClipId, 'start clip'), + endStillId: keepStill(src.endStillId, 'end still'), + guides, + imageStyles: normalizeStyles(src.imageStyles || { positive: [], negative: [] }), + promptSections: sectionsFrom(src.promptSections || { action: src.compiledPrompt || src.prompt || '' }), + settings: { + ...form.settings, + ...settings, + aspect, + seed: settings.seed ?? form.settings?.seed ?? null, + seedMode: 'locked', + loraStack + } + } + return { form: restoreLoras(next, loraStack, xaigen, memory), missing: missing[0] || '', warnings: missing } +} + export function restoreAll(form,item,xaigen=false,memory) { const restored=restoreInput(item),next={...form,...restored,settings:{...form.settings,...restored.settings,seedMode:'random'}} if(item.kind==='clip') return {...bindClip(next,item,xaigen,memory),mode:'extend'} diff --git a/shared/studio2/styles.mjs b/shared/studio2/styles.mjs index b5364e4..1135c30 100644 --- a/shared/studio2/styles.mjs +++ b/shared/studio2/styles.mjs @@ -1,6 +1,12 @@ export const styleGroups={Render:['Photorealistic','Pixar','3D Disney','Comic','Watercolor','Oil painting'],Quality:['Realistic skin','Skin pores','Fine detail','Natural texture'],Lighting:['Golden hour','Soft studio','Window light','Overcast','Cinematic lighting']} export const defaultNegatives=['blurry','deformed','low quality','text','watermark'] -export function normalizeStyles(value={}) { const known=[...Object.values(styleGroups).flat(),...defaultNegatives];const positive=[...new Set((value.positive||[]).filter(s=>known.includes(s)))];return {positive,negative:[...new Set((value.negative||[]).filter(s=>known.includes(s)&&!positive.includes(s)))]} } +export function normalizeStyles(value={}) { + const known=[...Object.values(styleGroups).flat(),...defaultNegatives] + const positive=[...new Set((value.positive||[]).filter(s=>known.includes(s)))] + // Freeform negatives are allowed (sticky custom chips); positives stay on the known style list. + const negative=[...new Set((value.negative||[]).map(s=>String(s||'').trim()).filter(s=>s&&!positive.includes(s)))] + return {positive,negative} +} export function toggleStyle(value,chip,negative=false) { const next=normalizeStyles(value),key=negative?'negative':'positive',other=negative?'positive':'negative';next[other]=next[other].filter(s=>s!==chip);next[key]=next[key].includes(chip)?next[key].filter(s=>s!==chip):[...next[key],chip];return next } export function stylePrompt(value,negative=false){return normalizeStyles(value)[negative?'negative':'positive'].join(', ')} export function parseBatch(value){return String(value||'').split(/\r?\n\s*\r?\n/).map(s=>s.trim()).filter(Boolean)} diff --git a/tests/studio2-bench.test.mjs b/tests/studio2-bench.test.mjs index 192d152..07f3b55 100644 --- a/tests/studio2-bench.test.mjs +++ b/tests/studio2-bench.test.mjs @@ -3,7 +3,7 @@ import assert from 'node:assert/strict' import {readFileSync} from 'node:fs' import ts from 'typescript' import {folderAssets,reconcileSelection,activeJobs,previewHidden} from '../shared/studio2/viewer.mjs' -import {useAsHero,useAsSource,restoreAll} from '../shared/studio2/input.mjs' +import {useAsHero,useAsSource,restoreAll,hydrateStudio2Job} from '../shared/studio2/input.mjs' import {toggleStyle,parseBatch,stylePrompt} from '../shared/studio2/styles.mjs' import {compilePrompt,validateRequest} from '../shared/studio2/contracts.mjs' import {cachedLoras} from '../shared/studio2/lora-cache.mjs' @@ -88,3 +88,21 @@ test('qwen21 supports Generate and Edit with 25/1 defaults and no locks',()=>{ 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/) }) +test('hydrateStudio2Job restores LoRA strengths, locked seed, and clears missing stills',()=>{ + const form={mode:'generate',engine:'flux',identityStillId:'',imageAId:'',promptSections:{action:''},imageStyles:{positive:[],negative:[]},settings:{aspect:'auto',seed:null,seedMode:'random',loraStack:[]},guides:[]} + const saved={mode:'edit',engine:'flux',identityStillId:'hero1',imageAId:'gone',lockFace:true,lockOutfit:false,promptSections:{action:'pokemon trainer'},imageStyles:{positive:[],negative:['blurry','custom bad']},settings:{aspect:'16:9',seed:42,steps:24,cfg:4,loraStack:[{name:'klein-pokemon.safetensors',strengthModel:1.6,strengthClip:1}]}} + const {form:next,missing}=hydrateStudio2Job(form,saved,{stillIds:new Set(['hero1']),clipIds:new Set()}) + assert.equal(next.mode,'edit');assert.equal(next.identityStillId,'hero1');assert.equal(next.imageAId,'') + assert.equal(next.settings.seed,42);assert.equal(next.settings.seedMode,'locked');assert.equal(next.settings.aspect,'16:9') + assert.deepEqual(next.settings.loraStack,[{name:'klein-pokemon.safetensors',strengthModel:1.6,strengthClip:1}]) + assert.deepEqual(next.imageStyles.negative,['blurry','custom bad']) + assert.match(missing,/start missing/) +}) +test('custom negative chips survive normalizeStyles and bench exposes Load actions',()=>{ + let styles={positive:[],negative:['blurry','my watermark']} + 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/) + assert.match(src,/aspect:'auto'/);assert.match(src,/strip-actions/);assert.match(src,/addNegative/) +})