440 lines
34 KiB
Vue
440 lines
34 KiB
Vue
<template>
|
||
<main class="w-full min-w-0 space-y-5 p-4 md:p-6 xl:p-8" :data-display="displayMode">
|
||
<header class="flex flex-wrap items-end justify-between gap-4">
|
||
<div>
|
||
<h1 class="font-display text-3xl font-bold tracking-tight text-zinc-50">Studio 2</h1>
|
||
<p class="mt-1 text-sm text-zinc-400">One hero. Every shot.</p>
|
||
</div>
|
||
<div class="flex flex-wrap items-center gap-3">
|
||
<label class="text-sm text-zinc-300">Folder
|
||
<select v-model="form.folderId" class="ml-2 rounded-lg border border-white/10 bg-zinc-900 px-3 py-2 outline-none ring-amber-300/40 focus:ring-2">
|
||
<option v-for="f in library.folders" :key="f.id" :value="f.id">{{ f.name }}</option>
|
||
</select>
|
||
</label>
|
||
<div v-if="isClassic && form.mode!=='music'" class="flex flex-wrap items-center gap-3 text-sm text-zinc-300" aria-label="Identity locks">
|
||
<label class="inline-flex items-center gap-1.5"><input v-model="form.lockFace" type="checkbox" class="accent-amber-400">Lock face</label>
|
||
<label class="inline-flex items-center gap-1.5"><input v-model="form.lockOutfit" type="checkbox" class="accent-amber-400">Lock outfit</label>
|
||
</div>
|
||
<div v-if="isClassic && xaigen" class="flex flex-wrap items-center gap-3 text-sm text-zinc-300" aria-label="Preview hide">
|
||
<label class="inline-flex items-center gap-1.5"><input v-model="classicHideInput" type="checkbox" class="accent-amber-400">Hide input</label>
|
||
<label class="inline-flex items-center gap-1.5"><input v-model="classicHideOutput" type="checkbox" class="accent-amber-400">Hide output</label>
|
||
</div>
|
||
<nav class="flex flex-wrap items-center gap-3 text-sm" aria-label="Studio links">
|
||
<Studio2GpuStatus :status="gpu" />
|
||
<NuxtLink to="/queue" class="text-zinc-300 outline-none ring-amber-300/40 focus-visible:ring-2">Queue</NuxtLink>
|
||
<NuxtLink to="/library" class="text-amber-200 outline-none ring-amber-300/40 focus-visible:ring-2">Library</NuxtLink>
|
||
<NuxtLink to="/settings" class="text-zinc-300 outline-none ring-amber-300/40 focus-visible:ring-2">Settings</NuxtLink>
|
||
</nav>
|
||
</div>
|
||
</header>
|
||
|
||
<nav class="flex flex-wrap gap-2" aria-label="Tasks">
|
||
<button
|
||
v-for="task in tasks"
|
||
:key="task"
|
||
type="button"
|
||
class="rounded-full border px-4 py-2 capitalize outline-none ring-amber-300/50 focus-visible:ring-2"
|
||
:class="form.mode===task ? 'border-amber-300 bg-amber-300 text-black' : 'border-white/15 text-zinc-200 hover:border-white/30'"
|
||
:aria-pressed="form.mode===task"
|
||
@click="chooseTask(task)"
|
||
>{{ task }}</button>
|
||
</nav>
|
||
|
||
<div class="grid min-w-0 gap-6 xl:grid-cols-[minmax(0,3fr)_minmax(24rem,2fr)] 2xl:gap-8">
|
||
<section class="min-w-0 space-y-4">
|
||
<div class="relative flex min-h-80 items-center justify-center overflow-hidden rounded-3xl border border-white/10 bg-black">
|
||
<button
|
||
v-if="selected && xaigen && isStudio"
|
||
type="button"
|
||
class="absolute right-3 top-3 z-10 rounded-full border border-white/20 bg-zinc-950/80 p-2 text-zinc-200 outline-none ring-amber-300/50 hover:border-amber-300/50 focus-visible:ring-2"
|
||
:aria-pressed="!isHidden(selected)"
|
||
:aria-label="isHidden(selected) ? 'Show preview' : 'Hide preview'"
|
||
@click.stop="toggleReveal(selected.id)"
|
||
>
|
||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" aria-hidden="true">
|
||
<path v-if="isHidden(selected)" d="M3 3l18 18M10.5 10.6a2.5 2.5 0 0 0 3 3M9.4 5.1A10.4 10.4 0 0 1 12 4.5c5 0 9.3 3.1 11 7.5a11.6 11.6 0 0 1-4.2 5.1M6.2 6.2A11.5 11.5 0 0 0 1 12c1.2 2.7 3.3 4.9 5.9 6.3A10.5 10.5 0 0 0 12 19.5c1.2 0 2.4-.2 3.5-.6" stroke-linecap="round"/>
|
||
<template v-else>
|
||
<path d="M2 12s3.5-7.5 10-7.5S22 12 22 12s-3.5 7.5-10 7.5S2 12 2 12Z"/>
|
||
<circle cx="12" cy="12" r="3"/>
|
||
</template>
|
||
</svg>
|
||
</button>
|
||
<p v-if="selected && isHidden(selected)" class="text-zinc-400">{{ selected.kind==='track' ? 'Track loaded · preview hidden' : selected.role==='input' ? 'Still loaded · preview hidden' : 'Output hidden' }}</p>
|
||
<div v-else-if="selected?.kind==='track'" class="w-full max-w-xl px-6 py-4">
|
||
<AudioPlayer :src="`/api/library/tracks/${selected.id}/audio`" :download-name="`${selected.name || 'track'}.${selected.ext || 'wav'}`" :label="selected.name || 'Track'" />
|
||
</div>
|
||
<video v-else-if="selected?.kind==='clip'" :key="selected.id" :src="`/api/library/clips/${selected.id}/video`" controls class="max-h-[65vh] w-full object-contain" />
|
||
<img v-else-if="selected" :src="stillUrl(selected.id)" class="max-h-[65vh] max-w-full object-contain" alt="Current output">
|
||
<p v-else class="text-zinc-500">Select a result to view it here</p>
|
||
</div>
|
||
|
||
<div v-if="selected" class="flex flex-wrap items-start gap-3">
|
||
<button v-if="selected.kind!=='track'" class="rounded-full border border-white/20 px-4 py-2 text-sm outline-none ring-amber-300/40 focus-visible:ring-2" @click="restore(selected)">Use as input</button>
|
||
<button v-if="selected.kind==='still'" class="rounded-full border border-white/20 px-4 py-2 text-sm outline-none ring-amber-300/40 focus-visible:ring-2" @click="Object.assign(form,useAsSource(form,selected))">Use as source still</button>
|
||
<button v-if="selected.kind==='still'" class="rounded-full border border-white/20 px-4 py-2 text-sm outline-none ring-amber-300/40 focus-visible:ring-2" @click="Object.assign(form,useAsHero(form,selected))">Use as hero</button>
|
||
<button v-if="selected.kind!=='track'" class="rounded-full border border-white/20 px-4 py-2 text-sm outline-none ring-amber-300/40 focus-visible:ring-2" @click="detailsOpen=true">Details</button>
|
||
<button class="rounded-full border border-red-400/40 px-4 py-2 text-sm text-red-200 outline-none ring-amber-300/40 focus-visible:ring-2" @click="deleteOutput">Delete</button>
|
||
<VideoUpscaleControl v-if="selected.kind==='clip'" :clip-id="selected.id" queue-only @complete="refresh" />
|
||
</div>
|
||
|
||
<div class="flex min-w-0 max-w-full gap-2 overflow-x-auto" aria-label="Current family outputs">
|
||
<div
|
||
v-for="o in family"
|
||
:key="o.id"
|
||
class="relative shrink-0 rounded-lg border p-1"
|
||
:class="selected?.id===o.id ? 'border-amber-300' : 'border-white/10'"
|
||
>
|
||
<button type="button" class="block outline-none ring-amber-300/40 focus-visible:ring-2" @click="selectOutput(o)">
|
||
<span v-if="isHidden(o)" class="flex h-20 w-24 items-center justify-center p-1 text-xs text-zinc-400">{{ o.kind==='track' ? 'Audio hidden' : o.role==='input' ? 'Still loaded · preview hidden' : 'Output hidden' }}</span>
|
||
<span v-else-if="o.kind==='track'" class="flex h-20 w-24 items-center justify-center rounded bg-zinc-900 text-xs text-amber-100">Audio</span>
|
||
<img v-else :src="o.kind==='clip' ? `/api/library/clips/${o.id}/thumb` : stillUrl(o.id)" class="h-20 w-24 rounded object-cover" alt="Family output">
|
||
</button>
|
||
<button
|
||
v-if="xaigen && isStudio"
|
||
type="button"
|
||
class="absolute right-1 top-1 rounded-md border border-white/20 bg-zinc-950/80 p-1 text-zinc-200 outline-none ring-amber-300/50 focus-visible:ring-2"
|
||
:aria-label="isHidden(o) ? 'Show tile' : 'Hide tile'"
|
||
:aria-pressed="!isHidden(o)"
|
||
@click.stop="toggleReveal(o.id)"
|
||
>
|
||
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" aria-hidden="true">
|
||
<path v-if="isHidden(o)" d="M3 3l18 18M10.5 10.6a2.5 2.5 0 0 0 3 3M9.4 5.1A10.4 10.4 0 0 1 12 4.5c5 0 9.3 3.1 11 7.5a11.6 11.6 0 0 1-4.2 5.1M6.2 6.2A11.5 11.5 0 0 0 1 12c1.2 2.7 3.3 4.9 5.9 6.3A10.5 10.5 0 0 0 12 19.5c1.2 0 2.4-.2 3.5-.6" stroke-linecap="round"/>
|
||
<template v-else>
|
||
<path d="M2 12s3.5-7.5 10-7.5S22 12 22 12s-3.5 7.5-10 7.5S2 12 2 12Z"/>
|
||
<circle cx="12" cy="12" r="3"/>
|
||
</template>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<section v-if="form.mode!=='music'" aria-label="Source" class="rounded-2xl border border-white/10 p-3">
|
||
<div class="mb-3 flex flex-wrap items-start justify-between gap-3">
|
||
<h2 class="text-xs uppercase tracking-wider text-zinc-400">Source</h2>
|
||
<div v-if="isStudio" class="flex flex-wrap gap-4 text-sm text-zinc-300" aria-label="Identity locks">
|
||
<label class="flex items-start gap-2"><input v-model="form.lockFace" type="checkbox" class="mt-1 accent-amber-400"><span>Lock face<span class="block text-xs text-zinc-500">Hero encode + lock on Klein / Krea stills</span></span></label>
|
||
<label class="flex items-start gap-2"><input v-model="form.lockOutfit" type="checkbox" class="mt-1 accent-amber-400"><span>Lock outfit<span class="block text-xs text-zinc-500">Hero encode + lock on Klein / Krea stills</span></span></label>
|
||
</div>
|
||
</div>
|
||
<div class="flex flex-wrap gap-4">
|
||
<Studio2AssetSlot label="Hero" :asset="stills.find((a:any)=>a.id===form.identityStillId)" :hidden="slotHidden(form.identityStillId)" upload :disabled="uploading" @pick="picker='identityStillId'" @upload="upload($event,'hero')" />
|
||
<Studio2AssetSlot :label="form.mode==='extend'?'Previous clip':'Start still'" :asset="sourceSlot" :hidden="slotHidden(form.mode==='extend'?form.startClipId:form.imageAId)" upload :disabled="uploading" @pick="picker=form.mode==='extend'?'startClipId':'imageAId'" @upload="upload($event,'source')" />
|
||
<Studio2AssetSlot v-if="form.mode==='compose'" label="Second still" :asset="stills.find((a:any)=>a.id===form.imageBId)" :hidden="slotHidden(form.imageBId)" @pick="picker='imageBId'" />
|
||
<Studio2AssetSlot v-if="form.mode==='refine'" label="Mask" :asset="stills.find((a:any)=>a.id===form.maskId)" :hidden="slotHidden(form.maskId)" @pick="picker='maskId'" />
|
||
<Studio2AssetSlot v-if="video" label="End still" :asset="stills.find((a:any)=>a.id===form.endStillId)" :hidden="slotHidden(form.endStillId)" @pick="picker='endStillId'" />
|
||
</div>
|
||
<label v-if="form.mode==='refine'" class="mt-3 block text-sm">Mask still (white = change). On-canvas mask comes later.
|
||
<select v-model="form.maskId" class="mt-1 w-full rounded-xl bg-zinc-800 p-2"><option value="">Choose mask still</option><option v-for="a in stills" :key="a.id" :value="a.id">{{ assetLabel(a) }}</option></select>
|
||
</label>
|
||
<p v-if="uploading || uploadMessage" role="status" class="mt-2 text-sm text-amber-200">{{ uploading?'Uploading image…':uploadMessage }}</p>
|
||
</section>
|
||
|
||
<details v-if="video" class="rounded-xl border border-white/10 p-3"><summary>Add Guide stills</summary>
|
||
<div v-for="(g,i) in form.guides" :key="i" class="mt-2 flex gap-2">
|
||
<Studio2AssetSlot label="Guide" :asset="stills.find((a:any)=>a.id===g.stillId)" :hidden="slotHidden(g.stillId)" @pick="picker='guide:'+i" />
|
||
<input v-model.number="g.frame" type="number" min="1" aria-label="Guide frame" class="w-20 bg-zinc-800 p-2">
|
||
<button type="button" @click="form.guides.splice(i,1)">Remove</button>
|
||
</div>
|
||
<button type="button" class="mt-3 text-amber-200" @click="form.guides.push({stillId:'',frame:24})">Add guide</button>
|
||
</details>
|
||
</section>
|
||
|
||
<section class="min-w-0 space-y-4">
|
||
<template v-if="form.mode==='music'">
|
||
<p class="text-sm text-zinc-400">YuE2 · lyrics-to-song · plan → synthesize → decode</p>
|
||
<label class="block text-sm">Genre / style tags
|
||
<textarea v-model="music.tags" rows="3" class="mt-1 w-full rounded-xl bg-zinc-900 p-3 outline-none ring-amber-300/40 focus:ring-2" :placeholder="tagsPlaceholder" />
|
||
</label>
|
||
<label class="block text-sm">Lyrics
|
||
<textarea v-model="music.lyrics" rows="8" class="mt-1 w-full rounded-xl bg-zinc-900 p-3 outline-none ring-amber-300/40 focus:ring-2" :placeholder="lyricsPlaceholder" />
|
||
</label>
|
||
<div class="grid grid-cols-2 gap-3">
|
||
<label class="block text-sm">Target length (sec)
|
||
<input v-model.number="music.duration" type="number" min="15" max="150" class="mt-1 w-full rounded-xl bg-zinc-900 p-3 outline-none ring-amber-300/40 focus:ring-2">
|
||
<span class="mt-1 block text-[11px] text-zinc-500">Caps semantic tokens (~25/s). Actual length follows the model end token — not an FFmpeg trim.</span>
|
||
</label>
|
||
<label class="block text-sm">Seed
|
||
<input v-model="music.seed" placeholder="random" class="mt-1 w-full rounded-xl bg-zinc-900 p-3 outline-none ring-amber-300/40 focus:ring-2">
|
||
</label>
|
||
</div>
|
||
<label class="block text-sm">Track name (optional)
|
||
<input v-model="music.name" class="mt-1 w-full rounded-xl bg-zinc-900 p-3 outline-none ring-amber-300/40 focus:ring-2" placeholder="Optional track name">
|
||
</label>
|
||
</template>
|
||
<template v-else>
|
||
<label class="block">Prompt<textarea v-model="form.promptSections.action" rows="4" class="mt-1 w-full rounded-xl bg-zinc-900 p-3 outline-none ring-amber-300/40 focus:ring-2" placeholder="Describe the image or the shot." /></label>
|
||
<Studio2PromptControls v-if="video" v-model="form.promptSections" />
|
||
<Studio2StyleChips v-else v-model="form.imageStyles" />
|
||
<label v-if="video" class="block">Extra constraints (optional) — do not repeat the Prompt. Example: keep the black glasses, no extra people.
|
||
<textarea v-model="form.promptSections.locks" rows="2" class="mt-1 w-full rounded-xl bg-zinc-900 p-3 outline-none ring-amber-300/40 focus:ring-2" />
|
||
</label>
|
||
<details v-if="video || form.mode==='edit'" class="rounded-xl border border-white/10 p-3">
|
||
<summary>{{ video?'Queue more shots':'Queue extra edit passes' }}</summary>
|
||
<p class="mt-2 text-xs text-zinc-400">Each blank-line-separated block is another job. Hero and settings stay. Press Enter twice between shots. A wrapped sentence is still one shot.{{ video?'':' Each extra pass edits the previous result with Klein.' }}</p>
|
||
<textarea v-model="batchText" rows="5" class="mt-2 w-full rounded-xl bg-zinc-900 p-3 outline-none ring-amber-300/40 focus:ring-2" placeholder="First additional prompt Next additional prompt" />
|
||
</details>
|
||
<button type="button" class="text-sm text-amber-200 outline-none ring-amber-300/40 focus-visible:ring-2" @click="settingsOpen=true">Generation settings</button>
|
||
<p v-if="!video && form.engine==='krea'" class="text-sm text-zinc-400">Krea does not attach the hero identity reference.</p>
|
||
<p v-if="form.mode==='extend'" class="text-sm text-amber-200">{{ form.startClipId ? 'Extend · '+(clips.find((c:any)=>c.id===form.startClipId)?.name || form.startClipId)+' · last frame' : 'Select the clip in the family strip or Previous clip.' }}</p>
|
||
</template>
|
||
|
||
<p v-if="error" role="alert" class="text-sm text-red-300">{{ error }}</p>
|
||
<button type="button" :disabled="submitting" class="w-full rounded-2xl bg-amber-300 p-4 font-semibold text-black outline-none ring-amber-300/60 focus-visible:ring-2 disabled:opacity-50" @click="queue">{{ submitting?'Queuing…':'Queue' }}</button>
|
||
|
||
<h2 class="text-sm font-semibold text-zinc-300">Job queue</h2>
|
||
<div v-for="job in upscales.filter(j=>j.folderId===form.folderId && ['queued','running'].includes(j.status))" :key="job.id" class="space-y-2 rounded-xl border border-amber-300/25 bg-zinc-900 p-4 text-sm" role="status">
|
||
<div class="flex justify-between gap-3"><strong>Video upscale</strong><span class="capitalize text-amber-200">{{ job.status }}</span></div>
|
||
<p class="truncate text-zinc-300">{{ job.name }}</p>
|
||
<p class="text-xs text-zinc-400">{{ job.width?job.width+' × '+job.height+' · ':'' }}{{ Math.round((job.elapsedMs||0)/1000) }}s elapsed</p>
|
||
<progress class="h-2 w-full accent-amber-300" max="100" :value="job.progress || 0" />
|
||
<p class="text-xs text-zinc-400">{{ job.message }}</p>
|
||
</div>
|
||
<div v-for="job in musicJobs" :key="job.id" class="space-y-2 rounded-xl border border-amber-300/25 bg-zinc-900 p-3 text-sm" aria-live="polite" role="status">
|
||
<div class="flex justify-between gap-3">
|
||
<strong class="truncate">{{ job.name || 'YuE2' }}</strong>
|
||
<span class="capitalize text-amber-200">{{ job.live?.status || job.status }}</span>
|
||
</div>
|
||
<p class="text-xs text-zinc-400">YuE2 · target {{ job.duration || '—' }}s<span v-if="job.live?.message"> · {{ job.live.message }}</span></p>
|
||
<progress v-if="job.live?.progress" class="h-2 w-full accent-amber-300" max="100" :value="job.live.progress || 0" />
|
||
<p v-if="job.lastError || job.live?.error" role="alert" class="text-red-300">{{ job.lastError || job.live?.error }}</p>
|
||
</div>
|
||
<Studio2JobProgress v-for="job in benchJobs" :key="job.id" :job="job" :now="now" @select="focusJob(job)" @cancel="cancel(job)" />
|
||
<button v-if="folderJobs.some(j=>['complete','failed','cancelled'].includes(j.state)) || musicJobs.some(j=>['complete','done','error','cancelled'].includes(j.status))" type="button" class="text-sm text-zinc-400 outline-none ring-amber-300/40 focus-visible:ring-2" @click="clearFinished">Clear finished</button>
|
||
</section>
|
||
</div>
|
||
|
||
<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-white/15 bg-zinc-950 p-6">
|
||
<button type="button" class="float-right outline-none ring-amber-300/40 focus-visible:ring-2" @click="settingsOpen=false;detailsOpen=false">Close</button>
|
||
<template v-if="settingsOpen">
|
||
<h2 class="mb-6 text-xl font-semibold">Generation settings</h2>
|
||
<label class="mb-4 block">Engine
|
||
<select v-model="form.engine" class="mt-1 w-full rounded bg-zinc-800 p-2 outline-none ring-amber-300/40 focus:ring-2">
|
||
<template v-if="video">
|
||
<option value="minimax">MiniMax H3</option>
|
||
<option v-if="xaigen" value="ltx">LTX Pink Cherry</option>
|
||
</template>
|
||
<template v-else>
|
||
<option value="flux">Flux Klein 9B Base</option>
|
||
<option value="krea" :disabled="['edit','compose','iterate'].includes(form.mode)">Krea 2 (Generate / Refine)</option>
|
||
</template>
|
||
</select>
|
||
</label>
|
||
<AspectRatioPick v-model="form.settings.aspect" heading="Aspect" :options="aspectOptions" />
|
||
<p class="my-3 text-sm text-zinc-400">{{ resolvedSize.width }} × {{ resolvedSize.height }} · fit with padding, never stretch</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="mt-1 w-full rounded bg-zinc-800 p-2 outline-none ring-amber-300/40 focus:ring-2">
|
||
</label>
|
||
<label class="mb-4 block">Seed · {{ form.settings.seedMode==='locked'?'Locked':'Random each job' }}
|
||
<input :value="form.settings.seed" type="number" min="0" max="2147483647" class="mt-1 w-full rounded bg-zinc-800 p-2 outline-none ring-amber-300/40 focus:ring-2" @input="form.settings.seed=Number(($event.target as HTMLInputElement).value);form.settings.seedMode='locked'">
|
||
</label>
|
||
<button type="button" class="mb-4 text-sm text-amber-200 outline-none ring-amber-300/40 focus-visible:ring-2" @click="form.settings.seedMode='random';form.settings.seed=randomSeed()">Random</button>
|
||
<label v-if="video"><input v-model="form.settings.turbo" type="checkbox" class="accent-amber-400"> Turbo</label>
|
||
<LoraSelect v-model="form.settings.loraStack" class="mt-5" :names="pickerLoras" :use="currentLoraUse" :clip-strength="true" filename-subtitle :aliases="{...loraNames.aliases,...library.loraAliases}" :triggers="{...loraNames.triggers,...library.loraTriggers}" missing-label="No cached LoRAs yet — Refresh to check Comfy" @insert-trigger="form.promptSections.action += ' '+$event" />
|
||
<button type="button" class="mt-2 text-sm text-amber-200 outline-none ring-amber-300/40 focus-visible:ring-2" @click="loadLoras(true)">Refresh LoRAs</button>
|
||
<details class="mt-6"><summary>Compiled prompt</summary>
|
||
<pre class="whitespace-pre-wrap text-xs text-zinc-400">{{ compiled }}</pre>
|
||
<p v-if="!video" class="mt-3 text-xs text-zinc-400">Negative: {{ stylePrompt(form.imageStyles,true) || 'None' }}</p>
|
||
</details>
|
||
</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="picker=''">
|
||
<section role="dialog" aria-modal="true" aria-label="Choose source asset" class="max-h-[80vh] w-full max-w-4xl overflow-auto rounded-2xl border border-white/15 bg-zinc-950 p-5">
|
||
<div class="mb-4 flex justify-between"><h2>Choose {{ picker==='identityStillId'?'hero':'source' }}</h2><button type="button" @click="picker=''">Close</button></div>
|
||
<button type="button" class="mb-4 text-sm text-zinc-400" @click="pickAsset(null)">Clear slot</button>
|
||
<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-xl border border-white/15 p-2 text-left outline-none ring-amber-300/40 focus-visible:ring-2" @click="pickAsset(a)">
|
||
<span v-if="isHidden(a)" class="flex h-24 items-center text-xs text-zinc-400">Still loaded · preview hidden</span>
|
||
<img v-else :src="a.kind==='clip'?'/api/library/clips/'+a.id+'/thumb':stillUrl(a.id)" class="h-24 w-full object-contain" alt="Source option">
|
||
<span class="mt-2 block truncate text-xs">{{ assetLabel(a) }}</span>
|
||
</button>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</main>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import {createLoraMemory,loraUse} from '~/shared/studio2/lora-memory.mjs'
|
||
import {filterLorasForStudio,filterLorasForUse} from '~/utils/loras'
|
||
import {compilePrompt,sectionsFrom} from '~/shared/studio2/contracts.mjs'
|
||
import {aspectOptions,resolveSize,bindClip,changeSource,restoreAll,useAsHero,useAsSource} from '~/shared/studio2/input.mjs'
|
||
import {folderAssets,newestOutput,assetLabel,reconcileSelection,previewHidden} from '~/shared/studio2/viewer.mjs'
|
||
import {queueJobs} from '~/shared/studio2/queue-view.mjs'
|
||
import {gpuStatus} from '~/shared/studio2/status.mjs'
|
||
import {applyUploadedStill} from '~/shared/studio2/upload.mjs'
|
||
import {parseBatch,stylePrompt} from '~/shared/studio2/styles.mjs'
|
||
import {randomSeed} from '~/shared/studio2/seed.mjs'
|
||
import {DEFAULT_MUSIC_LYRICS,DEFAULT_MUSIC_TAGS} from '~/utils/music'
|
||
|
||
const xaigen=useRuntimeConfig().public.studio==='xaigen'
|
||
const {displayMode,isStudio,isClassic}=useDisplayMode()
|
||
const classicHideInput=ref(true)
|
||
const classicHideOutput=ref(true)
|
||
const picker=ref('')
|
||
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 form=reactive<any>({mode:'generate',engine:'flux',imageStyles:{positive:[],negative:[]},lockFace:true,lockOutfit:true,hideInput:xaigen,folderId:'',identityStillId:'',imageAId:'',imageBId:'',maskId:'',startClipId:'',endStillId:'',guides:[],promptSections:sectionsFrom({}),settings:{aspect:'auto',fps:24,steps:24,cfg:4,seed:null,seedMode:'random',duration:5,turbo:true,loraStack:[]}})
|
||
const music=reactive({tags:DEFAULT_MUSIC_TAGS,lyrics:DEFAULT_MUSIC_LYRICS,duration:60,seed:'',name:''})
|
||
const tagsPlaceholder=DEFAULT_MUSIC_TAGS,lyricsPlaceholder=DEFAULT_MUSIC_LYRICS
|
||
let loraMemory=createLoraMemory(xaigen)
|
||
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 video=computed(()=>['video','extend'].includes(form.mode))
|
||
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 compiled=computed(()=>compilePrompt(form.promptSections,video.value,form))
|
||
const stillUrl=(id:string)=>`/api/library/stills/${id}`
|
||
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 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 sourceAsset=computed(()=>form.startClipId?clips.value.find((c:any)=>c.id===form.startClipId):stills.value.find((s:any)=>s.id===(form.imageAId||form.identityStillId)))
|
||
const sourceSize=computed(()=>measured[sourceAsset.value?.id] || sourceAsset.value)
|
||
const resolvedSize=computed(()=>resolveSize(form.settings.aspect,sourceSize.value))
|
||
|
||
function isHidden(asset:any){
|
||
if(!xaigen || !asset?.id)return false
|
||
if(isClassic.value){
|
||
const input=asset.role==='input'
|
||
return input?classicHideInput.value:classicHideOutput.value
|
||
}
|
||
return previewHidden(asset,revealed.value,xaigen)
|
||
}
|
||
function slotHidden(id:string){
|
||
if(!xaigen || !id)return false
|
||
if(isClassic.value)return classicHideInput.value
|
||
return !revealed.value.has(id)
|
||
}
|
||
function toggleReveal(id:string){
|
||
if(!id)return
|
||
const next=new Set(revealed.value)
|
||
if(next.has(id))next.delete(id);else next.add(id)
|
||
revealed.value=next
|
||
if(import.meta.client)localStorage.setItem(`studio2-revealed:${form.folderId}`,JSON.stringify([...next]))
|
||
}
|
||
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)})
|
||
|
||
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}
|
||
}
|
||
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){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)}
|
||
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.'}}
|
||
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 form[key]=asset?.id||'';picker.value=''}
|
||
async function clearFinished(){await $fetch('/api/studio-2/clear-finished',{method:'POST'});await refresh()}
|
||
async function deleteOutput(){
|
||
if(!selected.value)return
|
||
try{
|
||
const kind=selected.value.kind==='clip'?'clips/':selected.value.kind==='track'?'tracks/':'stills/'
|
||
await $fetch('/api/library/'+kind+selected.value.id,{method:'DELETE'})
|
||
await refresh()
|
||
}catch(e:any){error.value=e.data?.statusMessage||e.message}
|
||
}
|
||
async function queue(){
|
||
submitting.value=true;error.value=''
|
||
try{
|
||
if(form.mode==='music'){
|
||
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}})
|
||
await refresh()
|
||
return
|
||
}
|
||
if(form.settings.seedMode!=='locked')form.settings.seed=randomSeed()
|
||
const queued=await $fetch<any>('/api/studio-2/jobs',{method:'POST',body:{...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):[]}})
|
||
form.settings.seed=queued.seed;form.settings.width=queued.width;form.settings.height=queued.height
|
||
await refresh()
|
||
}catch(e:any){error.value=e.data?.statusMessage||e.message}
|
||
finally{submitting.value=false}
|
||
}
|
||
async function cancel(job:any){await $fetch('/api/studio-2/cancel',{method:'POST',body:{id:job.id}});await refresh()}
|
||
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;Object.assign(form,applyUploadedStill(form,response,target));measured[response.still.id]={width:response.still.width,height:response.still.height};uploadMessage.value=`Loaded ${response.still.name || file.name} as ${target==='hero'?'hero identity':'start still'}.`}
|
||
catch(e:any){error.value=e.data?.statusMessage||e.message}
|
||
finally{uploading.value=false;input.value=''}
|
||
}
|
||
|
||
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]
|
||
if(newestTrack && !seenOutputs.has(newestTrack.id)){seenOutputs.add(newestTrack.id);selected.value={...newestTrack,kind:'track'};return}
|
||
selected.value=existingFresh || reconcileSelection(selected.value,family.value)
|
||
}
|
||
|
||
watch(()=>form.folderId,id=>{selected.value=null;form.imageAId='';form.startClipId='';loadRevealed(id);if(import.meta.client)form.identityStillId=localStorage.getItem(`studio2-hero:${id}`)||''},{flush:'sync'})
|
||
watch(()=>form.identityStillId,id=>{if(import.meta.client&&form.folderId)localStorage.setItem(`studio2-hero:${form.folderId}`,id)})
|
||
let timer:ReturnType<typeof setTimeout>,clock:ReturnType<typeof setInterval>,disposed=false
|
||
onMounted(async()=>{
|
||
let storage;try{storage=localStorage}catch{}
|
||
loraMemory=createLoraMemory(xaigen,storage);loraMemory.sync(form);form.settings.seed=randomSeed()
|
||
clock=setInterval(()=>{now.value=Date.now()},1000);void loadLoras(true)
|
||
library.value=await $fetch('/api/studio-2/library');form.folderId=library.value.folders[0]?.id||'';loadRevealed(form.folderId)
|
||
const id=String(route.query.still||route.query.clip||route.query.track||'')
|
||
const asset=[...library.value.stills,...library.value.clips,...(library.value.tracks||[])].find((a:any)=>a.id===id)
|
||
if(asset){selected.value={...asset,kind:route.query.clip?'clip':route.query.track?'track':'still'};if(route.query.use==='1'&&selected.value.kind!=='track')restore(selected.value);if(xaigen)toggleReveal(asset.id)}
|
||
if(route.query.task==='music')form.mode='music'
|
||
const poll=async()=>{try{await refresh();const health=await $fetch<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()
|
||
})
|
||
onBeforeUnmount(()=>{disposed=true;clearTimeout(timer);clearInterval(clock)})
|
||
</script>
|