Files
aigen/components/studio2/Bench.vue
T

1835 lines
75 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="bench-root" :data-display="displayMode" :data-mobile="isMobile ? '1' : undefined">
<Studio2Mobile v-if="isMobile" :ctx="mobileCtx" />
<template v-else>
<header class="bench-header">
<div class="bench-brand">{{ instanceName }}</div>
<div class="bench-crumb" aria-label="Folder">
<span aria-hidden="true">📁</span>
<span>/ Projects /</span>
<select v-model="form.folderId" :aria-label="'Folder'">
<option v-for="f in library.folders" :key="f.id" :value="f.id">{{ f.name }}</option>
</select>
</div>
<div class="bench-header-right">
<nav class="bench-nav" aria-label="Studio links">
<NuxtLink to="/library">Library</NuxtLink>
<NuxtLink to="/queue">Queue</NuxtLink>
<NuxtLink to="/settings">Settings</NuxtLink>
</nav>
<span class="gpu-chip" :data-tone="gpu.tone" role="status" :title="gpu.detail">
<span class="dot" aria-hidden="true" />
{{ gpuChipLabel }}
</span>
</div>
</header>
<nav class="rail rail-task" aria-label="Tasks">
<button
v-for="task in tasks"
:key="task"
type="button"
class="seg"
:aria-pressed="form.mode===task"
@click="chooseTask(task)"
>{{ task }}</button>
</nav>
<div v-if="form.mode!=='music'" class="rail rail-model" role="group" aria-label="Model">
<button
v-for="opt in engineOptions"
:key="opt.value"
type="button"
class="seg"
:aria-pressed="form.engine===opt.value"
:disabled="opt.disabled"
:title="opt.hint || undefined"
@click="!opt.disabled && (form.engine=opt.value)"
>{{ opt.label }}</button>
</div>
<p v-else class="rail rail-model text-[var(--muted)]">YuE2</p>
<div class="bench-grid">
<!-- LEFT inspector -->
<aside class="bench-panel">
<div class="bench-scroll inspector">
<div class="classic-task-model space-y-2">
<label>
<span class="field-label">Task</span>
<select :value="form.mode" class="field-control capitalize" @change="onTaskSelect">
<option v-for="task in tasks" :key="task" :value="task" class="capitalize">{{ task }}</option>
</select>
</label>
<label v-if="form.mode!=='music'">
<span class="field-label">Model</span>
<select v-model="form.engine" class="field-control">
<option v-for="opt in engineOptions" :key="opt.value" :value="opt.value" :disabled="opt.disabled">{{ opt.label }}{{ opt.hint ? ' — '+opt.hint : '' }}</option>
</select>
</label>
<p v-else class="text-[var(--type-sm)] text-[var(--muted)]">Model · YuE2</p>
</div>
<template v-if="form.mode==='music'">
<label><span class="field-label">Genre / style tags</span><textarea v-model="music.tags" rows="3" class="field-area" :placeholder="tagsPlaceholder" /></label>
<label><span class="field-label">Lyrics</span><textarea v-model="music.lyrics" rows="8" class="field-area" :placeholder="lyricsPlaceholder" /></label>
<label><span class="field-label">Target length (sec)</span><input v-model.number="music.duration" type="number" min="15" max="150" class="field-control"></label>
<label><span class="field-label">Seed</span><input v-model="music.seed" placeholder="random" class="field-control"></label>
<label><span class="field-label">Track name</span><input v-model="music.name" class="field-control" placeholder="Optional"></label>
</template>
<template v-else>
<section v-if="photosPlanState.show" class="photos-section" aria-label="Photos">
<div class="photos-head">
<span class="field-label">Photos</span>
<button
v-if="!photosPlanState.handoffReadonly"
type="button"
class="photos-inputs-toggle"
@click="hideInputs=!hideInputs"
>{{ hideInputs ? 'Show inputs' : 'Hide inputs' }}</button>
</div>
<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
v-for="(card, index) in form.photos"
:key="'photo-'+index+'-'+card.stillId"
:asset="stillById(card.stillId)"
:hidden="hideInputs"
:role="card.role"
:role-options="photoRoleOptions"
@pick="picker='photo:'+index"
@remove="removePhoto(index)"
@mention="mentionPhoto(index)"
@update:role="setPhotoRole(index, $event)"
/>
<button
v-if="form.photos.length < photosPlanState.max"
type="button"
class="photos-add"
@click="addPhoto"
>+ Add photo</button>
<p v-if="photoRoleWarning" class="text-[11px] text-[var(--amber)]">{{ photoRoleWarning }}</p>
</template>
</section>
<div v-if="showIdentityLocks" class="identity-slots flex flex-col gap-2 text-[var(--type-sm)] text-[var(--muted)]" aria-label="Identity locks">
<label class="inline-flex items-center gap-2"><input v-model="form.lockFace" type="checkbox" class="accent-[var(--amber)]">Lock face</label>
<label class="inline-flex items-center gap-2"><input v-model="form.lockOutfit" type="checkbox" class="accent-[var(--amber)]">Lock outfit</label>
</div>
<label>
<span class="field-label">Prompt</span>
<textarea
ref="promptArea"
v-model="form.promptSections.action"
class="field-area"
placeholder="Describe the image or the shot. Blank line between prompts for a batch."
@select="capturePromptCaret"
@click="capturePromptCaret"
@keyup="capturePromptCaret"
/>
<p v-if="imagePromptCount > 1" class="mt-1 text-[var(--type-sm)] text-[var(--muted)]">{{ imagePromptCount }} prompts · queued in order</p>
</label>
<label v-if="form.engine==='qwen21' && (form.mode==='generate' || form.mode==='edit')" class="inline-flex items-center gap-2 text-[var(--type-sm)] text-[var(--muted)]">
<input v-model="form.enhancePrompt" type="checkbox" class="accent-[var(--amber)]">Enhance prompt
</label>
<div v-if="form.engine==='qwen21' && (form.mode==='generate' || form.mode==='edit')" class="flex flex-col gap-1">
<label class="inline-flex items-center gap-2 text-[var(--type-sm)] text-[var(--muted)]">
<input v-model="form.turbo" type="checkbox" class="accent-[var(--amber)]">Turbo
</label>
<p v-if="form.turbo" class="text-[11px] text-[var(--muted)]">6-step Viggle distill. CFG stays 1. Enhance prompt recommended.</p>
</div>
<div>
<span class="field-label">Negative prompt</span>
<div class="neg-row">
<span v-for="chip in negativeChips" :key="chip" class="neg-chip">
{{ chip }}
<button type="button" :aria-label="'Remove '+chip" @click="removeNegative(chip)">×</button>
</span>
</div>
<div class="neg-add-row">
<input
v-model="negativeDraft"
type="text"
class="field-control"
placeholder="Add negative chip"
@keydown.enter.prevent="addNegative"
>
<button type="button" class="text-[var(--type-sm)] text-[var(--amber)]" @click="addNegative">Add</button>
</div>
</div>
<Studio2StyleChips v-if="!video && form.mode!=='music'" v-model="form.imageStyles" class="mt-1" />
<Studio2PromptControls v-if="video" v-model="form.promptSections" class="mt-2" />
<div v-if="['flux','krea','qwen21'].includes(form.engine)">
<span class="field-label">LoRA</span>
<LoraSelect
v-model="form.settings.loraStack"
compact
:names="pickerLoras"
:use="currentLoraUse"
:clip-strength="form.engine!=='qwen21' && !video"
filename-subtitle
:aliases="{...loraNames.aliases,...library.loraAliases}"
:triggers="{...loraNames.triggers,...library.loraTriggers}"
missing-label="None"
@insert-trigger="form.promptSections.action += ' '+$event"
/>
</div>
<div>
<span class="field-label">Seed</span>
<div class="seed-row">
<input
:value="form.settings.seed ?? ''"
type="number"
min="0"
max="2147483647"
class="field-control"
@input="form.settings.seed=Number(($event.target as HTMLInputElement).value);form.settings.seedMode='locked'"
>
<button type="button" class="dice-btn" title="Random seed" aria-label="Random seed" @click="form.settings.seedMode='random';form.settings.seed=randomSeed()">🎲</button>
</div>
</div>
<label>
<span class="field-label">Aspect ratio</span>
<select v-model="form.settings.aspect" class="field-control">
<option v-for="opt in aspectOptions" :key="opt.id" :value="opt.id">{{ opt.label }}</option>
</select>
</label>
<button type="button" class="text-left text-[var(--type-sm)] text-[var(--amber)]" @click="settingsOpen=true">More settings (steps / CFG)…</button>
<p v-if="uploading || uploadMessage" role="status" class="text-[var(--type-sm)] text-[var(--amber)]">{{ uploading?'Uploading…':uploadMessage }}</p>
<p v-if="error" role="alert" class="text-[var(--type-sm)] text-red-300">{{ error }}</p>
</template>
</div>
</aside>
<!-- CENTER viewer -->
<section class="bench-panel">
<div v-if="form.mode!=='music'" class="viewer-lock-bar" aria-label="Locks and hide">
<template v-if="form.engine!=='qwen21'">
<label class="inline-flex items-center gap-1.5"><input v-model="form.lockFace" type="checkbox" class="accent-[var(--amber)]">Lock face</label>
<label class="inline-flex items-center gap-1.5"><input v-model="form.lockOutfit" type="checkbox" class="accent-[var(--amber)]">Lock outfit</label>
</template>
<label v-if="xaigen" class="inline-flex items-center gap-1.5"><input v-model="classicHideInput" type="checkbox" class="accent-[var(--amber)]">Hide input</label>
<label v-if="xaigen" class="inline-flex items-center gap-1.5"><input v-model="classicHideOutput" type="checkbox" class="accent-[var(--amber)]">Hide output</label>
</div>
<div class="center-stack">
<div class="stage-chrome" aria-label="Stage controls">
<button
type="button"
class="stage-library"
:aria-expanded="libraryOpen ? 'true' : 'false'"
@click="libraryOpen=true"
>Library<span v-if="familyStillCount" class="stage-library-count">{{ familyStillCount }}</span></button>
<div
v-if="selected && selected.kind!=='track'"
class="stage-actions"
aria-label="Selected still actions"
>
<button type="button" @click="applyUseImage()">Use image</button>
<button type="button" @click="applyUsePrompt()">Use prompt</button>
<button
v-if="selected.kind==='still'"
type="button"
:disabled="submitting"
@click="describeSelected"
>Describe</button>
<label v-if="selected.kind==='still'" class="inline-flex items-center gap-1 text-[var(--type-sm)] text-[var(--muted)]">
<span class="sr-only">Caption style</span>
<select v-model="captionStyle" class="field-control !w-auto !py-1 !text-[var(--type-sm)]" aria-label="Caption style">
<option v-for="style in captionStyles" :key="style" :value="style">{{ style }}</option>
</select>
</label>
<div class="stage-split">
<button
type="button"
class="stage-split-trigger"
:aria-expanded="stageMenu==='copy' ? 'true' : 'false'"
@click="stageMenu=stageMenu==='copy'?'':'copy'"
>Copy</button>
<div v-if="stageMenu==='copy'" class="stage-split-menu" role="menu">
<button type="button" role="menuitem" @click="runStageMenu(copySelectedPrompt)">Copy prompt</button>
<button type="button" role="menuitem" @click="runStageMenu(copySelectedRawPrompt)">Copy raw prompt</button>
</div>
</div>
<div class="stage-split">
<button
type="button"
class="stage-split-trigger"
:aria-expanded="stageMenu==='load' ? 'true' : 'false'"
@click="stageMenu=stageMenu==='load'?'':'load'"
>Load</button>
<div v-if="stageMenu==='load'" class="stage-split-menu" role="menu">
<button type="button" role="menuitem" @click="runStageMenu(loadInputUsed)">Load input</button>
<button type="button" role="menuitem" @click="runStageMenu(loadInputRaw)">Load input raw</button>
</div>
</div>
<button type="button" class="stage-action-danger" @click="deleteOutput">Delete</button>
</div>
<button
v-if="selected"
type="button"
class="eye-viewer"
:aria-pressed="eyeOpen"
:aria-label="eyeOpen ? 'Hide output' : 'Show output'"
@click="toggleReveal()"
>
<img :src="eyeOpen ? '/icons/display/eye.svg' : '/icons/display/eye-off.svg'" alt="" width="20" height="20">
</button>
</div>
<div class="stage-media-row">
<button
v-if="family.length > 1"
type="button"
class="stage-nav stage-nav-prev"
aria-label="Previous"
@click="stepFamily(-1)"
>‹</button>
<div class="viewer-frame">
<p
v-show="selected && isHidden(selected)"
class="placeholder-still viewer-veil-label"
>{{ selected?.kind==='track' ? 'Track loaded · preview hidden' : 'Output hidden' }}</p>
<template v-if="selected?.kind==='track'">
<div v-show="!isHidden(selected)" class="w-full max-w-xl px-6 py-4">
<AudioPlayer :key="selected.id" :src="`/api/library/tracks/${selected.id}/audio`" :download-name="`${selected.name || 'track'}.${selected.ext || 'wav'}`" :label="selected.name || 'Track'" />
</div>
</template>
<template v-else-if="selected?.kind==='clip'">
<video
v-show="!isHidden(selected)"
ref="viewerVideo"
:key="selected.id"
:src="`/api/library/clips/${selected.id}/video`"
:muted="viewerMuted"
@timeupdate="onVideoTime"
@loadedmetadata="onVideoTime"
/>
</template>
<template v-else-if="selected">
<img
v-show="!isHidden(selected)"
:key="selected.id+'-'+stagePreviewNonce"
class="viewer-still"
:src="stagePreviewSrc"
alt="Selected still"
decoding="async"
@error="onStagePreviewError"
>
</template>
<template v-else>
<p class="placeholder-still">Open Library to pick a still or clip</p>
</template>
</div>
<button
v-if="family.length > 1"
type="button"
class="stage-nav stage-nav-next"
aria-label="Next"
@click="stepFamily(1)"
>›</button>
</div>
<div v-if="selected?.kind==='clip'" class="transport">
<span>{{ transportLabel }}</span>
<div class="ops">
<button type="button" :aria-pressed="viewerMuted" :aria-label="viewerMuted ? 'Unmute' : 'Mute'" @click="viewerMuted=!viewerMuted">{{ viewerMuted ? '🔇' : '🔊' }}</button>
<button type="button" aria-label="Fullscreen" @click="toggleFullscreen">⛶</button>
</div>
</div>
</div>
<Studio2LibraryModal
:open="libraryOpen"
:assets="family"
:selected="selected"
:folder-id="form.folderId"
:preview-hidden="!eyeOpen"
@close="libraryOpen=false"
@select="selectOutput"
@use-image="onLibraryUseImage"
@use-prompt="onLibraryUsePrompt"
@copy-prompt="onLibraryCopyPrompt"
@copy-raw-prompt="onLibraryCopyRawPrompt"
@load-input="onLibraryLoadInput"
@load-input-raw="onLibraryLoadInputRaw"
@delete="onLibraryDelete"
@toggle-preview="toggleReveal()"
/>
</section>
<!-- RIGHT queue -->
<aside class="bench-panel queue-col">
<div class="queue-head">
Queue
<span class="queue-badge">{{ queueCount }}</span>
</div>
<div class="queue-list">
<div
v-for="card in queueCards"
:key="card.id"
class="job-card"
:data-active="focusedJobId===card.id ? 'true' : undefined"
@click="focusQueueCard(card)"
>
<div class="job-id">{{ card.code }}</div>
<div class="job-title">{{ card.title }}</div>
<div class="job-meta">{{ card.meta }}</div>
<div class="job-status" :data-state="card.state">
<span class="left"><span class="dot" aria-hidden="true" />{{ card.statusLabel }}</span>
<button v-if="card.cancelable" type="button" class="kebab" aria-label="Cancel job" @click.stop="cancel(card.job)">⋮</button>
<button v-else-if="card.dismissable" type="button" class="kebab" aria-label="Dismiss job" @click.stop="dismissFinishedCard(card)">×</button>
</div>
<div v-if="card.progress" class="job-progress" :data-indeterminate="card.progress.indeterminate ? 'true' : undefined">
<progress
class="job-progress-bar"
max="100"
:value="card.progress.indeterminate ? undefined : card.progress.percent"
/>
<span v-if="card.elapsed != null" class="job-elapsed">{{ card.elapsed }}</span>
</div>
<p v-if="card.resultText" class="mt-2 whitespace-pre-wrap text-[var(--type-sm)]">{{ card.resultText }}</p>
<div v-if="card.resultText" class="mt-2 flex flex-wrap gap-2">
<button type="button" class="text-[var(--type-sm)] text-[var(--amber)]" @click.stop="copyCaption(card.resultText)">Copy</button>
<button type="button" class="text-[var(--type-sm)] text-[var(--amber)]" @click.stop="useCaptionAsPrompt(card.resultText)">Use as prompt</button>
</div>
</div>
<p v-if="!queueCards.length" class="px-1 text-[var(--type-sm)] text-[var(--muted)]">No active jobs</p>
</div>
<div class="queue-foot">
<button type="button" class="generate-cta" :disabled="submitting" @click="queue">
<span aria-hidden="true">▶</span>
{{ submitting ? 'Queuing…' : (imagePromptCount > 1 ? `Generate · ${imagePromptCount} prompts` : 'Generate') }}
</button>
<p v-if="error" class="queue-foot-error">{{ error }}</p>
<p class="tiny">
Job queue
<button v-if="canClearFinished" type="button" @click="clearFinished"> · Clear finished</button>
</p>
</div>
</aside>
</div>
</template>
<div v-if="settingsOpen || detailsOpen" class="fixed inset-0 z-50 bg-black/70" @click.self="settingsOpen=false;detailsOpen=false">
<aside class="absolute inset-y-0 right-0 w-full max-w-md overflow-auto border-l border-[var(--line)] bg-[var(--bg)] p-6">
<button type="button" class="float-right" @click="settingsOpen=false;detailsOpen=false">Close</button>
<template v-if="settingsOpen">
<h2 class="mb-6 text-xl font-semibold">More settings</h2>
<p class="mb-3 text-[var(--type-sm)] text-[var(--muted)]">{{ resolvedSize.width }} × {{ resolvedSize.height }} · fit with padding</p>
<label v-for="key in ['steps','cfg',...(video?['duration','fps']:[])]" :key="key" class="mb-4 block capitalize">{{ key }}
<input v-model.number="form.settings[key]" type="number" class="field-control mt-1">
</label>
<label v-if="video" class="mb-4 flex items-center gap-2"><input v-model="form.settings.turbo" type="checkbox" class="accent-[var(--amber)]"> Turbo</label>
<details v-if="video || form.mode==='edit'" class="mt-4">
<summary>{{ video?'Queue more shots':'Queue extra edit passes' }}</summary>
<textarea v-model="batchText" rows="5" class="field-area mt-2" placeholder="First additional prompt&#10;&#10;Next additional prompt" />
</details>
<button type="button" class="mt-4 text-[var(--type-sm)] text-[var(--amber)]" @click="loadLoras(true)">Refresh LoRAs</button>
</template>
<template v-else>
<h2 class="mb-6 text-xl font-semibold">Output details</h2>
<Studio2Details :metadata="selected?.studio2" />
</template>
</aside>
</div>
<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 a photo" 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 a photo</h2><button type="button" @click="closePicker">Cancel</button></div>
<div class="mb-4 flex flex-wrap items-center gap-3">
<label class="slot-upload">Upload from disk
<input type="file" accept="image/*" class="sr-only" :disabled="uploading" @change="uploadPickerFile">
</label>
<button
v-if="xaigen && pickerAssets.length"
type="button"
class="text-[var(--type-sm)]"
:class="pickerRevealAll ? 'text-[var(--amber)]' : 'text-[var(--muted)]'"
:title="pickerRevealAll ? 'Hide all thumbnails in this picker' : 'Reveal all thumbnails in this picker'"
@click="pickerRevealAll=!pickerRevealAll"
>{{ pickerRevealAll ? 'Hide all' : 'Reveal all' }}</button>
</div>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4 lg:grid-cols-5">
<button v-for="a in pickerAssets" :key="a.id" type="button" class="rounded-[var(--radius)] border border-[var(--line)] p-2 text-left" @click="pickAsset(a)">
<span v-if="pickerThumbHidden(a)" class="flex h-24 items-center text-xs text-[var(--muted)]">Still loaded · preview hidden</span>
<img v-else :src="a.kind==='clip'?'/api/library/clips/'+a.id+'/thumb':stillThumbUrl(a.id)" class="h-24 w-full object-contain" alt="">
<span class="mt-2 block truncate text-xs">{{ assetLabel(a) }}</span>
</button>
</div>
<p v-if="!pickerAssets.length" class="text-[var(--type-sm)] text-[var(--muted)]">No stills in this folder yet. Upload from disk above.</p>
</section>
</div>
</div>
</template>
<script setup lang="ts">
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,useAsSource,hydrateStudio2Job} from '~/shared/studio2/input.mjs'
import {folderAssets,newestOutput,assetLabel,reconcileSelection,previewHidden} from '~/shared/studio2/viewer.mjs'
import {queueJobs,jobStageLabel,jobProgressView,elapsedSeconds} from '~/shared/studio2/queue-view.mjs'
import {gpuStatus} from '~/shared/studio2/status.mjs'
import {CAPTION_STYLES} from '~/shared/caption.mjs'
import {applyUploadedStill} from '~/shared/studio2/upload.mjs'
import {defaultNegatives,parseBatch} from '~/shared/studio2/styles.mjs'
import {randomSeed} from '~/shared/studio2/seed.mjs'
import {
photosPlan,
PHOTO_ROLE_LABEL,
mentionPhrase,
insertMention,
duplicatePhotoRole,
applyPhotosToRequest,
photosFromRequest
} from '~/shared/studio2/photos.mjs'
import {DEFAULT_MUSIC_LYRICS,DEFAULT_MUSIC_TAGS} from '~/utils/music'
import {isXaigenStudio} from '~/utils/videoModels'
import {LOAD_GENERATION_PAYLOAD_KEY} from '~/utils/queuedJob'
const NEG_STORAGE_KEY='studio2-negative-chips'
const ENHANCE_STORAGE_KEY='studio2-qwen21-enhance-prompt'
const TURBO_STORAGE_KEY='studio2-qwen21-turbo'
const BENCH_DRAFT_KEY='aigen:bench-draft'
let draftTimer:ReturnType<typeof setTimeout>|null=null
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 */}
}
function readStoredEnhancePrompt(){
if(!import.meta.client)return false
try{return localStorage.getItem(ENHANCE_STORAGE_KEY)==='1'}catch{return false}
}
function writeStoredEnhancePrompt(on:boolean){
if(!import.meta.client)return
try{localStorage.setItem(ENHANCE_STORAGE_KEY,on?'1':'0')}catch{/* ignore */}
}
function readStoredTurbo(){
if(!import.meta.client)return false
try{return localStorage.getItem(TURBO_STORAGE_KEY)==='1'}catch{return false}
}
function writeStoredTurbo(on:boolean){
if(!import.meta.client)return
try{localStorage.setItem(TURBO_STORAGE_KEY,on?'1':'0')}catch{/* ignore */}
}
function benchDraftPayload(){
return {
v:1,
mode:form.mode,
engine:form.engine,
folderId:form.folderId,
enhancePrompt:!!form.enhancePrompt,
turbo:!!form.turbo,
lockFace:!!form.lockFace,
lockOutfit:!!form.lockOutfit,
promptSections:structuredClone(form.promptSections||{}),
photos:(form.photos||[]).filter((p:any)=>p?.stillId).map((p:any)=>({stillId:p.stillId,role:p.role})),
imageStyles:structuredClone(form.imageStyles||{positive:[],negative:[]}),
settings:{
aspect:form.settings?.aspect||'auto',
seed:form.settings?.seed ?? null,
seedMode:form.settings?.seedMode||'random',
steps:form.settings?.steps,
cfg:form.settings?.cfg,
duration:form.settings?.duration,
fps:form.settings?.fps,
loraStack:structuredClone(form.settings?.loraStack||[])
},
eyeOpen:!!eyeOpen.value,
hideInputs:!!hideInputs.value,
music:structuredClone({tags:music.tags,lyrics:music.lyrics,duration:music.duration,seed:music.seed,name:music.name})
}
}
function saveBenchDraft(immediate=false){
if(!import.meta.client)return
const write=()=>{
try{localStorage.setItem(BENCH_DRAFT_KEY,JSON.stringify(benchDraftPayload()))}catch{/* ignore */}
}
if(draftTimer)clearTimeout(draftTimer)
if(immediate){write();return}
draftTimer=setTimeout(write,400)
}
function restoreBenchDraft(){
if(!import.meta.client)return
let raw=''
try{raw=localStorage.getItem(BENCH_DRAFT_KEY)||''}catch{return}
if(!raw)return
let draft:any
try{draft=JSON.parse(raw)}catch{return}
if(!draft||draft.v!==1)return
if(draft.mode)form.mode=draft.mode
if(draft.engine)form.engine=draft.engine
if(draft.folderId)form.folderId=draft.folderId
if(draft.enhancePrompt!=null)form.enhancePrompt=!!draft.enhancePrompt
if(draft.turbo!=null)form.turbo=!!draft.turbo
if(draft.lockFace!=null)form.lockFace=!!draft.lockFace
if(draft.lockOutfit!=null)form.lockOutfit=!!draft.lockOutfit
if(draft.promptSections)form.promptSections=sectionsFrom(draft.promptSections)
if(Array.isArray(draft.photos))form.photos=draft.photos.filter((p:any)=>p?.stillId).map((p:any)=>({stillId:String(p.stillId),role:String(p.role||'change')}))
if(draft.imageStyles)form.imageStyles={positive:draft.imageStyles.positive||[],negative:draft.imageStyles.negative||[]}
if(draft.settings){
Object.assign(form.settings,{
aspect:draft.settings.aspect||form.settings.aspect,
seed:draft.settings.seed ?? form.settings.seed,
seedMode:draft.settings.seedMode||form.settings.seedMode,
steps:draft.settings.steps ?? form.settings.steps,
cfg:draft.settings.cfg ?? form.settings.cfg,
duration:draft.settings.duration ?? form.settings.duration,
fps:draft.settings.fps ?? form.settings.fps,
loraStack:Array.isArray(draft.settings.loraStack)?draft.settings.loraStack:form.settings.loraStack
})
}
if(draft.eyeOpen!=null)eyeOpen.value=!!draft.eyeOpen
if(draft.hideInputs!=null)hideInputs.value=!!draft.hideInputs
if(draft.music){
music.tags=String(draft.music.tags||music.tags)
music.lyrics=String(draft.music.lyrics||music.lyrics)
if(draft.music.duration!=null)music.duration=Number(draft.music.duration)||music.duration
if(draft.music.seed!=null)music.seed=String(draft.music.seed)
if(draft.music.name!=null)music.name=String(draft.music.name)
}
syncPhotosBindings()
}
const xaigen=isXaigenStudio(useRuntimeConfig().public)
const instanceName=String(useRuntimeConfig().public.instanceName || 'AIGen')
const {displayMode,isClassic}=useDisplayMode()
const {isMobile}=useIsMobile()
const classicHideInput=ref(true)
const classicHideOutput=ref(true)
const picker=ref('')
const pickerRevealAll=ref(false)
const uploading=ref(false),uploadMessage=ref('')
const revealed=ref<Set<string>>(new Set())
const measured=reactive<Record<string,{width:number;height:number}>>({}),seenOutputs=new Set<string>()
const loraNames=ref<any>({image:[],video:[]}),now=ref(Date.now()),healthState=ref<any>(null),upscales=ref<any[]>([])
const library=ref<any>({folders:[],stills:[],clips:[],tracks:[]}),jobs=ref<any[]>([]),selected=ref<any>(null)
const studioQueue=ref<any[]>([])
const liveMusic=ref<Record<string,any>>({})
const negativeDraft=ref('')
const libraryOpen=ref(false)
const stageMenu=ref<'copy'|'load'|''>('')
const eyeOpen=ref(!xaigen)
const hideInputs=ref(!!xaigen)
const form=reactive<any>({mode:'generate',engine:'flux',enhancePrompt:readStoredEnhancePrompt(),turbo:readStoredTurbo(),imageStyles:{positive:[],negative:readStoredNegatives()},lockFace:true,lockOutfit:true,hideInput:xaigen,folderId:'',identityStillId:'',imageAId:'',imageBId:'',maskId:'',startClipId:'',endStillId:'',guides:[],photos:[],promptSections:sectionsFrom({}),settings:{aspect:'auto',fps:24,steps:24,cfg:4,seed:null,seedMode:'random',duration:5,turbo:true,loraStack:[]}})
const promptArea=ref<HTMLTextAreaElement|null>(null)
const promptCaret=ref({start:0,end:0})
const music=reactive({tags:DEFAULT_MUSIC_TAGS,lyrics:DEFAULT_MUSIC_LYRICS,duration:60,seed:'',name:''})
const tagsPlaceholder=DEFAULT_MUSIC_TAGS,lyricsPlaceholder=DEFAULT_MUSIC_LYRICS
const captionStyles=CAPTION_STYLES
const captionStyle=ref('descriptive')
const liveCaption=ref<Record<string,any>>({})
let loraMemory=createLoraMemory(xaigen)
const currentLoraUse=computed(()=>loraUse(form))
const pickerLoras=computed(()=>!xaigen && currentLoraUse.value==='ltx'?[]:filterLorasForUse(filterLorasForStudio([...loraNames.value.image,...loraNames.value.video],xaigen),currentLoraUse.value))
watch(()=>[currentLoraUse.value,form.settings.loraStack],()=>loraMemory.sync(form),{deep:true,flush:'sync'})
const route=useRoute()
const tasks=['generate','edit','compose','refine','video','extend','music']
const settingsOpen=ref(false),detailsOpen=ref(false),submitting=ref(false),error=ref(''),batchText=ref('')
const viewerVideo=ref<HTMLVideoElement|null>(null)
const viewerMuted=ref(false)
const videoTime=ref(0)
const videoDuration=ref(0)
const focusedJobId=ref('')
const video=computed(()=>['video','extend'].includes(form.mode))
const kreaSticky=computed(()=>['edit','compose','iterate'].includes(form.mode))
const qwenAllowed=computed(()=>['generate','edit'].includes(form.mode))
const engineOptions=computed(()=>{
if(video.value){
return [
{value:'minimax',label:'MiniMax H3',disabled:false,hint:''},
{value:'ltx',label:'LTX',disabled:!xaigen,hint:xaigen?'':'xAIGen only'}
]
}
return [
{value:'flux',label:'Klein',disabled:false,hint:''},
{value:'krea',label:'Krea 2',disabled:kreaSticky.value,hint:kreaSticky.value?'sticky hero = Klein':''},
{value:'qwen21',label:'Qwen 2.1',disabled:!qwenAllowed.value,hint:qwenAllowed.value?'':'Generate and Edit only'}
]
})
watch(engineOptions,opts=>{
const current=opts.find(o=>o.value===form.engine)
if(!current || current.disabled)form.engine=opts.find(o=>!o.disabled)?.value || form.engine
})
watch(()=>form.engine,(engine,prev)=>{
if(engine==='qwen21' && prev!=='qwen21'){
form.settings.steps=form.turbo?6:25
form.settings.cfg=1
form.lockFace=false
form.lockOutfit=false
}
})
watch(()=>form.enhancePrompt,on=>writeStoredEnhancePrompt(!!on))
watch(()=>form.turbo,on=>{
writeStoredTurbo(!!on)
if(form.engine!=='qwen21')return
form.settings.steps=on?6:25
form.settings.cfg=1
})
watch(()=>[form.mode,form.engine],([mode,engine])=>{
if(engine==='qwen21' && mode==='edit'){
form.settings.steps=form.turbo?6:25
form.settings.cfg=1
form.lockFace=false
form.lockOutfit=false
}
const plan=photosPlan({mode,engine})
if(!plan.show){
if(engine==='qwen21')form.identityStillId=''
// Keep photos + prompt; section may hide for this mode.
saveBenchDraft(true)
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()
saveBenchDraft(true)
})
watch(()=>form.folderId,(id,prev)=>{
selected.value=null
// Wipe Photos only when leaving an existing folder — first restore from '' must keep the draft
if(prev){
form.imageAId=''
form.imageBId=''
form.photos=[]
form.startClipId=''
}
loadRevealed(id)
if(import.meta.client && form.engine!=='qwen21')form.identityStillId=localStorage.getItem(`studio2-hero:${id}`)||''
void loadLibraryOnce().catch(()=>null)
},{flush:'sync'})
watch(()=>form.identityStillId,id=>{if(import.meta.client&&form.folderId&&form.engine!=='qwen21')localStorage.setItem(`studio2-hero:${form.folderId}`,id)})
const stills=computed(()=>library.value.stills.filter((s:any)=>s.folderId===form.folderId))
const clips=computed(()=>library.value.clips.filter((s:any)=>s.folderId===form.folderId))
const stillUrl=(id:string)=>`/api/library/stills/${id}`
const stillThumbUrl=(id:string)=>`/api/library/stills/${id}/thumb`
const stillPreviewUrl=(id:string)=>`/api/library/stills/${id}/preview`
/** Stage uses /preview (1600px). Tiles keep /thumb. Never auto-upgrade to the archive master. */
const stagePreviewNonce=ref(0)
const stagePreviewRetries=ref(0)
const stagePreviewSrc=computed(()=>{
const id=selected.value?.id
if(!id || selected.value?.kind==='clip' || selected.value?.kind==='track') return ''
const base=stillPreviewUrl(id)
return stagePreviewNonce.value?`${base}?t=${stagePreviewNonce.value}`:base
})
function onStagePreviewError(){
if(!selected.value?.id || stagePreviewRetries.value>=8) return
stagePreviewRetries.value+=1
window.setTimeout(()=>{
if(!selected.value?.id) return
stagePreviewNonce.value=Date.now()
}, 700)
}
watch(()=>selected.value?.id,()=>{stagePreviewNonce.value=0;stagePreviewRetries.value=0})
const folderJobs=computed(()=>jobs.value.filter(j=>j.request.folderId===form.folderId))
const benchJobs=computed(()=>queueJobs(folderJobs.value))
/** Desktop image generate: blank-line paragraphs in the prompt box (same parseBatch as mobile batchText). */
const imagePromptParts=computed(()=>{
if(video.value || form.mode!=='generate') return [] as string[]
return parseBatch(form.promptSections?.action)
})
const imagePromptCount=computed(()=>imagePromptParts.value.length)
const musicJobs=computed(()=>studioQueue.value
.filter((j:any)=>j.kind==='music' && j.folderId===form.folderId && !['complete','done'].includes(j.status))
.map((j:any)=>({...j,live:j.liveJobId?liveMusic.value[j.liveJobId]:null})))
const captionJobs=computed(()=>studioQueue.value
.filter((j:any)=>j.kind==='caption' && j.folderId===form.folderId)
.map((j:any)=>({...j,live:j.liveJobId?liveCaption.value[j.liveJobId]:null})))
const gpu=computed(()=>gpuStatus(jobs.value,healthState.value,upscales.value,musicJobs.value,captionJobs.value))
const gpuChipLabel=computed(()=>{
if(!healthState.value) return 'GPU ?'
const raw=String(healthState.value?.comfy?.gpu || healthState.value?.gpuName || healthState.value?.comfy?.device || '').trim()
const short=raw.match(/(\d{3,4})\b/)?.[1]
if(gpu.value.tone==='ready') return short ? `${short} Ready` : 'Ready'
return gpu.value.label || 'GPU ?'
})
const sourceSlot=computed(()=>{const clip=clips.value.find((a:any)=>a.id===form.startClipId);return form.mode==='extend'?(clip?{...clip,kind:'clip'}:null):inputStill.value})
const pickerAssets=computed(()=>picker.value==='startClipId'?clips.value.map((a:any)=>({...a,kind:'clip'})):stills.value.map((a:any)=>({...a,kind:'still'})))
const inputStill=computed(()=>stills.value.find((s:any)=>s.id===form.imageAId))
const family=computed(()=>folderAssets(library.value,jobs.value,form.folderId))
const familyStillCount=computed(()=>{
const folder=(library.value.folders||[]).find((f:any)=>f.id===form.folderId)
const fromFolder=Number(folder?.imageCount||0)
if(fromFolder>0) return fromFolder
return family.value.filter((a:any)=>a.kind==='still').length
})
const sourceAsset=computed(()=>form.startClipId?clips.value.find((c:any)=>c.id===form.startClipId):stills.value.find((s:any)=>s.id===(form.imageAId||form.identityStillId||form.photos?.[0]?.stillId)))
const sourceSize=computed(()=>measured[sourceAsset.value?.id] || sourceAsset.value)
const resolvedSize=computed(()=>resolveSize(form.settings.aspect,sourceSize.value,undefined,form.engine))
const negativeChips=computed(()=>form.imageStyles.negative || [])
const photosPlanState=computed(()=>photosPlan({mode:form.mode,engine:form.engine}))
const photoRoleOptions=computed(()=>(photosPlanState.value.roles||[]).map((id:string)=>({id,label:PHOTO_ROLE_LABEL[id]||id})))
const photoFilledCount=computed(()=>(form.photos||[]).filter((p:any)=>p?.stillId).length)
const photoRoleWarning=computed(()=>{
const dup=duplicatePhotoRole(form.photos)
return dup?`Two photos are set to the same job (${dup}). Change one.`:''
})
const showIdentityLocks=computed(()=>form.engine!=='qwen21' && form.engine!=='krea' && !['music'].includes(form.mode))
const queueCards=computed(()=>{
const cards:any[]=[]
const now=Date.now()
for(const job of benchJobs.value){
const action=job.request?.promptSections?.action || 'Job'
const eng=job.request?.engine==='ltx'?'LTX':job.request?.engine==='krea'?'Krea 2':job.request?.engine==='qwen21'?'Qwen 2.1':job.request?.engine==='minimax'?'MiniMax H3':'Klein'
const mode=job.request?.mode || 'generate'
const modeLabel=mode.charAt(0).toUpperCase()+mode.slice(1)
const aspect=job.request?.settings?.aspect || 'auto'
const state=job.state==='waiting'?'queued':job.state
const secs=elapsedSeconds(job,now)
const progress=jobProgressView(job)
cards.push({
id:job.id,
job,
code:'#'+String(job.id).replace(/\D/g,'').slice(-3).padStart(3,'0'),
title:action.slice(0,72),
meta:`${modeLabel} · ${eng} · ${aspect}`,
state,
statusLabel:jobStageLabel(state),
progress,
elapsed:secs>0?`${Math.floor(secs/60)}m ${secs%60}s`:'',
cancelable:!['complete','failed','cancelled'].includes(job.state)
})
}
for(const job of musicJobs.value){
cards.push({
id:job.id,
job,
code:'#'+String(job.id).replace(/\D/g,'').slice(-3).padStart(3,'0'),
title:job.name || 'YuE2',
meta:`Music · YuE2 · ${job.duration || '—'}s`,
state:job.live?.status || job.status,
statusLabel:job.live?.status || job.status,
cancelable:!['complete','done','error','cancelled'].includes(job.status)
})
}
for(const job of captionJobs.value){
const live=job.live
const text=live?.resultText || job.resultText || ''
const state=live?.status || job.status
const finished=['complete','done','error','cancelled','failed'].includes(String(state))
cards.push({
id:job.id,
job,
code:'#'+String(job.id).replace(/\D/g,'').slice(-3).padStart(3,'0'),
title:job.name || `Describe · ${job.captionStyle || job.payload?.captionStyle || 'caption'}`,
meta:`Caption · ${job.captionStyle || job.payload?.captionStyle || 'descriptive'}`,
state,
statusLabel:live?.message || state,
resultText:text,
cancelable:!finished,
dismissable:finished
})
}
for(const job of upscales.value.filter((j:any)=>j.folderId===form.folderId && ['queued','running'].includes(j.status))){
cards.push({
id:job.id,
job,
code:'#'+String(job.id).replace(/\D/g,'').slice(-3).padStart(3,'0'),
title:job.name || 'Video upscale',
meta:'Upscale',
state:job.status,
statusLabel:job.status==='running'?'Running':'Queued',
cancelable:false
})
}
return cards
})
const queueCount=computed(()=>queueCards.value.length)
const canClearFinished=computed(()=>folderJobs.value.some(j=>['complete','failed','cancelled'].includes(j.state)) || musicJobs.value.some(j=>['complete','done','error','cancelled'].includes(j.status)) || captionJobs.value.some(j=>['complete','done','error','cancelled'].includes(j.status) || ['complete','error','cancelled'].includes(j.live?.status)))
const transportLabel=computed(()=>{
if(selected.value?.kind==='clip') return `${formatTime(videoTime.value)} / ${formatTime(videoDuration.value)}`
if(selected.value?.kind==='track') return 'Audio'
if(selected.value) return 'Still'
return '—'
})
function formatTime(sec:number){
const s=Math.max(0,Number(sec)||0)
const m=Math.floor(s/60)
const r=s-m*60
const whole=Math.floor(r)
const frac=Math.floor((r-whole)*100)
return `${String(m).padStart(2,'0')}:${String(whole).padStart(2,'0')}.${String(frac).padStart(2,'0')}`
}
function onVideoTime(){
const el=viewerVideo.value
if(!el)return
videoTime.value=el.currentTime||0
videoDuration.value=el.duration||0
}
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)}
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 stillById(id:string){return id?stills.value.find((s:any)=>s.id===id)||null:null}
/** Photos array is source of truth — never resurrect cards from leftover imageAId. */
function syncPhotosBindings(){
const filled=(form.photos||[]).filter((p:any)=>p?.stillId)
const next=applyPhotosToRequest({
...form,
photos:filled,
imageAId:'',
imageBId:'',
identityStillId:'',
})
// Keep empty draft cards in the UI; only overwrite filled bindings.
const drafts=(form.photos||[]).filter((p:any)=>!p?.stillId)
form.photos=[...(next.photos||[]),...drafts]
form.imageAId=next.imageAId||''
form.imageBId=next.imageBId||''
form.identityStillId=form.engine==='qwen21'?'':(next.identityStillId||'')
}
function addPhoto(){
const plan=photosPlanState.value
if(!plan.show||plan.handoffReadonly)return
if(form.photos.length>=plan.max)return
form.photos.push({stillId:'',role:plan.defaultRole||'change'})
picker.value='photo:'+(form.photos.length-1)
saveBenchDraft(true)
}
function removePhoto(index:number){
if(!Number.isInteger(index)||index<0||index>=form.photos.length)return
form.photos.splice(index,1)
// Explicit empty photos array clears imageAId / imageBId / identityStillId.
syncPhotosBindings()
saveBenchDraft(true)
}
function setPhotoRole(index:number,role:string){
if(!form.photos[index])return
form.photos[index].role=role
syncPhotosBindings()
saveBenchDraft(true)
}
function setPhotoStill(index:number,stillId:string){
if(!Number.isInteger(index)||!form.photos[index])return
if(!stillId){
form.photos.splice(index,1)
}else{
form.photos[index].stillId=stillId
}
syncPhotosBindings()
saveBenchDraft(true)
}
function discardEmptyPhotoDraft(index:number){
if(!Number.isInteger(index)||!form.photos[index])return
if(!form.photos[index].stillId)form.photos.splice(index,1)
}
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 usedPromptOf(item:any){
return String(
item?.studio2?.prompt
|| item?.prompt
|| item?.studio2?.compiledPrompt
|| item?.studio2?.promptSections?.action
|| ''
).trim()
}
function rawPromptOf(item:any){
return String(item?.promptRaw || item?.studio2?.promptRaw || '').trim()
}
function sourceStillIdsOf(item:any){
const meta=item?.studio2 || {}
const ids:string[]=[]
const push=(id:any)=>{const v=String(id||'').trim();if(v && !ids.includes(v))ids.push(v)}
push(meta.sourceStillId)
push(meta.imageAId)
for(const card of meta.photos||[]) push(card?.stillId)
if(meta.imageBId) push(meta.imageBId)
return ids.filter(id=>stillById(id))
}
function resolveActionAsset(item?:any){
// Stage @click handlers pass a MouseEvent as the first arg — never treat events as library assets.
if(item && typeof item.id==='string' && item.id) return item
return selected.value
}
function useImageRole(){
const plan=photosPlanState.value
const filled=(form.photos||[]).filter((p:any)=>p?.stillId)
if((form.mode==='edit' || video.value) && !filled.length && plan.roles.includes('change')) return 'change'
if(plan.roles.includes('extra')) return 'extra'
if(plan.roles.includes('outfit')) return 'outfit'
return plan.defaultRole || 'change'
}
function applyUseImage(item?:any){
const asset=resolveActionAsset(item)
if(!asset?.id){
uploadMessage.value='Select a still first.'
return
}
if(asset.kind==='track' || asset.kind==='clip'){
uploadMessage.value='Use image needs a still.'
return
}
const stillId=String(asset.id)
if(!stillById(stillId)){
uploadMessage.value='Select a still first.'
return
}
const plan=photosPlanState.value
const role=useImageRole()
// Prefer filling an empty draft card left by + Add photo; never open the picker.
const emptyIdx=(form.photos||[]).findIndex((p:any)=>!p?.stillId)
if(emptyIdx>=0){
form.photos[emptyIdx]={stillId,role}
syncPhotosBindings()
uploadMessage.value=`Added ${assetLabel(asset)} to Photos.`
saveBenchDraft(true)
return
}
if(plan.show && !plan.handoffReadonly && (form.photos||[]).length>=plan.max){
const changeIdx=(form.photos||[]).findIndex((p:any)=>p?.role==='change')
if(changeIdx>=0){
form.photos[changeIdx]={stillId,role:'change'}
syncPhotosBindings()
uploadMessage.value=`Replaced Photos card with ${assetLabel(asset)}.`
saveBenchDraft(true)
return
}
uploadMessage.value='Photos full.'
return
}
form.photos.push({stillId,role})
syncPhotosBindings()
uploadMessage.value=`Added ${assetLabel(asset)} to Photos.`
saveBenchDraft(true)
}
async function copyCaption(text:string){
try{await navigator.clipboard.writeText(text)}catch{error.value='Could not copy caption.'}
}
function useCaptionAsPrompt(text:string){
const current=String(form.promptSections.action||'').trim()
form.promptSections.action=current?`${current}\n\n${text}`:text
}
async function describeSelected(){
const id=selected.value?.kind==='still'?selected.value.id:''
if(!id){error.value='Select a still to describe.';return}
submitting.value=true;error.value=''
try{
await $fetch('/api/generate/caption',{method:'POST',body:{folderId:form.folderId,stillId:id,sourcePath:id,captionStyle:captionStyle.value}})
await refreshJobs()
}catch(e:any){error.value=e.data?.statusMessage||e.message}
finally{submitting.value=false}
}
function applyUsePrompt(item?:any){
const asset=resolveActionAsset(item)
if(!asset){
uploadMessage.value='Select a still first.'
return
}
const text=usedPromptOf(asset)
form.promptSections.action=text
uploadMessage.value=text?'Prompt set from this item.':'No Used prompt on this item.'
saveBenchDraft(true)
}
async function copySelectedPrompt(item?:any){
const asset=resolveActionAsset(item)
const text=usedPromptOf(asset)
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.'}
}
async function copySelectedRawPrompt(item?:any){
const asset=resolveActionAsset(item)
const raw=rawPromptOf(asset)
const used=usedPromptOf(asset)
const text=raw || used
try{
if(import.meta.client && navigator.clipboard)await navigator.clipboard.writeText(String(text||''))
uploadMessage.value=raw?'Raw prompt copied.':(used?'Used prompt copied (no Typed on this item).':'No prompt on this item.')
}catch{error.value='Could not copy prompt.'}
}
function loadInputPhotos(ids:string[]){
if(!ids.length)return
const plan=photosPlanState.value
const roles=plan.roles?.length?plan.roles:['change']
form.photos=ids.slice(0,plan.max || ids.length).map((id,i)=>({
stillId:id,
role:i===0 && roles.includes('change')?'change':(roles.includes('extra')?'extra':roles[Math.min(i,roles.length-1)]||'change')
}))
syncPhotosBindings()
}
function loadInputUsed(item?:any){
const asset=resolveActionAsset(item)
if(!asset){
uploadMessage.value='Select a still first.'
return
}
const ids=sourceStillIdsOf(asset)
const text=usedPromptOf(asset)
if(ids.length) loadInputPhotos(ids)
if(text) form.promptSections.action=text
uploadMessage.value=ids.length
?(text?'Input photos and Used prompt loaded.':'Input photos loaded.')
:(text?'Used prompt loaded (no source still on this item).':'Nothing to load from this item.')
saveBenchDraft(true)
}
function loadInputRaw(item?:any){
const asset=resolveActionAsset(item)
if(!asset){
uploadMessage.value='Select a still first.'
return
}
const ids=sourceStillIdsOf(asset)
const raw=rawPromptOf(asset)
const used=usedPromptOf(asset)
if(ids.length) loadInputPhotos(ids)
form.promptSections.action=raw || used
uploadMessage.value=ids.length
?(raw?'Input photos and Typed prompt loaded.':(used?'Input photos loaded; Typed missing so Used was applied.':'Input photos loaded.'))
:(raw?'Typed prompt loaded (no source still).':(used?'Used prompt loaded (no Typed / source still).':'Nothing to load from this item.'))
saveBenchDraft(true)
}
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||[]
form.photos=photosFromRequest({...next,photos:saved.photos})
syncPhotosBindings()
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.'
saveBenchDraft(true)
}
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 refreshJobs()
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 refreshJobs()
if(!(library.value.stills||[]).length) await loadLibraryOnce()
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)
}
function focusQueueCard(card:any){
focusedJobId.value=card.id
if(card.job?.outputs?.length)focusJob(card.job)
}
function isHidden(asset:any){
if(!asset?.id)return false
if(isClassic.value){
const input=asset.role==='input'
return input?classicHideInput.value:classicHideOutput.value
}
// Session chrome: eye gates the stage preview across neighbors.
return !eyeOpen.value
}
function pickerThumbHidden(asset:any){
if(pickerRevealAll.value)return false
if(!asset?.id)return false
if(isClassic.value)return classicHideInput.value
if(xaigen) return previewHidden(asset,revealed.value,xaigen)
return false
}
function slotHidden(_id:string){
// Photos inspector uses hideInputs — never the output eye.
return hideInputs.value
}
function toggleReveal(_id?:string){
eyeOpen.value=!eyeOpen.value
}
async function deleteOutput(){
if(!selected.value)return
const label=assetLabel(selected.value)
if(import.meta.client && !window.confirm(`Delete ${label}?`))return
try{
const kind=selected.value.kind==='clip'?'clips/':selected.value.kind==='track'?'tracks/':'stills/'
const id=selected.value.id
await $fetch('/api/library/'+kind+id,{method:'DELETE'})
library.value={
...library.value,
stills:(library.value.stills||[]).filter((s:any)=>s.id!==id),
clips:(library.value.clips||[]).filter((c:any)=>c.id!==id),
tracks:(library.value.tracks||[]).filter((t:any)=>t.id!==id)
}
selected.value=null
await refreshJobs()
}catch(e:any){error.value=e.data?.statusMessage||e.message}
}
function stepFamily(delta:number){
const list=family.value
if(list.length < 2)return
const i=list.findIndex((a:any)=>a.id===selected.value?.id)
const from=i < 0 ? 0 : i
const next=list[(from + delta + list.length) % list.length]
if(next) selectOutput(next)
}
function runStageMenu(fn:()=>void|Promise<void>){
stageMenu.value=''
void fn()
}
function onLibraryUseImage(item:any){
if(item)selectOutput(item)
applyUseImage(item)
}
function onLibraryUsePrompt(item:any){
if(item)selectOutput(item)
applyUsePrompt(item)
}
function onLibraryCopyPrompt(item:any){
if(item)selectOutput(item)
void copySelectedPrompt(item)
}
function onLibraryCopyRawPrompt(item:any){
if(item)selectOutput(item)
void copySelectedRawPrompt(item)
}
function onLibraryLoadInput(item:any){
if(item)selectOutput(item)
loadInputUsed(item)
}
function onLibraryLoadInputRaw(item:any){
if(item)selectOutput(item)
loadInputRaw(item)
}
function onLibraryDelete(item:any){
if(item)selectOutput(item)
void deleteOutput()
}
function onStageHotkey(e:KeyboardEvent){
if(e.metaKey||e.ctrlKey||e.altKey)return
const t=e.target as HTMLElement|null
if(t && (t.tagName==='INPUT'||t.tagName==='TEXTAREA'||t.isContentEditable))return
if(libraryOpen.value||picker.value)return
if(e.key==='Escape' && stageMenu.value){
e.preventDefault()
stageMenu.value=''
return
}
if(e.key==='l' || e.key==='L'){
e.preventDefault()
libraryOpen.value=true
return
}
if(e.key==='ArrowLeft'){
e.preventDefault()
stepFamily(-1)
return
}
if(e.key==='ArrowRight'){
e.preventDefault()
stepFamily(1)
}
}
function onStagePointerDown(e:PointerEvent){
if(!stageMenu.value)return
const t=e.target as HTMLElement|null
if(t?.closest?.('.stage-split'))return
stageMenu.value=''
}
function closePicker(){
const key=picker.value
if(key.startsWith('photo:'))discardEmptyPhotoDraft(Number(key.slice(6)))
picker.value=''
pickerRevealAll.value=false
}
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.startsWith('photo:')){
const index=Number(key.slice(6))
if(asset?.id)setPhotoStill(index,asset.id)
else discardEmptyPhotoDraft(index)
}else if(key) form[key]=asset?.id||''
picker.value=''
pickerRevealAll.value=false
}
async function uploadPickerFile(event:Event){
const input=event.target as HTMLInputElement
const file=input.files?.[0]
if(!file)return
const key=picker.value
uploading.value=true;uploadMessage.value='';error.value=''
const body=new FormData();body.append('image',file);body.append('folderId',form.folderId);body.append('hideInput',String(xaigen))
try{
const response=await $fetch<any>('/api/studio-2/stills',{method:'POST',body})
library.value=response
measured[response.still.id]={width:response.still.width,height:response.still.height}
if(key.startsWith('photo:')){
setPhotoStill(Number(key.slice(6)),response.still.id)
picker.value=''
pickerRevealAll.value=false
}else{
Object.assign(form,applyUploadedStill(form,response,'source'))
}
uploadMessage.value=`Loaded ${response.still.name || file.name}.`
}catch(e:any){error.value=e.data?.statusMessage||e.message}
finally{uploading.value=false;input.value=''}
}
async function upload(event:Event,target:'source'|'hero'){
const input=event.target as HTMLInputElement,file=input.files?.[0];if(!file)return
uploading.value=true;uploadMessage.value='';error.value=''
const body=new FormData();body.append('image',file);body.append('folderId',form.folderId);body.append('hideInput',String(xaigen))
try{
const response=await $fetch<any>('/api/studio-2/stills',{method:'POST',body})
library.value=response
measured[response.still.id]={width:response.still.width,height:response.still.height}
const plan=photosPlanState.value
if(plan.show && !plan.handoffReadonly){
const role=target==='hero'
?(plan.roles.includes('person')?'person':plan.defaultRole)
:plan.defaultRole
if(form.photos.length>=plan.max)form.photos[form.photos.length-1]={stillId:response.still.id,role}
else form.photos.push({stillId:response.still.id,role:role||'change'})
syncPhotosBindings()
}else{
Object.assign(form,applyUploadedStill(form,response,target))
}
uploadMessage.value=`Loaded ${response.still.name || file.name}.`
}
catch(e:any){error.value=e.data?.statusMessage||e.message}
finally{uploading.value=false;input.value=''}
}
function loadRevealed(folderId:string){
if(!import.meta.client||!xaigen){revealed.value=new Set();return}
try{revealed.value=new Set(JSON.parse(localStorage.getItem(`studio2-revealed:${folderId}`)||'[]'))}
catch{revealed.value=new Set()}
}
watch(()=>sourceAsset.value?.id,id=>{
const asset=sourceAsset.value
if(!import.meta.client||!asset||measured[id])return
if(asset.width && asset.height){
measured[asset.id]={width:Number(asset.width),height:Number(asset.height)}
return
}
// Clips without stored size: probe video metadata only (never the still archive master)
if(asset.id===form.startClipId || asset.kind==='clip'){
const media=document.createElement('video')
media.preload='metadata'
media.addEventListener('loadedmetadata',()=>{
measured[asset.id]={width:media.videoWidth,height:media.videoHeight}
},{once:true})
media.src=`/api/library/clips/${asset.id}/video`
}
})
watch(()=>selected.value?.id,()=>{videoTime.value=0;videoDuration.value=0})
function chooseTask(task:string){
if(task==='music'){form.mode='music';return}
if(task==='extend'){
form.mode='extend'
form.engine=xaigen && form.engine==='ltx'?'ltx':'minimax'
const clip=selected.value?.kind==='clip'?selected.value:clips.value.find((c:any)=>c.id===form.startClipId)
if(clip?.id){
try{Object.assign(form,bindClip(form,clip,xaigen,loraMemory))}
catch(e:any){error.value=e?.message||'Could not copy clip settings. Previous clip is still bound.';form.startClipId=clip.id}
}
return
}
const was=video.value;form.mode=task;form.startClipId='';if(video.value!==was){form.engine=video.value?(xaigen && form.engine==='ltx'?'ltx':'minimax'):'flux';form.settings.steps=video.value?8:24;form.settings.cfg=video.value?1.5:4}
if(selected.value?.kind==='track' && task!=='music') selected.value=family.value.find((a:any)=>a.kind!=='track') || null
}
function onTaskSelect(event:Event){chooseTask((event.target as HTMLSelectElement).value)}
function chooseClip(id:string){if(!id){form.startClipId='';return}const clip=clips.value.find((c:any)=>c.id===id);if(clip){try{Object.assign(form,bindClip(form,clip,xaigen,loraMemory))}catch(e:any){error.value=e?.message||'Could not copy clip settings.';form.startClipId=clip.id;form.mode='extend';form.engine=xaigen && form.engine==='ltx'?'ltx':'minimax'}}}
function selectOutput(item:any){stageMenu.value='';selected.value=item;if(form.mode==='extend' && item?.kind==='clip')chooseClip(item.id)}
function restore(item:any){Object.assign(form,restoreAll(form,item,xaigen,loraMemory));form.guides=form.guides||[];batchText.value=''}
function focusJob(job:any){selectOutput(job.outputs?.at(-1)||null);focusedJobId.value=job.id}
async function loadLoras(fresh=false){try{loraNames.value=await $fetch('/api/studio-2/loras'+(fresh?'?fresh=1':''))}catch{error.value='Could not refresh LoRAs. The last loaded list is still available.'}}
async function clearFinished(){await $fetch('/api/studio-2/clear-finished',{method:'POST'});await refreshJobs()}
async function dismissFinishedCard(card:any){
const id=card?.job?.id || card?.id
if(!id) return
try{
await $fetch('/api/studio-2/clear-finished',{method:'POST',body:{id}})
await refreshJobs()
}catch(e:any){error.value=e.data?.statusMessage||e.message||'Could not dismiss job'}
}
async function refreshJobs(){
const [nextJobs,nextUpscales,queue]=await Promise.all([
$fetch<any[]>('/api/studio-2/jobs'),
$fetch<any[]>('/api/studio-2/upscales'),
$fetch<any>('/api/studio-queue?full=1').catch(()=>({jobs:[]}))
])
jobs.value=nextJobs;upscales.value=nextUpscales
studioQueue.value=Array.isArray(queue)?queue:(queue?.jobs||[])
const musicRows=studioQueue.value.filter((j:any)=>j.kind==='music' && j.liveJobId && !['complete','done'].includes(j.status))
const liveEntries=await Promise.all(musicRows.map(async(j:any)=>{
try{return [j.liveJobId,await $fetch<any>(`/api/generate/${j.liveJobId}`)] as const}
catch{return [j.liveJobId,null] as const}
}))
const nextLive:Record<string,any>={}
for(const [id,snap] of liveEntries){if(snap)nextLive[id]=snap}
liveMusic.value=nextLive
const captionRows=studioQueue.value.filter((j:any)=>j.kind==='caption' && j.liveJobId)
const captionEntries=await Promise.all(captionRows.map(async(j:any)=>{
try{return [j.liveJobId,await $fetch<any>(`/api/generate/${j.liveJobId}`)] as const}
catch{return [j.liveJobId,null] as const}
}))
const nextCaption:Record<string,any>={}
for(const [id,snap] of captionEntries){if(snap)nextCaption[id]=snap}
liveCaption.value=nextCaption
ingestNewOutputs(nextJobs)
}
/** Merge freshly completed outputs into the local catalog — never refetch the whole library. */
function ingestNewOutputs(nextJobs:any[]){
const fresh=newestOutput(nextJobs,seenOutputs,form.folderId)
for(const job of nextJobs){
for(const output of job.outputs||[]){
if(seenOutputs.has(output.id)) continue
seenOutputs.add(output.id)
const meta=output.studio2||{}
const folderId=job.request?.folderId||form.folderId
if(output.kind==='clip'){
if(!(library.value.clips||[]).some((c:any)=>c.id===output.id)){
library.value={...library.value,clips:[...(library.value.clips||[]),{
id:output.id,folderId,createdAt:meta.savedAt||Date.now(),
name:meta.name||output.id.slice(0,8),hasThumbnail:true,hasVideo:true,
width:meta.outputWidth,height:meta.outputHeight,studio2:meta
}]}
}
}else if(output.kind!=='track'){
if(!(library.value.stills||[]).some((s:any)=>s.id===output.id)){
library.value={...library.value,stills:[...(library.value.stills||[]),{
id:output.id,folderId,createdAt:meta.savedAt||Date.now(),
name:meta.name||output.id.slice(0,8),role:'output',
width:meta.outputWidth,height:meta.outputHeight,
prompt:meta.prompt,promptRaw:meta.promptRaw,studio2:meta
}]}
}
}
}
}
if(fresh){
const existingFresh=family.value.find(a=>a.id===fresh.id)
if(existingFresh) selected.value=existingFresh
else{
const kind=fresh.kind==='clip'?'clip':'still'
selected.value={...fresh,kind,folderId:form.folderId,studio2:fresh.studio2}
}
}else{
let next=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
}
const newestTrack=(library.value.tracks||[]).filter((t:any)=>t.folderId===form.folderId).sort((a:any,b:any)=>(b.createdAt||0)-(a.createdAt||0))[0]
if(form.mode==='music' && newestTrack && !seenOutputs.has(newestTrack.id)){
seenOutputs.add(newestTrack.id)
selected.value={...newestTrack,kind:'track'}
}
}
async function queue(){
const t0=typeof performance!=='undefined'?performance.now():Date.now()
const logTiming=(label:string)=>{
const t=typeof performance!=='undefined'?performance.now():Date.now()
console.info(`[bench-queue] ${label} +${Math.round(t-t0)}ms`)
}
const localId=`local-${Date.now()}`
submitting.value=true
error.value=''
try{
if(form.mode==='music'){
logTiming('POST /api/generate/music start')
void $fetch('/api/generate/music',{method:'POST',body:{engine:'yue2',tags:music.tags,lyrics:music.lyrics,duration:music.duration,seed:music.seed||'random',folderId:form.folderId,name:music.name}})
.then(()=>{logTiming('POST music ack');void refreshJobs()})
.catch((e:any)=>{error.value=e.data?.statusMessage||e.message||String(e)})
return
}
if(photoRoleWarning.value){
error.value=photoRoleWarning.value
return
}
// Plain snapshot only — never structuredClone(form) (Vue proxies / Files throw DataCloneError)
const size={...resolvedSize.value}
const photos=(form.photos||[])
.filter((p:any)=>p?.stillId)
.map((p:any)=>({stillId:String(p.stillId),role:String(p.role||'change')}))
const promptAction=String(form.promptSections?.action||'')
// Image generate: split the prompt box on blank lines (parseBatch). Video/edit keep batchText.
const imageParts=(!video.value && form.mode==='generate') ? parseBatch(promptAction) : null
const firstAction=imageParts?.length ? imageParts[0] : promptAction
const imageBatch=imageParts?.length ? imageParts.slice(1) : []
const promptSections=video.value
?{
action:promptAction,
camera:String(form.promptSections?.camera||''),
soundscape:String(form.promptSections?.soundscape||''),
music:String(form.promptSections?.music||''),
locks:String(form.promptSections?.locks||''),
cameraBeats:Array.isArray(form.promptSections?.cameraBeats)?JSON.parse(JSON.stringify(form.promptSections.cameraBeats)):[],
soundscapeChips:[...(form.promptSections?.soundscapeChips||[])],
soundscapeLevel:form.promptSections?.soundscapeLevel||'normal',
musicChip:form.promptSections?.musicChip||'N/A',
dialogue:form.promptSections?.dialogue?JSON.parse(JSON.stringify(form.promptSections.dialogue)):{mode:'none',line:''}
}
:{action:firstAction}
const settings={
aspect:String(form.settings?.aspect||'auto'),
fps:Number(form.settings?.fps)||24,
steps:Number(form.settings?.steps)||24,
cfg:Number(form.settings?.cfg)||4,
seed:form.settings?.seed ?? null,
seedMode:String(form.settings?.seedMode||'random'),
duration:Number(form.settings?.duration)||5,
turbo:!!form.settings?.turbo,
width:size.width,
height:size.height,
loraStack:Array.isArray(form.settings?.loraStack)
?form.settings.loraStack.map((l:any)=>({
name:String(l?.name||l?.file||''),
strength:Number(l?.strength ?? 1),
clipStrength:l?.clipStrength != null ? Number(l.clipStrength) : undefined
})).filter((l:any)=>l.name)
:[]
}
const optimisticRequest={
mode:String(form.mode),
engine:String(form.engine),
folderId:String(form.folderId||''),
enhancePrompt:!!form.enhancePrompt,
turbo:!!form.turbo,
lockFace:!!form.lockFace,
lockOutfit:!!form.lockOutfit,
hideInput:!!xaigen,
hideOutput:false,
imageAId:String(form.imageAId||''),
imageBId:String(form.imageBId||''),
identityStillId:String(form.identityStillId||''),
startClipId:String(form.startClipId||''),
photos,
promptSections,
imageStyles:{
positive:[...(form.imageStyles?.positive||[])],
negative:[...(form.imageStyles?.negative||[])]
},
settings
}
jobs.value=[{
id:localId,
state:'waiting',
queuedAt:Date.now(),
request:optimisticRequest,
outputs:[],
purgeResult:'Queued'
},...jobs.value]
focusedJobId.value=localId
logTiming('local-card')
syncPhotosBindings()
if(form.settings.seedMode!=='locked')form.settings.seed=randomSeed()
const body=applyPhotosToRequest({
mode:optimisticRequest.mode==='edit' && parseBatch(batchText.value).length?'iterate':optimisticRequest.mode,
engine:optimisticRequest.engine,
folderId:optimisticRequest.folderId,
enhancePrompt:optimisticRequest.enhancePrompt,
turbo:optimisticRequest.turbo,
lockFace:optimisticRequest.lockFace,
lockOutfit:optimisticRequest.lockOutfit,
hideInput:optimisticRequest.hideInput,
hideOutput:false,
imageAId:optimisticRequest.imageAId,
imageBId:optimisticRequest.imageBId,
identityStillId:optimisticRequest.identityStillId,
startClipId:optimisticRequest.startClipId,
photos,
promptSections,
imageStyles:optimisticRequest.imageStyles,
settings:{...settings,seed:form.settings.seed},
batch:video.value || form.mode==='edit' ? parseBatch(batchText.value) : imageBatch,
guides:[],
maskId:'',
endStillId:''
})
logTiming('POST /api/studio-2/jobs start')
void $fetch<any>('/api/studio-2/jobs',{method:'POST',body})
.then((queued)=>{
logTiming('POST ack')
if(queued){
form.settings.seed=queued.seed;form.settings.width=queued.width;form.settings.height=queued.height
jobs.value=jobs.value.map(j=>j.id===localId?{...j,id:queued.id,state:'waiting'}:j)
focusedJobId.value=queued.id
}
void refreshJobs().then(()=>logTiming('refreshJobs done'))
})
.catch((e:any)=>{
jobs.value=jobs.value.filter(j=>j.id!==localId)
error.value=e.data?.statusMessage||e.message||String(e)
})
}catch(e:any){
jobs.value=jobs.value.filter(j=>j.id!==localId)
error.value=e.data?.statusMessage||e.message||String(e)
}finally{
submitting.value=false
logTiming('done')
}
}
async function cancel(job:any){
if(job?.kind==='music' || job?.kind==='caption'){
await $fetch(`/api/studio-queue/${job.id}`,{method:'DELETE'})
}else{
await $fetch('/api/studio-2/cancel',{method:'POST',body:{id:job.id}})
}
await refreshJobs()
}
async function loadLibraryOnce(){
// Current folder page only — never the yearly Inbox JSON on /
const qs=new URLSearchParams({limit:'50'})
if(form.folderId) qs.set('folderId',form.folderId)
const page=await $fetch<any>(`/api/library?${qs.toString()}`)
library.value={
folders:page.folders||library.value.folders||[],
stills:page.stills||[],
clips:page.clips||[],
tracks:page.tracks||[],
drafts:page.drafts||library.value.drafts||[],
loraAliases:page.loraAliases||library.value.loraAliases||{},
loraTriggers:page.loraTriggers||library.value.loraTriggers||{},
keepPrompts:page.keepPrompts||library.value.keepPrompts
}
}
async function fetchHealth(){
try{
return await Promise.race([
$fetch<any>('/api/health'),
new Promise<never>((_,rej)=>setTimeout(()=>rej(new Error('health_timeout')),800))
])
}catch{
return null
}
}
watch(()=>[
form.mode,form.engine,form.folderId,form.enhancePrompt,form.turbo,form.lockFace,form.lockOutfit,
form.promptSections,form.photos,form.imageStyles,form.settings,
eyeOpen.value,hideInputs.value,
music.tags,music.lyrics,music.duration,music.seed,music.name
],()=>saveBenchDraft(),{deep:true})
let timer:ReturnType<typeof setTimeout>,clock:ReturnType<typeof setInterval>,disposed=false
onMounted(()=>{
submitting.value=false
let storage;try{storage=localStorage}catch{}
loraMemory=createLoraMemory(xaigen,storage);loraMemory.sync(form);form.settings.seed=randomSeed()
// First paint in ~2s: chrome + draft. Do not await library before the prompt works.
restoreBenchDraft()
if(!form.folderId && library.value.folders?.[0]?.id) form.folderId=library.value.folders[0].id
loadRevealed(form.folderId)
clock=setInterval(()=>{now.value=Date.now()},1000)
void loadLoras(false)
// Folder page in background — never blocks Generate / prompt
void loadLibraryOnce().then(()=>{
restoreBenchDraft()
if(!form.folderId)form.folderId=library.value.folders[0]?.id||''
loadRevealed(form.folderId)
void 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'
}).catch((e:any)=>{error.value=e.data?.statusMessage||e.message})
// Idle poll: jobs + health only — 800ms health budget, chip GPU ? on miss
const poll=async()=>{
try{
await refreshJobs()
healthState.value=await fetchHealth()
}catch(e:any){healthState.value=null;error.value=e.data?.statusMessage||e.message}
if(!disposed)timer=setTimeout(poll,3000)
}
void poll()
if(import.meta.client){
window.addEventListener('keydown',onStageHotkey)
window.addEventListener('pointerdown',onStagePointerDown,true)
}
})
onBeforeUnmount(()=>{
disposed=true;clearTimeout(timer);clearInterval(clock)
if(draftTimer)clearTimeout(draftTimer)
if(import.meta.client){
saveBenchDraft(true)
window.removeEventListener('keydown',onStageHotkey)
window.removeEventListener('pointerdown',onStagePointerDown,true)
}
})
function randomizeSeed(){form.settings.seedMode='random';form.settings.seed=randomSeed()}
function setPicker(key:string){picker.value=key}
const mobileCtx=computed(()=>({
instanceName,
xaigen,
form,
music,
library:library.value,
stills:stills.value,
tasks,
engineOptions:engineOptions.value,
gpu:gpu.value,
gpuChipLabel:gpuChipLabel.value,
queueCount:queueCount.value,
queueCards:queueCards.value,
focusedJobId:focusedJobId.value,
canClearFinished:canClearFinished.value,
selected:selected.value,
family:family.value,
familyStillCount:familyStillCount.value,
libraryOpen:libraryOpen.value,
eyeOpen:eyeOpen.value,
hideInputs:hideInputs.value,
photoFilledCount:photoFilledCount.value,
openLibrary:()=>{libraryOpen.value=true},
closeLibrary:()=>{libraryOpen.value=false},
toggleHideInputs:()=>{hideInputs.value=!hideInputs.value;saveBenchDraft(true)},
showInputsAndAddPhoto:()=>{hideInputs.value=false;addPhoto()},
sourceSlot:sourceSlot.value,
video:video.value,
submitting:submitting.value,
uploading:uploading.value,
uploadMessage:uploadMessage.value,
error:error.value,
batchText:batchText.value,
negativeChips:negativeChips.value,
negativeDraft:negativeDraft.value,
photosPlanState:photosPlanState.value,
photoRoleOptions:photoRoleOptions.value,
photoRoleWarning:photoRoleWarning.value,
showIdentityLocks:showIdentityLocks.value,
pickerLoras:pickerLoras.value,
currentLoraUse:currentLoraUse.value,
loraAliases:{...loraNames.value.aliases,...library.value.loraAliases},
loraTriggers:{...loraNames.value.triggers,...library.value.loraTriggers},
aspectOptions,
stillUrl,
stillThumbUrl,
stillPreviewUrl,
stagePreviewSrc:stagePreviewSrc.value,
stagePreviewNonce:stagePreviewNonce.value,
onStagePreviewError,
isHidden,
slotHidden,
stillById,
toggleReveal,
chooseTask,
selectOutput,
stepFamily,
deleteOutput,
queue,
upload,
cancel,
focusQueueCard,
clearFinished,
dismissFinishedCard,
copyCaption,
useCaptionAsPrompt,
removeNegative,
addNegative,
addPhoto,
removePhoto,
setPhotoRole,
mentionPhoto,
applyUseAsSource:applyUseImage,
applyUseImage,
applyUsePrompt,
copySelectedPrompt,
copySelectedRawPrompt,
loadInputUsed,
loadInputRaw,
loadSelectedJob,
onLibraryUseAsPhoto:onLibraryUseImage,
onLibraryUseImage,
onLibraryUsePrompt,
onLibraryCopyPrompt,
onLibraryCopyRawPrompt,
onLibraryLoadInput,
onLibraryLoadInputRaw,
onLibraryDelete,
randomizeSeed,
setPicker,
setBatchText:(v:string)=>{batchText.value=v},
setNegativeDraft:(v:string)=>{negativeDraft.value=v},
}))
</script>