1568 lines
66 KiB
Vue
1568 lines
66 KiB
Vue
<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."
|
||
@select="capturePromptCaret"
|
||
@click="capturePromptCaret"
|
||
@keyup="capturePromptCaret"
|
||
/>
|
||
</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>
|
||
<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">
|
||
<template v-if="selected && isHidden(selected)">
|
||
<p class="placeholder-still">{{ selected.kind==='track' ? 'Track loaded · preview hidden' : 'Output hidden' }}</p>
|
||
</template>
|
||
<template v-else-if="selected?.kind==='track'">
|
||
<div 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
|
||
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
|
||
:key="selected.id"
|
||
:src="stageStillSrc"
|
||
alt="Selected still"
|
||
decoding="async"
|
||
@load="onStageStillLoad"
|
||
>
|
||
</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>
|
||
</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>
|
||
</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…' : 'Generate' }}
|
||
</button>
|
||
<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 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 {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
|
||
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=>{selected.value=null;form.imageAId='';form.imageBId='';form.photos=[];form.startClipId='';loadRevealed(id);if(import.meta.client && form.engine!=='qwen21')form.identityStillId=localStorage.getItem(`studio2-hero:${id}`)||''},{flush:'sync'})
|
||
watch(()=>form.identityStillId,id=>{if(import.meta.client&&form.folderId&&form.engine!=='qwen21')localStorage.setItem(`studio2-hero:${form.folderId}`,id)})
|
||
const stills=computed(()=>library.value.stills.filter((s:any)=>s.folderId===form.folderId))
|
||
const 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`
|
||
/** Stage shows thumb first, then swaps to original after decode — never leave the previous still painted. */
|
||
const stageStillHiRes=ref(false)
|
||
const stageStillSrc=computed(()=>{
|
||
const id=selected.value?.id
|
||
if(!id || selected.value?.kind==='clip' || selected.value?.kind==='track') return ''
|
||
if(isHidden(selected.value)) return ''
|
||
return stageStillHiRes.value ? stillUrl(id) : stillThumbUrl(id)
|
||
})
|
||
function onStageStillLoad(e:Event){
|
||
const id=selected.value?.id
|
||
if(!id || stageStillHiRes.value) return
|
||
const img=e.target as HTMLImageElement
|
||
const bump=()=>{ if(selected.value?.id===id) stageStillHiRes.value=true }
|
||
if(typeof img.decode==='function') void img.decode().then(bump).catch(bump)
|
||
else bump()
|
||
}
|
||
watch(()=>selected.value?.id,()=>{ stageStillHiRes.value=false })
|
||
watch(eyeOpen,()=>{ if(eyeOpen.value) stageStillHiRes.value=false })
|
||
const folderJobs=computed(()=>jobs.value.filter(j=>j.request.folderId===form.folderId))
|
||
const benchJobs=computed(()=>queueJobs(folderJobs.value))
|
||
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 gpu=computed(()=>gpuStatus(jobs.value,healthState.value,upscales.value,musicJobs.value))
|
||
const gpuChipLabel=computed(()=>{
|
||
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
|
||
})
|
||
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(()=>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 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)))
|
||
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)
|
||
}
|
||
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 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)
|
||
}
|
||
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'})
|
||
await refresh()
|
||
}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;const media=asset.id===form.startClipId?document.createElement('video'):new Image();if(media.tagName==='VIDEO')(media as HTMLVideoElement).preload='metadata';const done=()=>{measured[asset.id]={width:(media as HTMLVideoElement).videoWidth||(media as HTMLImageElement).naturalWidth,height:(media as HTMLVideoElement).videoHeight||(media as HTMLImageElement).naturalHeight}};media.addEventListener(media.tagName==='VIDEO'?'loadedmetadata':'load',done,{once:true});media.src=media.tagName==='VIDEO'?`/api/library/clips/${asset.id}/video`:stillUrl(asset.id)})
|
||
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 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
|
||
}
|
||
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`)
|
||
}
|
||
submitting.value=true;error.value=''
|
||
// Optimistic card in <100ms — never await library / object_info before the list updates
|
||
const localId=`local-${Date.now()}`
|
||
if(form.mode!=='music'){
|
||
const optimistic={
|
||
id:localId,
|
||
state:'waiting',
|
||
queuedAt:Date.now(),
|
||
request:structuredClone({
|
||
...form,
|
||
promptSections:video.value?form.promptSections:{action:form.promptSections.action},
|
||
settings:{...form.settings,...resolvedSize.value}
|
||
}),
|
||
outputs:[],
|
||
purgeResult:'Queued'
|
||
}
|
||
jobs.value=[optimistic,...jobs.value]
|
||
focusedJobId.value=localId
|
||
logTiming('local-card')
|
||
}
|
||
try{
|
||
if(form.mode==='music'){
|
||
logTiming('POST /api/generate/music start')
|
||
await $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}})
|
||
logTiming('POST music ack')
|
||
void refresh()
|
||
return
|
||
}
|
||
if(photoRoleWarning.value){
|
||
jobs.value=jobs.value.filter(j=>j.id!==localId)
|
||
error.value=photoRoleWarning.value
|
||
return
|
||
}
|
||
syncPhotosBindings()
|
||
if(form.settings.seedMode!=='locked')form.settings.seed=randomSeed()
|
||
const body=applyPhotosToRequest({
|
||
...form,
|
||
mode:form.mode==='edit' && parseBatch(batchText.value).length?'iterate':form.mode,
|
||
hideInput:xaigen,
|
||
hideOutput:false,
|
||
promptSections:video.value?form.promptSections:{action:form.promptSections.action},
|
||
settings:{...form.settings,...resolvedSize.value},
|
||
batch:video.value || form.mode==='edit'?parseBatch(batchText.value):[]
|
||
})
|
||
logTiming('POST /api/studio-2/jobs start')
|
||
const queued=await Promise.race([
|
||
$fetch<any>('/api/studio-2/jobs',{method:'POST',body}),
|
||
new Promise<never>((_,rej)=>setTimeout(()=>rej(new Error('queue_ack_timeout')),12000))
|
||
]).catch(async(err)=>{
|
||
logTiming(`POST fail/timeout: ${err?.message||err}`)
|
||
// Keep the local card queued; poll will reconcile
|
||
if(String(err?.message||err).includes('queue_ack_timeout')) return null
|
||
throw err
|
||
})
|
||
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
|
||
}
|
||
// Jobs only — do not refetch the whole library when a job starts
|
||
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
|
||
}finally{submitting.value=false;logTiming('done')}
|
||
}
|
||
async function cancel(job:any){await $fetch('/api/studio-2/cancel',{method:'POST',body:{id:job.id}});await refreshJobs()}
|
||
|
||
async function refresh(){
|
||
const [nextJobs,nextLibrary,nextUpscales,queue]=await Promise.all([
|
||
$fetch<any[]>('/api/studio-2/jobs'),
|
||
$fetch<any>('/api/studio-2/library'),
|
||
$fetch<any[]>('/api/studio-2/upscales'),
|
||
$fetch<any>('/api/studio-queue?full=1').catch(()=>({jobs:[]}))
|
||
])
|
||
jobs.value=nextJobs;library.value=nextLibrary;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 fresh=newestOutput(nextJobs,seenOutputs,form.folderId)
|
||
for(const job of nextJobs)for(const output of job.outputs||[])seenOutputs.add(output.id)
|
||
const existingFresh=family.value.find(a=>a.id===fresh?.id)
|
||
const newestTrack=(library.value.tracks||[]).filter((t:any)=>t.folderId===form.folderId).sort((a:any,b:any)=>(b.createdAt||0)-(a.createdAt||0))[0]
|
||
// Only auto-select new tracks on Music — never leave a waveform mounted over Generate/Klein/Video.
|
||
if(form.mode==='music' && newestTrack && !seenOutputs.has(newestTrack.id)){seenOutputs.add(newestTrack.id);selected.value={...newestTrack,kind:'track'};return}
|
||
if(newestTrack) seenOutputs.add(newestTrack.id)
|
||
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
|
||
}
|
||
|
||
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(()=>{
|
||
let storage;try{storage=localStorage}catch{}
|
||
loraMemory=createLoraMemory(xaigen,storage);loraMemory.sync(form);form.settings.seed=randomSeed()
|
||
// First paint: chrome + empty stage + draft from localStorage — do not await library / object_info
|
||
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)
|
||
// Deferred heavy work after shell is interactive
|
||
void (async()=>{
|
||
void loadLoras(false)
|
||
try{
|
||
library.value=await $fetch('/api/studio-2/library')
|
||
restoreBenchDraft()
|
||
if(!form.folderId)form.folderId=library.value.folders[0]?.id||''
|
||
restoreBenchDraft()
|
||
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'
|
||
}catch(e:any){error.value=e.data?.statusMessage||e.message}
|
||
const poll=async()=>{try{await refresh();const health=await $fetch<any>('/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()
|
||
})()
|
||
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,
|
||
stageStillSrc:stageStillSrc.value,
|
||
onStageStillLoad,
|
||
isHidden,
|
||
slotHidden,
|
||
stillById,
|
||
toggleReveal,
|
||
chooseTask,
|
||
selectOutput,
|
||
stepFamily,
|
||
deleteOutput,
|
||
queue,
|
||
upload,
|
||
cancel,
|
||
focusQueueCard,
|
||
clearFinished,
|
||
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>
|