8098 lines
341 KiB
Vue
8098 lines
341 KiB
Vue
<template>
|
||
<div class="min-h-dvh min-w-0">
|
||
<AppHeader
|
||
:subtitle="ltxEnabled ? 'MiniMax H3 · LTX-2.3 · Flux.2 Klein' : 'MiniMax H3 · Flux.2 Klein'"
|
||
current="studio"
|
||
:queue-count="queueBadgeCount"
|
||
@gpu="pollComfyHealth"
|
||
/>
|
||
|
||
<main class="mx-auto min-w-0 max-w-7xl space-y-6 px-4 py-6 sm:px-6">
|
||
<StudioKindCards
|
||
:current="imageStudio ? 'image' : 'video'"
|
||
:ltx-enabled="ltxEnabled"
|
||
:video-engine="videoEngine"
|
||
:video-start="videoStart"
|
||
:image-engine="v2Engine"
|
||
@select="selectStudioKind"
|
||
@video-engine="selectVideoEngine"
|
||
@image-engine="setV2Engine"
|
||
/>
|
||
<div class="grid min-w-0 gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:items-stretch">
|
||
<section id="studio-input" class="glass min-w-0 max-w-full space-y-5 rounded-3xl p-5 sm:p-6">
|
||
<div>
|
||
<h2 class="font-display text-2xl font-bold">Input</h2>
|
||
<p class="text-sm text-zinc-400">{{ studioMode === 'editv2'
|
||
? (v2Mode === 'generate'
|
||
? 'Text-to-image: describe what you want. Add a starting image to switch to image-to-image.'
|
||
: v2Mode === 'iterate'
|
||
? (file
|
||
? (editRefFile
|
||
? 'Iterate: each prompt runs as Compose from stills A and B (or chain from the last output). All results nest in one library collection.'
|
||
: 'Iterate: each prompt runs from still A (batch) or from the last output (chain). All results nest in one library collection.')
|
||
: 'Iterate: each prompt is a separate generate. All results nest in one library collection like video extensions.')
|
||
: v2Mode === 'refine'
|
||
? 'Refine: paint a mask on Still A. Prompt only what should change in the painted area. Strength is denoise in that region.'
|
||
: v2Mode === 'compose'
|
||
? 'Compose: still A is the person/body. Still B is the face or outfit. Two stills required — no silent one-image fallback.'
|
||
: 'Edit: one still plus text. Compose is a separate graph if you need a second still.')
|
||
: studioMode === 'edit'
|
||
? 'Drop the still to edit. An optional second still applies image 2 onto image 1. Files leave the desktop after save.'
|
||
: (textToVideo
|
||
? 'Text-to-video: write a prompt and generate. No start still required. Shot 2+ still continues from the last frame.'
|
||
: 'Describe the motion and generate. Add a starting image for image-to-video; remove it for text-to-video.') }}</p>
|
||
</div>
|
||
|
||
<div
|
||
v-if="!(studioMode === 'editv2' && (v2Mode === 'generate' || (v2Mode === 'iterate' && !file && !inputStillId))) && !(studioMode === 'video' && textToVideo && !file && !inputStillId)"
|
||
class="relative cursor-pointer rounded-2xl border border-dashed border-white/15 bg-zinc-950/50 p-4 transition hover:border-amber-300/50"
|
||
:class="{ 'border-amber-300/70 bg-amber-400/5': dragging }"
|
||
@dragover.prevent="dragging = true"
|
||
@dragleave.prevent="dragging = false"
|
||
@drop.prevent="onDrop"
|
||
@click="openPicker('main')"
|
||
>
|
||
<div v-if="studioMode === 'editv2' && v2Mode === 'refine' && preview" class="relative" @click.stop>
|
||
<ImageV2MaskPainter
|
||
ref="refinePainter"
|
||
:src="preview"
|
||
:hide-preview="hideInputPreview"
|
||
@painted="refineMaskDirty = $event"
|
||
/>
|
||
<button type="button" class="absolute right-3 top-3 z-10 rounded-full bg-zinc-950/80 px-3 py-1 text-xs" @click.stop="resetImage">Reset</button>
|
||
</div>
|
||
<div v-else-if="preview && !hideInputPreview" class="relative">
|
||
<img :src="preview" alt="Source still" class="max-h-72 w-full rounded-xl object-contain bg-black/40">
|
||
<button type="button" class="absolute right-3 top-3 rounded-full bg-zinc-950/80 px-3 py-1 text-xs" @click.stop="resetImage">Reset</button>
|
||
</div>
|
||
<div v-else-if="preview && hideInputPreview" class="relative flex min-h-44 flex-col items-center justify-center text-center">
|
||
<span class="sensitive-glow absolute inset-3 rounded-xl" />
|
||
<p class="relative font-medium">Still loaded · preview hidden</p>
|
||
<p class="relative mt-1 text-sm text-zinc-500">Toggle Hide input still off to show it</p>
|
||
<button type="button" class="relative mt-3 rounded-full bg-zinc-950/80 px-3 py-1 text-xs" @click.stop="resetImage">Reset</button>
|
||
</div>
|
||
<div v-else class="flex min-h-44 flex-col items-center justify-center text-center">
|
||
<p class="font-medium">{{ studioMode === 'edit' || studioMode === 'editv2' ? (studioMode === 'editv2' ? (v2Mode === 'refine' ? 'Still A · canvas' : 'Still A') : 'Image 1') : (textToVideo ? 'Start still · optional' : 'Choose an image') }}</p>
|
||
<p class="mt-1 text-sm text-zinc-500">{{ textToVideo ? 'Text-to-video does not need a still. Shot 2+ will still extend from the last frame.' : 'Browse the library or upload a new PNG, JPG, or WEBP' }}</p>
|
||
</div>
|
||
</div>
|
||
<div
|
||
v-else-if="studioMode === 'video' && textToVideo"
|
||
class="rounded-2xl border border-dashed border-white/15 bg-zinc-950/40 px-4 py-3 text-sm text-zinc-400"
|
||
>
|
||
<button type="button" class="font-medium text-amber-100 hover:underline" @click="openPicker('main')">Optional · add a start still</button>
|
||
<span class="mt-1 block text-zinc-500">Text-to-video does not need one. Shot 2+ still extends from the last frame.</span>
|
||
</div>
|
||
<div
|
||
v-else-if="studioMode === 'editv2' && v2Mode === 'generate'"
|
||
class="rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3 text-sm text-zinc-400"
|
||
>
|
||
<button type="button" class="font-medium text-amber-100 hover:underline" @click="openPicker('main')">Optional · add a starting image</button>
|
||
<span class="mt-1 block text-zinc-400">No image: text-to-image. Add an image to edit it instead.</span>
|
||
</div>
|
||
<div
|
||
v-else-if="studioMode === 'editv2' && v2Mode === 'iterate'"
|
||
class="rounded-2xl border border-dashed border-white/15 bg-zinc-950/40 px-4 py-3 text-sm text-zinc-400"
|
||
>
|
||
<button type="button" class="font-medium text-amber-100 hover:underline" @click="openPicker('main')">Optional · add still A</button>
|
||
<span class="mt-1 block text-zinc-500">No still = text generate iterations. With a still = edit (or compose if you add still B).</span>
|
||
</div>
|
||
|
||
<div v-if="file || inputStillId" class="mt-2 flex min-w-0 items-center gap-2">
|
||
<input
|
||
v-if="renamingInputStill"
|
||
ref="inputStillRenameEl"
|
||
v-model="inputStillNameDraft"
|
||
class="min-w-0 flex-1 rounded-xl border border-amber-300/40 bg-zinc-950 px-3 py-1.5 text-sm outline-none ring-amber-300/40 focus:ring-2"
|
||
maxlength="80"
|
||
aria-label="Input still name"
|
||
@keydown.enter.prevent="saveInputStillName"
|
||
@keydown.escape.prevent="cancelInputStillRename"
|
||
@click.stop
|
||
@blur="saveInputStillName"
|
||
>
|
||
<p v-else class="min-w-0 flex-1 truncate text-sm text-zinc-300">{{ inputStillLabel }}</p>
|
||
<button
|
||
v-if="!renamingInputStill"
|
||
type="button"
|
||
class="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-amber-200 hover:bg-white/5 hover:text-amber-100"
|
||
title="Rename still"
|
||
aria-label="Rename still"
|
||
@click.stop="startInputStillRename"
|
||
>
|
||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-4 w-4">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931z" />
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 7.125L16.862 4.487" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
<div v-if="studioMode === 'editv2'" class="flex flex-wrap gap-2">
|
||
<button
|
||
type="button"
|
||
class="rounded-full border px-3 py-1 text-xs font-medium disabled:cursor-not-allowed disabled:opacity-40"
|
||
:class="(v2Mode === 'edit' || v2Mode === 'generate') ? 'border-amber-300/70 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400 hover:text-white'"
|
||
:disabled="v2ModeLocked && v2Mode !== 'edit' && v2Mode !== 'generate'"
|
||
:title="v2ModeLocked && v2Mode !== 'edit' && v2Mode !== 'generate' ? 'Remove extra passes to change graphs' : 'Uses your image when one is provided; otherwise generates from text'"
|
||
@click="setV2Mode('edit')"
|
||
>
|
||
Image
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="rounded-full border px-3 py-1 text-xs font-medium disabled:cursor-not-allowed disabled:opacity-40"
|
||
:class="v2Mode === 'compose' ? 'border-amber-300/70 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400 hover:text-white'"
|
||
:disabled="v2ModeLocked && v2Mode !== 'compose'"
|
||
:title="v2ModeLocked && v2Mode !== 'compose' ? 'Remove extra passes to change graphs' : ''"
|
||
@click="setV2Mode('compose')"
|
||
>
|
||
Compose
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="rounded-full border px-3 py-1 text-xs font-medium disabled:cursor-not-allowed disabled:opacity-40"
|
||
:class="v2Mode === 'refine' ? 'border-amber-300/70 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400 hover:text-white'"
|
||
:disabled="v2ModeLocked && v2Mode !== 'refine'"
|
||
:title="v2ModeLocked && v2Mode !== 'refine' ? 'Remove extra passes to change graphs' : ''"
|
||
@click="setV2Mode('refine')"
|
||
>
|
||
Refine
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="rounded-full border px-3 py-1 text-xs font-medium disabled:cursor-not-allowed disabled:opacity-40"
|
||
:class="v2Mode === 'iterate' ? 'border-amber-300/70 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400 hover:text-white'"
|
||
:disabled="v2ModeLocked && v2Mode !== 'iterate'"
|
||
:title="v2ModeLocked && v2Mode !== 'iterate' ? 'Remove extra passes to change graphs' : ''"
|
||
@click="setV2Mode('iterate')"
|
||
>
|
||
Iterate
|
||
</button>
|
||
<label v-if="v2Mode === 'compose' || (v2Mode === 'iterate' && editRefFile)" class="ml-auto text-xs">
|
||
<span class="mr-2 text-zinc-500">Task</span>
|
||
<select
|
||
v-model="v2Task"
|
||
class="rounded-lg border border-white/10 bg-zinc-950 px-2 py-1 text-xs text-zinc-300 outline-none ring-amber-300/40 focus:ring-2"
|
||
>
|
||
<option value="scene">scene</option>
|
||
<option value="identity">identity</option>
|
||
<option value="outfit">outfit</option>
|
||
<option value="face_lock">face_lock</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
|
||
<div v-if="studioMode === 'edit' || (studioMode === 'editv2' && (v2Mode === 'compose' || (v2Mode === 'iterate' && file)))" class="rounded-2xl border border-dashed border-white/15 bg-zinc-950/50 p-4">
|
||
<div class="mb-2 flex items-center justify-between gap-2">
|
||
<p class="text-sm font-medium text-zinc-300">{{ studioMode === 'editv2' && v2Mode === 'compose' ? 'Still B · required' : studioMode === 'editv2' ? 'Still B · optional' : 'Image 2 · optional' }}</p>
|
||
<button
|
||
v-if="editRefFile"
|
||
type="button"
|
||
class="text-xs text-zinc-400 hover:text-white"
|
||
@click="clearEditRef"
|
||
>
|
||
Remove
|
||
</button>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
class="relative block w-full overflow-hidden rounded-xl border border-white/10 bg-zinc-950/60 text-left"
|
||
@click="openPicker('editRef')"
|
||
>
|
||
<span
|
||
v-if="editRefPreview && hideInputPreview"
|
||
class="relative flex min-h-28 items-center justify-center px-3 py-6 text-center text-sm text-zinc-300"
|
||
>
|
||
<span class="sensitive-glow absolute inset-1 rounded-lg" />
|
||
<span class="relative">Image 2 loaded · preview hidden</span>
|
||
</span>
|
||
<img
|
||
v-else-if="editRefPreview"
|
||
:src="editRefPreview"
|
||
alt="Reference still"
|
||
class="max-h-40 w-full object-contain bg-black/40"
|
||
>
|
||
<span v-else class="flex min-h-28 items-center justify-center px-3 py-6 text-center text-sm text-zinc-500">
|
||
{{ studioMode === 'editv2' ? stillBHint : 'Apply this still onto image 1' }}
|
||
</span>
|
||
</button>
|
||
<p class="mt-2 text-xs text-zinc-500">
|
||
{{ studioMode === 'editv2'
|
||
? (editRefFile ? stillBHint : 'Compose will reject this job until still B is loaded.')
|
||
: (editRefFile
|
||
? 'Two-image branch on. Image 2 is applied onto image 1.'
|
||
: 'One-image branch on. Add a second still to transfer details from image 2.') }}
|
||
</p>
|
||
</div>
|
||
|
||
<div>
|
||
<div class="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||
<label class="text-sm font-medium text-zinc-300">{{ studioMode === 'editv2' && v2Mode === 'generate' ? 'Generate prompt' : studioMode === 'editv2' && v2Mode === 'iterate' ? 'Iteration 1' : studioMode === 'editv2' && v2Mode === 'refine' ? 'Refine prompt' : studioMode === 'edit' || studioMode === 'editv2' ? 'Edit prompt' : 'Motion & scene prompt' }}</label>
|
||
<div class="flex flex-wrap items-center gap-3">
|
||
<button
|
||
v-if="identityPrompting"
|
||
type="button"
|
||
class="text-xs text-amber-200/80 hover:text-amber-100"
|
||
@click="insertIdentityTemplate"
|
||
>
|
||
Insert shot template
|
||
</button>
|
||
<select
|
||
v-model="recommendPresetId"
|
||
class="max-w-[10rem] rounded-lg border border-white/10 bg-zinc-950 px-2 py-1 text-xs text-zinc-300 outline-none ring-amber-300/40 focus:ring-2"
|
||
:disabled="recommendBusy"
|
||
:title="recommendHint"
|
||
>
|
||
<option v-for="item in visiblePromptPresets" :key="item.id" :value="item.id">{{ item.name }}</option>
|
||
</select>
|
||
<button
|
||
type="button"
|
||
class="rounded-full border px-3 py-1 text-xs font-medium disabled:cursor-not-allowed disabled:opacity-50"
|
||
:class="recommendBusy ? 'border-amber-300/40 text-amber-100' : 'border-amber-300/40 text-amber-100 hover:bg-amber-400/10'"
|
||
:disabled="recommendDisabled"
|
||
:title="recommendHint"
|
||
@click="recommendPrompt"
|
||
>
|
||
{{ recommendBusy ? 'Recommending…' : 'Recommend' }}
|
||
</button>
|
||
<label v-if="studioMode === 'video'" class="flex cursor-pointer items-center gap-2 text-xs text-zinc-400">
|
||
<span class="relative inline-flex h-5 w-9 shrink-0 items-center">
|
||
<input v-model="shotScriptMode" type="checkbox" class="peer sr-only" role="switch">
|
||
<span class="absolute inset-0 rounded-full bg-zinc-700 transition peer-checked:bg-amber-400" />
|
||
<span class="absolute left-0.5 top-0.5 h-4 w-4 rounded-full bg-white transition peer-checked:translate-x-4" />
|
||
</span>
|
||
Shot script
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<DirectorScriptBar
|
||
v-if="studioMode === 'video'"
|
||
class="mb-2"
|
||
:scripts="directorScripts"
|
||
v-model:selected-id="directorScriptId"
|
||
v-model:name="directorScriptName"
|
||
@load="loadDirectorScript"
|
||
@save="saveDirectorScript"
|
||
@remove="deleteDirectorScript"
|
||
/>
|
||
<p v-if="shotScriptMode && studioMode === 'video'" class="mb-2 text-xs text-zinc-500">
|
||
Paste a block, or load a saved Director script. Text before the first marker is shot 1. Put <span class="font-medium text-zinc-300">shot 2</span>, <span class="font-medium text-zinc-300">shot 3</span> on their own lines to queue extensions — a line like <span class="font-medium text-zinc-300">Shot Type</span> will not start a new shot. Queued shots use the duration slider; Extend copies the length of the clip you are extending. Global locks are inherited; each shot should add one beat.
|
||
<span v-if="identityPrompting"> Character stills ride along as extra pictures. Picture 1 is the current frame (last frame after shot 1), not the original start still.</span>
|
||
</p>
|
||
<p v-else-if="identityPrompting" class="mb-2 text-xs text-zinc-500">
|
||
Write camera, action, and audio. Picture 1 is the current frame. Extra stills lock face and body only — they do not reset clothes. MiniMax still cannot hard-lock a last frame and character refs in one node, so this path is Reference to Video with the last frame as Picture 1.
|
||
</p>
|
||
<div
|
||
class="grid gap-3"
|
||
:class="studioMode === 'edit' || studioMode === 'editv2' ? 'md:grid-cols-2' : ''"
|
||
>
|
||
<div class="min-w-0">
|
||
<PromptPartsEditor
|
||
v-model:pre="promptPre"
|
||
v-model:prompt="prompt"
|
||
v-model:post="promptPost"
|
||
:prompt-placeholder="promptPlaceholder"
|
||
:prompt-rows="shotScriptMode || identityPrompting ? 12 : 6"
|
||
:keep-items="activeKeepPrompts"
|
||
:keep-heading="keepPromptHeading"
|
||
/>
|
||
</div>
|
||
<label v-if="studioMode === 'edit' || studioMode === 'editv2'" class="block min-w-0 text-sm">
|
||
<span class="mb-2 block font-medium text-zinc-300">Negative prompt</span>
|
||
<textarea
|
||
v-if="studioMode === 'editv2'"
|
||
v-model="v2Negative"
|
||
rows="2"
|
||
class="min-h-[4.5rem] w-full rounded-2xl border border-white/10 bg-zinc-950/70 px-4 py-3 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 [overflow-wrap:anywhere] focus:ring-2 md:h-[calc(100%-1.75rem)] md:min-h-[8.5rem]"
|
||
placeholder="What to avoid…"
|
||
/>
|
||
<textarea
|
||
v-else
|
||
v-model="editNegative"
|
||
rows="2"
|
||
class="min-h-[4.5rem] w-full rounded-2xl border border-white/10 bg-zinc-950/70 px-4 py-3 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 [overflow-wrap:anywhere] focus:ring-2 md:h-[calc(100%-1.75rem)] md:min-h-[8.5rem]"
|
||
placeholder="What to avoid…"
|
||
/>
|
||
</label>
|
||
</div>
|
||
<p v-if="studioMode === 'editv2' && v2Mode === 'generate'" class="mt-3 text-xs text-zinc-500">No reference image. Prompt only.</p>
|
||
<p v-else-if="studioMode === 'editv2' && v2Mode === 'iterate'" class="mt-3 text-xs text-zinc-500">Use one prompt with different steps, CFG, seeds, and LoRAs below. Everything saves as one library collection.</p>
|
||
<div v-if="studioMode === 'editv2' && v2Mode === 'refine'" class="mt-3 space-y-2">
|
||
<p class="text-xs text-zinc-500">Prompt only what should change in the painted area.</p>
|
||
<div class="flex flex-wrap gap-2">
|
||
<button type="button" class="rounded-full border border-white/10 px-2.5 py-1 text-xs text-zinc-400 hover:text-white" @click="prompt = IMAGE_V2_REFINE_FACE_PROMPT">
|
||
Face prompt
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<p v-if="shotScriptMode && studioMode === 'video' && parsedShots.length" class="mt-2 text-xs text-zinc-500 md:hidden">
|
||
{{ parsedShots.length }} shot{{ parsedShots.length === 1 ? '' : 's' }} in this script. Open the prompt to scroll through them.
|
||
</p>
|
||
<div v-if="recommendBusy || recommendText || recommendError" class="mt-3 max-h-64 overflow-y-auto rounded-2xl border border-amber-300/20 bg-amber-400/5 p-4">
|
||
<p class="text-xs font-medium uppercase tracking-wide text-amber-200/80">Recommended prompt</p>
|
||
<p v-if="recommendBusy" class="mt-2 text-sm text-zinc-400">{{ recommendMessage || 'Beast Qwen VL is reading the still…' }}</p>
|
||
<p v-else-if="recommendError" class="mt-2 text-sm text-red-300">{{ recommendError }}</p>
|
||
<div v-else-if="recommendedShots.length > 1" class="mt-2 space-y-2">
|
||
<div
|
||
v-for="shot in recommendedShots"
|
||
:key="shot.n"
|
||
class="rounded-xl border border-amber-300/15 bg-zinc-950/50 px-3 py-2"
|
||
>
|
||
<p class="text-[11px] font-medium uppercase tracking-wide text-amber-200/80">shot {{ shot.n }}</p>
|
||
<p class="mt-1 whitespace-pre-wrap text-sm text-zinc-200 [overflow-wrap:anywhere]">{{ shot.prompt }}</p>
|
||
</div>
|
||
</div>
|
||
<p v-else class="mt-2 whitespace-pre-wrap text-sm text-zinc-200 [overflow-wrap:anywhere]">{{ recommendText }}</p>
|
||
<div v-if="recommendText && !recommendBusy" class="mt-3 flex flex-wrap gap-2">
|
||
<button
|
||
type="button"
|
||
class="rounded-xl bg-amber-400 px-3 py-1.5 text-xs font-semibold text-zinc-950 disabled:opacity-50"
|
||
:disabled="recommendApplied"
|
||
@click="applyRecommend"
|
||
>
|
||
{{ recommendApplied ? 'Applied' : 'Apply to prompt' }}
|
||
</button>
|
||
<button
|
||
v-if="recommendApplied"
|
||
type="button"
|
||
class="rounded-xl border border-white/15 px-3 py-1.5 text-xs text-zinc-300 hover:border-amber-300/40"
|
||
@click="revertRecommend"
|
||
>
|
||
Revert
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="rounded-xl border border-white/15 px-3 py-1.5 text-xs text-zinc-400 hover:text-white"
|
||
@click="dismissRecommend"
|
||
>
|
||
Keep mine
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div v-if="studioMode === 'edit' || v2PassChaining" class="mt-3 space-y-3">
|
||
<ImageIterationSweep v-if="studioMode === 'editv2' && v2Mode === 'iterate'" @apply="applyIterationSweep" />
|
||
<div v-if="!(v2Mode === 'iterate' && !file)" class="grid grid-cols-2 gap-2">
|
||
<button
|
||
type="button"
|
||
class="rounded-xl border px-3 py-2 text-left text-sm transition"
|
||
:class="imagePassMode === 'batch' ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 hover:border-white/20'"
|
||
@click="imagePassMode = 'batch'"
|
||
>
|
||
<span class="block font-semibold">{{ v2Mode === 'iterate' ? 'From original' : 'Batch' }}</span>
|
||
<span class="text-[11px] leading-snug text-zinc-400">Each prompt from the start still</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="rounded-xl border px-3 py-2 text-left text-sm transition"
|
||
:class="imagePassMode === 'chain' ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 hover:border-white/20'"
|
||
@click="imagePassMode = 'chain'"
|
||
>
|
||
<span class="block font-semibold">{{ v2Mode === 'iterate' ? 'From last' : 'Chain' }}</span>
|
||
<span class="text-[11px] leading-snug text-zinc-400">Each pass from the last output</span>
|
||
</button>
|
||
</div>
|
||
<button
|
||
v-if="imagePassMode === 'batch'"
|
||
type="button"
|
||
class="w-full scroll-mt-24 rounded-2xl border border-dashed px-4 py-2.5 text-left text-sm transition"
|
||
:class="imageBatchScript.trim()
|
||
? 'border-amber-300/40 bg-amber-400/5 text-amber-100 hover:border-amber-300'
|
||
: 'border-white/15 text-zinc-300 hover:border-amber-300/40 hover:text-amber-100'"
|
||
@click.stop.prevent="openImageBatchModal"
|
||
>
|
||
<span class="block font-semibold">{{ imageBatchScript.trim() ? 'Edit batch script' : 'Paste batch script' }}</span>
|
||
<span class="text-[11px] leading-snug text-zinc-400">{{ imageBatchButtonHint }}</span>
|
||
</button>
|
||
<div
|
||
v-for="(item, index) in editPassQueue"
|
||
:key="item.id"
|
||
class="rounded-2xl border border-white/10 bg-zinc-950/50 p-4"
|
||
>
|
||
<div class="mb-3 flex items-center justify-between gap-2">
|
||
<p class="text-sm font-medium text-zinc-200">{{ v2Mode === 'iterate' ? 'Iteration' : imagePassMode === 'batch' ? 'Prompt' : 'Pass' }} {{ index + 2 }}</p>
|
||
<button
|
||
type="button"
|
||
class="text-xs text-red-300 hover:text-red-200"
|
||
@click="removeEditPass(item.id)"
|
||
>
|
||
Remove
|
||
</button>
|
||
</div>
|
||
<PromptField
|
||
v-model="item.prompt"
|
||
:rows="3"
|
||
:title="`${imagePassMode === 'batch' ? 'Prompt' : 'Pass'} ${index + 2}`"
|
||
:placeholder="extraPassPlaceholder"
|
||
:keep-items="activeKeepPrompts"
|
||
:keep-heading="keepPromptHeading"
|
||
/>
|
||
<ImageIterationSettings
|
||
v-if="studioMode === 'editv2' && v2Mode === 'iterate'"
|
||
:model-value="item"
|
||
:base-steps="v2Steps"
|
||
:base-cfg="v2Cfg"
|
||
:lora-names="imageV2LoraOptions"
|
||
:engine="v2Engine"
|
||
:allow-strength="v2Engine === 'krea' && Boolean(file)"
|
||
@update:model-value="editPassQueue[index] = { ...$event, id: item.id }"
|
||
/>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
class="w-full rounded-2xl border border-dashed border-white/15 px-4 py-2.5 text-sm text-zinc-300 transition hover:border-amber-300/40 hover:text-amber-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||
@click="queueEditPass"
|
||
>
|
||
{{ extraPassButtonLabel }}
|
||
</button>
|
||
<p class="text-xs text-zinc-500">{{ extraPassHint }}</p>
|
||
</div>
|
||
<details v-if="identityPrompting && prompt.trim()" class="mt-2 rounded-xl border border-white/10 bg-zinc-950/40 px-3 py-2">
|
||
<summary class="cursor-pointer text-xs text-zinc-400">Prompt sent to Reference to Video</summary>
|
||
<p class="mt-2 text-[10px] uppercase tracking-wide text-zinc-500">Identity prefix</p>
|
||
<p class="mt-1 whitespace-pre-wrap text-xs text-zinc-400 [overflow-wrap:anywhere]">{{ identityBoilerplate(extraIdentityPictures) }}</p>
|
||
<p class="mt-3 text-[10px] uppercase tracking-wide text-zinc-500">Dynamic action prompt</p>
|
||
<p class="mt-1 whitespace-pre-wrap text-xs text-zinc-300 [overflow-wrap:anywhere]">{{ identityActionPrompt }}</p>
|
||
<p class="mt-3 text-[10px] uppercase tracking-wide text-zinc-500">Concatenated result</p>
|
||
<p class="mt-1 whitespace-pre-wrap text-xs text-zinc-300 [overflow-wrap:anywhere]">{{ composedIdentityPrompt }}</p>
|
||
</details>
|
||
<details v-if="studioMode === 'video'" class="mt-3 rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3">
|
||
<summary class="cursor-pointer text-sm font-medium text-zinc-200">
|
||
Continuity
|
||
<span class="ml-2 font-normal text-xs text-zinc-500">Locks and named stills. Last-frame I2V carries the picture.</span>
|
||
</summary>
|
||
<div class="mt-3 space-y-3">
|
||
<GlobalLocksEditor v-model="globalLocks" embedded />
|
||
<div class="rounded-xl border border-white/10 bg-zinc-950/40 px-3 py-2.5">
|
||
<PermanenceRefStrip
|
||
:refs="familyPermanenceRefs"
|
||
:name-options="lockBindingKeys"
|
||
label="Named stills · all shots"
|
||
hint="Name + still"
|
||
cta="Add a named still"
|
||
add-title="Choose a still to name for every shot"
|
||
:hidden="hideInputPreview"
|
||
@add-ref="openPermanencePicker()"
|
||
@remove="removeFamilyRef"
|
||
@update-label="updateFamilyRefLabel"
|
||
/>
|
||
<p class="mt-2 text-[11px] leading-relaxed text-zinc-500">
|
||
MiniMax does not see these photos. Shot 2+ starts from the previous clip’s last frame. Names only go in the lock line.
|
||
</p>
|
||
<p class="mt-1 text-[11px] leading-relaxed text-zinc-500">{{ permanenceComfyNote }}</p>
|
||
<p v-if="familyRefLabelLine" class="mt-1 text-[11px] text-amber-200/80">{{ familyRefLabelLine }}</p>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
<div v-if="shotScriptMode && studioMode === 'video'" class="mt-3 space-y-2">
|
||
<p v-if="!parsedShots.length" class="text-xs text-zinc-500">Add a first shot, then <span class="text-zinc-300">shot 2</span> for an extension.</p>
|
||
<div
|
||
v-for="(shot, index) in parsedShots"
|
||
:key="shot.n + '-' + index"
|
||
class="rounded-xl border border-white/10 bg-zinc-950/50 px-3 py-2"
|
||
>
|
||
<p class="text-xs font-medium text-amber-100">
|
||
{{ index === 0 ? 'Initial' : `Extension ${index}` }}
|
||
<span class="font-normal text-zinc-500"> · shot {{ shot.n }} · {{ formatClipDuration(duration) }}</span>
|
||
</p>
|
||
<p class="mt-1 whitespace-pre-wrap text-xs text-zinc-300 [overflow-wrap:anywhere]">{{ composedShotPreview(shot.prompt, index, shot.n) }}</p>
|
||
<LoraSelect
|
||
class="mt-2"
|
||
:model-value="shotLoraStacks[shot.n] || []"
|
||
:names="videoLoraOptions"
|
||
:triggers="loraTriggers"
|
||
:aliases="loraAliases"
|
||
label="LoRA"
|
||
empty-label="Default"
|
||
add-label="Add LoRA"
|
||
compact
|
||
:clip-strength="false"
|
||
@update:model-value="setShotLoraStack(shot.n, $event)"
|
||
@insert-trigger="insertLoraTrigger($event, { shot: shot.n })"
|
||
@update-trigger="saveLoraTrigger"
|
||
/>
|
||
<PermanenceRefStrip
|
||
class="mt-2"
|
||
:refs="shotPermanenceRefs[shot.n] || []"
|
||
:name-options="lockBindingKeys"
|
||
label="This shot only"
|
||
hint="Extra stills"
|
||
cta="Add still"
|
||
add-title="Choose a still to name for this shot only"
|
||
:hidden="hideInputPreview"
|
||
:max="4"
|
||
@add-ref="openPermanencePicker(shot.n)"
|
||
@remove="(refIndex) => removeShotRef(shot.n, refIndex)"
|
||
@update-label="(refIndex, label) => updateShotRefLabel(shot.n, refIndex, label)"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="studioMode === 'video' && !shotScriptMode" class="space-y-3">
|
||
<div
|
||
v-for="(item, index) in extensionQueue"
|
||
:key="item.id"
|
||
class="rounded-2xl border border-white/10 bg-zinc-950/50 p-4"
|
||
>
|
||
<div class="mb-3 flex items-center justify-between gap-2">
|
||
<p class="text-sm font-medium text-zinc-200">Extension #{{ index + 1 }}</p>
|
||
<button
|
||
type="button"
|
||
class="text-xs text-red-300 hover:text-red-200"
|
||
@click="removeQueuedExtension(item.id)"
|
||
>
|
||
Remove
|
||
</button>
|
||
</div>
|
||
<PromptPartsEditor
|
||
v-model:pre="item.promptPre"
|
||
v-model:prompt="item.prompt"
|
||
v-model:post="item.promptPost"
|
||
prompt-placeholder="Motion prompt for this extension…"
|
||
:prompt-rows="4"
|
||
:keep-items="keepPrompts.video"
|
||
keep-heading="Video prompts"
|
||
/>
|
||
<DurationField v-model="item.duration" class="mt-3" label="Duration" />
|
||
<LoraSelect
|
||
class="mt-3"
|
||
:model-value="item.loraStack || []"
|
||
:names="videoLoraOptions"
|
||
:triggers="loraTriggers"
|
||
:aliases="loraAliases"
|
||
label="LoRA"
|
||
empty-label="Default"
|
||
add-label="Add LoRA"
|
||
compact
|
||
:clip-strength="false"
|
||
@update:model-value="item.loraStack = $event"
|
||
@insert-trigger="insertLoraTrigger($event, { extensionId: item.id })"
|
||
@update-trigger="saveLoraTrigger"
|
||
/>
|
||
<PermanenceRefStrip
|
||
class="mt-3"
|
||
:refs="shotPermanenceRefs[index + 2] || []"
|
||
:name-options="lockBindingKeys"
|
||
label="This extension only"
|
||
hint="Extra stills"
|
||
cta="Add still"
|
||
add-title="Choose a still to name for this extension only"
|
||
:hidden="hideInputPreview"
|
||
:max="4"
|
||
@add-ref="openPermanencePicker(index + 2)"
|
||
@remove="(refIndex) => removeShotRef(index + 2, refIndex)"
|
||
@update-label="(refIndex, label) => updateShotRefLabel(index + 2, refIndex, label)"
|
||
/>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
class="w-full rounded-2xl border border-dashed border-white/15 px-4 py-2.5 text-sm text-zinc-300 transition hover:border-amber-300/40 hover:text-amber-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||
@click="queueExtension"
|
||
>
|
||
+ Queue Extension
|
||
</button>
|
||
</div>
|
||
|
||
<div>
|
||
<p class="mb-2 text-sm font-medium text-zinc-300">Save to library</p>
|
||
<div class="space-y-3">
|
||
<select v-model="folderId" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm">
|
||
<option v-for="folder in folders" :key="folder.id" :value="folder.id">
|
||
{{ folder.name }}{{ folder.protected ? (folder.unlocked ? ' · unlocked' : ' · locked') : '' }}
|
||
</option>
|
||
</select>
|
||
<label class="block text-sm">
|
||
<span class="mb-1 block text-zinc-400">Name</span>
|
||
<input
|
||
v-model="clipName"
|
||
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm"
|
||
:placeholder="autoOutputName"
|
||
maxlength="80"
|
||
>
|
||
<span class="mt-1 block text-[11px] text-zinc-600">Leave blank to use the auto name. Type here to override.</span>
|
||
</label>
|
||
<form v-if="currentFolder?.protected && !currentFolder.unlocked" class="space-y-2" @submit.prevent="unlockFolder(folderId)">
|
||
<p class="text-sm text-zinc-400">This folder is locked. You can still generate into it; unlock to view clips.</p>
|
||
<div class="flex gap-2">
|
||
<input v-model="unlockPassword" type="password" class="min-w-0 flex-1 rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm" placeholder="Folder password">
|
||
<button type="submit" class="rounded-xl bg-amber-400 px-3 py-2 text-sm font-semibold text-zinc-950">Unlock</button>
|
||
</div>
|
||
</form>
|
||
<label v-if="showPrivacyToggles" class="flex cursor-pointer items-center justify-between gap-3 rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3 text-sm">
|
||
<span class="font-medium text-zinc-200">Hide input still</span>
|
||
<span class="relative inline-flex h-6 w-11 shrink-0 items-center">
|
||
<input v-model="hideInputPreview" type="checkbox" class="peer sr-only" role="switch">
|
||
<span class="absolute inset-0 rounded-full bg-zinc-700 transition peer-checked:bg-amber-400" />
|
||
<span class="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition peer-checked:translate-x-5" />
|
||
</span>
|
||
</label>
|
||
<label v-if="showPrivacyToggles" class="flex cursor-pointer items-center justify-between gap-3 rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3 text-sm">
|
||
<span class="font-medium text-zinc-200">Hide output preview</span>
|
||
<span class="relative inline-flex h-6 w-11 shrink-0 items-center">
|
||
<input v-model="hideThumbnail" type="checkbox" class="peer sr-only" role="switch">
|
||
<span class="absolute inset-0 rounded-full bg-zinc-700 transition peer-checked:bg-amber-400" />
|
||
<span class="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition peer-checked:translate-x-5" />
|
||
</span>
|
||
</label>
|
||
<label v-if="studioMode === 'video' && !ltxVideo" class="flex cursor-pointer items-center justify-between gap-3 rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3 text-sm">
|
||
<span>
|
||
<span class="block font-medium text-zinc-200">Generate sound</span>
|
||
<span class="mt-0.5 block text-xs text-zinc-500">Off skips the audio VAE so Comfy encodes a silent clip.</span>
|
||
</span>
|
||
<span class="relative inline-flex h-6 w-11 shrink-0 items-center">
|
||
<input v-model="withSound" type="checkbox" class="peer sr-only" role="switch">
|
||
<span class="absolute inset-0 rounded-full bg-zinc-700 transition peer-checked:bg-amber-400" />
|
||
<span class="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition peer-checked:translate-x-5" />
|
||
</span>
|
||
</label>
|
||
<label v-if="studioMode === 'video'" class="flex cursor-pointer items-center justify-between gap-3 rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3 text-sm">
|
||
<span>
|
||
<span class="block font-medium text-zinc-200">Autoplay</span>
|
||
<span class="mt-0.5 block text-xs text-zinc-500">Off leaves finished clips paused until you press play.</span>
|
||
</span>
|
||
<span class="relative inline-flex h-6 w-11 shrink-0 items-center">
|
||
<input v-model="autoplayEnabled" type="checkbox" class="peer sr-only" role="switch">
|
||
<span class="absolute inset-0 rounded-full bg-zinc-700 transition peer-checked:bg-amber-400" />
|
||
<span class="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition peer-checked:translate-x-5" />
|
||
</span>
|
||
</label>
|
||
<p v-if="showPrivacyToggles" class="text-xs text-zinc-500">Hide input still and hide output preview are on by default. Click the player or still to reveal; click anywhere else on the page to hide it again.</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="studioMode === 'video'" class="space-y-3">
|
||
<p class="text-xs text-zinc-500">{{ videoModelHint }}</p>
|
||
<div v-if="showLegacyMinimaxV1 && videoEngine === 'minimax' && videoStart === 'still'">
|
||
<p class="mb-2 text-xs uppercase tracking-wider text-zinc-500">MiniMax graph</p>
|
||
<div class="grid grid-cols-2 gap-2">
|
||
<button
|
||
type="button"
|
||
class="rounded-xl border px-3 py-2 text-left text-sm"
|
||
:class="minimaxGraph === 'v1' ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 hover:border-white/20'"
|
||
@click="minimaxGraph = 'v1'"
|
||
>
|
||
<span class="block font-semibold">v1 · validated</span>
|
||
<span class="text-xs text-zinc-400">First-frame image to video</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="rounded-xl border px-3 py-2 text-left text-sm"
|
||
:class="minimaxGraph === 'v2' ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 hover:border-white/20'"
|
||
@click="minimaxGraph = 'v2'"
|
||
>
|
||
<span class="block font-semibold">v2 · last frame + stills</span>
|
||
<span class="text-xs text-zinc-400">Optional character stills. Last frame stays the start.</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<details v-if="videoEngine === 'minimax' && videoStart === 'still' && minimaxGraph === 'v2'" class="rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3" :open="identityRefs.some(Boolean)">
|
||
<summary class="cursor-pointer text-sm font-medium text-zinc-200">
|
||
Character stills
|
||
<span class="ml-2 font-normal text-xs text-zinc-500">Optional extra views of the same person. Off unless you need them.</span>
|
||
</summary>
|
||
<div class="mt-3 space-y-3">
|
||
<p class="text-xs text-zinc-500">Face, hair, body. Shot 2+ still starts from the last frame (clothes and pose stay). Empty slots are left out. A named extra person turns these off.</p>
|
||
<div v-if="!extraPersonLock" class="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||
<div
|
||
v-for="slot in 4"
|
||
:key="slot"
|
||
class="relative min-h-24 overflow-hidden rounded-xl border border-dashed border-white/15 bg-zinc-950/60"
|
||
>
|
||
<button
|
||
type="button"
|
||
class="relative block w-full text-left"
|
||
@click="openPicker(slot - 1)"
|
||
>
|
||
<span
|
||
v-if="identityPreviews[slot - 1] && hideInputPreview"
|
||
class="relative flex h-24 items-center justify-center px-2 text-center text-[11px] text-zinc-300"
|
||
>
|
||
<span class="sensitive-glow absolute inset-1 rounded-lg" />
|
||
<span class="relative">Picture {{ slot + 1 }} · hidden</span>
|
||
</span>
|
||
<img
|
||
v-else-if="identityPreviews[slot - 1]"
|
||
:src="identityPreviews[slot - 1]"
|
||
alt=""
|
||
class="h-24 w-full object-cover"
|
||
>
|
||
<span v-else class="flex h-24 items-center justify-center px-2 text-center text-[11px] text-zinc-500">
|
||
Picture {{ slot + 1 }}
|
||
</span>
|
||
</button>
|
||
<button
|
||
v-if="identityRefs[slot - 1]"
|
||
type="button"
|
||
class="absolute right-1 top-1 rounded-full bg-zinc-950/80 px-2 py-0.5 text-[10px] text-zinc-300"
|
||
@click="clearIdentityRef(slot - 1)"
|
||
>
|
||
Remove
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<p v-if="extraPersonLock" class="text-xs text-zinc-500">Character stills stay off while a second named person is attached. Shot 2+ starts from the last frame.</p>
|
||
<p v-else class="text-xs text-zinc-500">Picture 1 is the start still on shot 1 and the last frame after that. Extra stills are Picture 2–5. They do not put the start-still outfit back on.</p>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
|
||
<div v-if="studioMode === 'video'" class="max-w-md space-y-2">
|
||
<LoraSelect
|
||
v-model="videoLoraStack"
|
||
:names="videoLoraOptions"
|
||
:triggers="loraTriggers"
|
||
:aliases="loraAliases"
|
||
label="LoRA"
|
||
empty-label="None"
|
||
compact
|
||
:clip-strength="false"
|
||
@insert-trigger="insertLoraTrigger($event)"
|
||
@update-trigger="saveLoraTrigger"
|
||
/>
|
||
<GenerationPresetBar
|
||
:presets="videoGenerationPresets"
|
||
v-model:selected-id="videoPresetId"
|
||
v-model:name="videoPresetName"
|
||
@load="loadGenerationPreset"
|
||
@save="saveGenerationPreset"
|
||
@remove="deleteGenerationPreset"
|
||
/>
|
||
</div>
|
||
<div v-else-if="studioMode === 'editv2'" class="space-y-3">
|
||
<p class="text-xs text-zinc-500">Edit, Compose, Refine, and Generate are separate graphs. Shares the desktop GPU with video. Landscape / Portrait / Square fill new edges without stretching the still.</p>
|
||
<div class="max-w-md">
|
||
<label
|
||
v-if="v2ConceptLoraEnabled && imageV2ConceptFile"
|
||
class="mb-2 flex cursor-pointer items-center gap-2 text-xs text-zinc-400"
|
||
>
|
||
<span class="relative inline-flex h-5 w-9 shrink-0 items-center">
|
||
<input
|
||
type="checkbox"
|
||
class="peer sr-only"
|
||
role="switch"
|
||
:checked="imageV2SnofsOn"
|
||
@change="onImageV2SnofsToggle"
|
||
>
|
||
<span class="absolute inset-0 rounded-full bg-zinc-700 transition peer-checked:bg-amber-400" />
|
||
<span class="absolute left-0.5 top-0.5 h-4 w-4 rounded-full bg-white transition peer-checked:translate-x-4" />
|
||
</span>
|
||
SNOFS
|
||
</label>
|
||
<LoraSelect
|
||
v-model="imageV2LoraStack"
|
||
:names="imageV2LoraOptions"
|
||
:engine="v2Engine"
|
||
:triggers="loraTriggers"
|
||
:aliases="loraAliases"
|
||
label="LoRA"
|
||
empty-label="Add LoRA"
|
||
missing-label="Wake GPU to load LoRAs"
|
||
compact
|
||
@insert-trigger="insertLoraTrigger($event)"
|
||
@update-trigger="saveLoraTrigger"
|
||
/>
|
||
<p v-if="v2Engine === 'krea'" class="mt-1 text-[11px] text-zinc-500">Krea only lists krea LoRAs. Flux files stay on the Flux engine.</p>
|
||
</div>
|
||
<div>
|
||
<AspectRatioPick :model-value="aspect" heading="Aspect ratio" :options="imageAspects" @update:model-value="setImageAspect" />
|
||
</div>
|
||
<div v-if="v2Mode === 'refine' || (v2Engine === 'krea' && v2Mode !== 'generate')" class="space-y-2 rounded-2xl border border-white/10 bg-zinc-950/40 p-3">
|
||
<Thumbwheel
|
||
v-model="v2Denoise"
|
||
label="Strength"
|
||
lock-key="v2-denoise"
|
||
:min="IMAGE_V2_DENOISE_MIN"
|
||
:max="IMAGE_V2_DENOISE_MAX"
|
||
:step="IMAGE_V2_DENOISE_STEP"
|
||
:digits="2"
|
||
compact
|
||
/>
|
||
<div v-if="v2Mode === 'refine'" class="flex flex-wrap gap-2">
|
||
<button
|
||
type="button"
|
||
class="rounded-full border px-2.5 py-1 text-xs"
|
||
:class="v2Denoise === IMAGE_V2_REFINE_FACE.strength ? 'border-amber-300/70 text-amber-100' : 'border-white/10 text-zinc-400'"
|
||
@click="applyRefinePreset('face')"
|
||
>
|
||
Face {{ IMAGE_V2_REFINE_FACE.strength.toFixed(2) }}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="rounded-full border px-2.5 py-1 text-xs"
|
||
:class="v2Denoise === IMAGE_V2_REFINE_HAND.strength ? 'border-amber-300/70 text-amber-100' : 'border-white/10 text-zinc-400'"
|
||
@click="applyRefinePreset('hand')"
|
||
>
|
||
Hand-chest {{ IMAGE_V2_REFINE_HAND.strength.toFixed(2) }}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="rounded-full border px-2.5 py-1 text-xs"
|
||
:class="v2Denoise === IMAGE_V2_REFINE_HEAVY.strength ? 'border-amber-300/70 text-amber-100' : 'border-white/10 text-zinc-400'"
|
||
@click="applyRefinePreset('heavy')"
|
||
>
|
||
Heavy {{ IMAGE_V2_REFINE_HEAVY.strength.toFixed(2) }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="flex flex-wrap items-start gap-x-4 gap-y-3">
|
||
<Thumbwheel
|
||
v-model="v2Steps"
|
||
label="Steps"
|
||
:min="IMAGE_STEPS_MIN"
|
||
:max="IMAGE_STEPS_MAX"
|
||
:step="1"
|
||
:fine-step="1"
|
||
:coarse-step="5"
|
||
:digits="0"
|
||
compact
|
||
:disabled="v2Turbo"
|
||
/>
|
||
<Thumbwheel
|
||
v-model="v2Cfg"
|
||
label="CFG"
|
||
:min="IMAGE_CFG_MIN"
|
||
:max="IMAGE_CFG_MAX"
|
||
:step="IMAGE_CFG_STEP"
|
||
compact
|
||
:disabled="v2Turbo"
|
||
/>
|
||
<template v-if="v2Mode !== 'generate' && !(v2Mode === 'iterate' && !file)">
|
||
<LampToggle :model-value="v2ScaleOn" label="Scale to MP" @update:model-value="setV2ScaleOn" />
|
||
<Thumbwheel
|
||
v-model="v2Megapixels"
|
||
label="Megapixels"
|
||
lock-key="megapixels"
|
||
:min="IMAGE_SCALE_MP_MIN"
|
||
:max="IMAGE_SCALE_MP_MAX"
|
||
:step="IMAGE_SCALE_MP_STEP"
|
||
:digits="2"
|
||
compact
|
||
:disabled="!v2ScaleOn"
|
||
/>
|
||
</template>
|
||
</div>
|
||
<label class="block max-w-xs text-sm">
|
||
<span class="mb-1 flex items-center justify-between gap-2 text-zinc-400">
|
||
Seed
|
||
<button
|
||
v-if="inputSeed"
|
||
type="button"
|
||
class="min-w-0 truncate text-[11px] font-medium text-amber-300 hover:text-amber-100"
|
||
:title="'Fill the seed box with ' + inputSeed"
|
||
@click.prevent="applyInputSeed"
|
||
>Apply {{ inputSeed }}</button>
|
||
</span>
|
||
<input v-model="seedInput" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm" placeholder="random">
|
||
</label>
|
||
<label v-if="v2Engine !== 'krea'" class="flex cursor-pointer items-center gap-2 text-xs text-zinc-400">
|
||
<span class="relative inline-flex h-5 w-9 shrink-0 items-center">
|
||
<input v-model="v2Turbo" type="checkbox" class="peer sr-only" role="switch">
|
||
<span class="absolute inset-0 rounded-full bg-zinc-700 transition peer-checked:bg-amber-400" />
|
||
<span class="absolute left-0.5 top-0.5 h-4 w-4 rounded-full bg-white transition peer-checked:translate-x-4" />
|
||
</span>
|
||
Turbo · 8 steps, CFG 1
|
||
</label>
|
||
<div class="max-w-md">
|
||
<GenerationPresetBar
|
||
:presets="imageV2GenerationPresets"
|
||
v-model:selected-id="imageV2PresetId"
|
||
v-model:name="imageV2PresetName"
|
||
@load="loadGenerationPreset"
|
||
@save="saveGenerationPreset"
|
||
@remove="deleteGenerationPreset"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div v-else-if="studioMode === 'edit'" class="space-y-3">
|
||
<p class="text-xs text-zinc-500">Legacy Klein v1 still. New Image work uses Flux / Krea above. This graph stays for older library stills.</p>
|
||
<div>
|
||
<AspectRatioPick :model-value="aspect" heading="Aspect ratio" :options="imageAspects" @update:model-value="setImageAspect" />
|
||
</div>
|
||
<div class="max-w-md space-y-2">
|
||
<LoraSelect
|
||
v-model="imageLoraStack"
|
||
:names="imageLoraOptions"
|
||
engine="flux"
|
||
:triggers="loraTriggers"
|
||
:aliases="loraAliases"
|
||
label="LoRA"
|
||
empty-label="None"
|
||
compact
|
||
@insert-trigger="insertLoraTrigger($event)"
|
||
@update-trigger="saveLoraTrigger"
|
||
/>
|
||
<label class="flex cursor-pointer items-center gap-2 text-xs text-zinc-400">
|
||
<span class="relative inline-flex h-5 w-9 shrink-0 items-center">
|
||
<input v-model="editScaleToTotalPixels" type="checkbox" class="peer sr-only" role="switch" aria-label="Scale to 1 MP">
|
||
<span class="absolute inset-0 rounded-full bg-zinc-700 transition peer-checked:bg-amber-400" />
|
||
<span class="absolute left-0.5 top-0.5 h-4 w-4 rounded-full bg-white transition peer-checked:translate-x-4" />
|
||
</span>
|
||
<span>Scale to 1 MP</span>
|
||
<input
|
||
v-if="editScaleToTotalPixels"
|
||
v-model.number="editScaleMegapixels"
|
||
type="number"
|
||
:min="IMAGE_SCALE_MP_MIN"
|
||
:max="IMAGE_SCALE_MP_MAX"
|
||
:step="IMAGE_SCALE_MP_STEP"
|
||
class="w-16 rounded-lg border border-white/10 bg-zinc-950 px-2 py-1 text-xs outline-none ring-amber-300/40 focus:ring-2"
|
||
aria-label="Megapixels"
|
||
@change="onEditScaleMegapixels"
|
||
>
|
||
</label>
|
||
<GenerationPresetBar
|
||
:presets="imageGenerationPresets"
|
||
v-model:selected-id="imagePresetId"
|
||
v-model:name="imagePresetName"
|
||
@load="loadGenerationPreset"
|
||
@save="saveGenerationPreset"
|
||
@remove="deleteGenerationPreset"
|
||
/>
|
||
</div>
|
||
<div class="grid grid-cols-2 gap-3">
|
||
<label class="block text-sm">
|
||
<span class="mb-1 block text-zinc-400">Steps</span>
|
||
<input
|
||
v-model.number="editSteps"
|
||
type="number"
|
||
:min="IMAGE_STEPS_MIN"
|
||
:max="IMAGE_STEPS_MAX"
|
||
step="1"
|
||
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 focus:ring-2"
|
||
@change="onEditStepsNumber"
|
||
>
|
||
</label>
|
||
<label class="block text-sm">
|
||
<span class="mb-1 block text-zinc-400">CFG</span>
|
||
<input
|
||
v-model.number="editCfg"
|
||
type="number"
|
||
:min="IMAGE_CFG_MIN"
|
||
:max="IMAGE_CFG_MAX"
|
||
:step="IMAGE_CFG_STEP"
|
||
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 focus:ring-2"
|
||
@change="onEditCfgNumber"
|
||
>
|
||
</label>
|
||
<label class="col-span-2 block text-sm">
|
||
<span class="mb-1 flex items-center justify-between gap-2 text-zinc-400">
|
||
Seed
|
||
<button
|
||
v-if="inputSeed"
|
||
type="button"
|
||
class="min-w-0 truncate text-[11px] font-medium text-amber-300 hover:text-amber-100"
|
||
:title="'Fill the seed box with ' + inputSeed"
|
||
@click.prevent="applyInputSeed"
|
||
>Apply {{ inputSeed }}</button>
|
||
</span>
|
||
<input v-model="seedInput" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm" placeholder="random">
|
||
</label>
|
||
</div>
|
||
<p class="text-xs text-zinc-500">Graph default is 20 steps, CFG 1, euler. CFG 0–20 (0.1 steps), steps 1–50. A second still switches the two-image branch on. Scale to 1 MP runs Scale Image to Total Pixels (lanczos) on both stills before Klein.</p>
|
||
</div>
|
||
|
||
<div v-if="studioMode === 'video'" class="space-y-4">
|
||
<div class="grid grid-cols-1 items-start gap-3 sm:grid-cols-2">
|
||
<div>
|
||
<DurationField
|
||
v-model="duration"
|
||
label="Duration"
|
||
/>
|
||
<button
|
||
type="button"
|
||
class="mt-2 text-[11px] text-amber-200/90 hover:text-amber-100"
|
||
title="Save this length as the default for new video jobs"
|
||
@click="saveDurationAsDefault"
|
||
>
|
||
Save {{ duration }}s as default
|
||
</button>
|
||
</div>
|
||
<div>
|
||
<Thumbwheel
|
||
v-model="cfg"
|
||
label="CFG"
|
||
:min="CFG_MIN"
|
||
:max="CFG_MAX"
|
||
:step="CFG_STEP"
|
||
@update:model-value="cfgTouched = true"
|
||
/>
|
||
<p class="mt-1 text-[11px] text-zinc-500">{{ ltxVideo ? 'Default 3.5' : (turbo ? 'Default 1.5' : 'Default 4.0') }}</p>
|
||
</div>
|
||
</div>
|
||
<label class="block text-sm">
|
||
<span class="mb-1 flex items-center justify-between gap-2 text-zinc-400">
|
||
Seed
|
||
<button
|
||
v-if="inputSeed"
|
||
type="button"
|
||
class="min-w-0 truncate text-[11px] font-medium text-amber-300 hover:text-amber-100"
|
||
:title="'Fill the seed box with ' + inputSeed"
|
||
@click.prevent="applyInputSeed"
|
||
>Apply {{ inputSeed }}</button>
|
||
</span>
|
||
<input v-model="seedInput" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2" placeholder="random">
|
||
</label>
|
||
<div>
|
||
<p class="mb-2 text-xs uppercase tracking-wider text-zinc-500">Sampler & scheduler</p>
|
||
<div class="grid grid-cols-2 gap-3">
|
||
<label class="text-sm">
|
||
<span class="mb-1 block text-zinc-400">Sampler</span>
|
||
<select v-model="samplerName" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm">
|
||
<option value="res_multistep">res_multistep</option>
|
||
<option value="euler">euler</option>
|
||
<option value="dpmpp_2m">dpmpp_2m</option>
|
||
</select>
|
||
</label>
|
||
<label class="text-sm">
|
||
<span class="mb-1 block text-zinc-400">Scheduler</span>
|
||
<select v-model="scheduler" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm">
|
||
<option value="simple">simple</option>
|
||
<option value="ddim_uniform">ddim_uniform</option>
|
||
<option value="sgm_uniform">sgm_uniform</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<details class="rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3">
|
||
<summary class="cursor-pointer text-sm font-medium">Aspect, steps, frame rate</summary>
|
||
<div class="mt-4 space-y-4">
|
||
<div>
|
||
<AspectRatioPick v-model="aspect" heading="Aspect ratio / resolution" :options="videoAspects" />
|
||
<p class="mt-2 text-xs text-zinc-500">Output {{ outputSize.width }} × {{ outputSize.height }}{{ aspect === 'auto' ? (file ? ' · matching the still' : ' · 16:9 until a still is loaded') : '' }}</p>
|
||
</div>
|
||
<div>
|
||
<p class="mb-2 text-xs uppercase tracking-wider text-zinc-500">Steps</p>
|
||
<div class="grid grid-cols-2 gap-2">
|
||
<button type="button" class="rounded-xl border px-3 py-2 text-sm" :class="turbo ? 'border-amber-300 bg-amber-400/10' : 'border-white/10'" @click="turbo = true">{{ ltxVideo ? '8 Distilled LoRA' : (videoWorkflow === 'v2' ? '6 Turbo · LoRA on' : '8 Turbo · LoRA on') }}</button>
|
||
<button type="button" class="rounded-xl border px-3 py-2 text-sm" :class="!turbo ? 'border-amber-300 bg-amber-400/10' : 'border-white/10'" @click="turbo = false">{{ ltxVideo ? '12 Quality · LoRA on' : '20 High-Fidelity · LoRA bypass' }}</button>
|
||
</div>
|
||
</div>
|
||
<label class="text-sm">
|
||
<span class="mb-1 block text-zinc-400">Frame rate</span>
|
||
<select v-model.number="fps" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm">
|
||
<option :value="24">24 FPS</option>
|
||
<option :value="12">12 FPS</option>
|
||
<option :value="30">30 FPS</option>
|
||
</select>
|
||
<p class="mt-1 text-[11px] text-zinc-500">{{ frameCount }} frames · duration × FPS</p>
|
||
</label>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
|
||
<label
|
||
v-if="studioMode === 'video' && queuedExtensionCount > 0"
|
||
class="flex cursor-pointer items-start gap-3 rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3"
|
||
>
|
||
<input v-model="queueAutoRun" type="checkbox" class="mt-1">
|
||
<span>
|
||
<span class="block text-sm font-medium text-zinc-200">Run remaining shots automatically</span>
|
||
<span class="mt-0.5 block text-xs text-zinc-500">Overnight set-and-forget. Leave this off to edit prompts on Queue (under this job) and process the next shot when you are ready.</span>
|
||
</span>
|
||
</label>
|
||
<div v-if="studioMode === 'video'" class="space-y-3">
|
||
<label
|
||
class="flex cursor-pointer items-start gap-3 rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3"
|
||
title="Runs a low-denoise Bilateral + Flux cleanup pass on the handoff frame to strip compression artifacts."
|
||
>
|
||
<input v-model="refineExtensionFrame" type="checkbox" class="mt-1">
|
||
<span>
|
||
<span class="block text-sm font-medium text-zinc-200">Refine Extension Frame</span>
|
||
<span class="mt-0.5 block text-xs text-zinc-500">Runs a low-denoise Bilateral + Flux cleanup pass on the handoff frame to strip compression artifacts.</span>
|
||
</span>
|
||
</label>
|
||
<div
|
||
v-if="refineExtensionFrame"
|
||
class="rounded-2xl border border-white/10 bg-zinc-950/40 p-3"
|
||
>
|
||
<Thumbwheel
|
||
v-model="refinementDenoise"
|
||
label="Refinement Denoise"
|
||
:min="REFINEMENT_DENOISE_MIN"
|
||
:max="REFINEMENT_DENOISE_MAX"
|
||
:step="0.01"
|
||
:digits="2"
|
||
/>
|
||
</div>
|
||
<label
|
||
class="flex cursor-pointer items-start gap-3 rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3"
|
||
title="Saves an uncompressed PNG of the final frame alongside the MP4 for cleaner future extensions."
|
||
>
|
||
<input v-model="saveLosslessAnchor" type="checkbox" class="mt-1">
|
||
<span>
|
||
<span class="block text-sm font-medium text-zinc-200">Save Lossless Anchor Frame</span>
|
||
<span class="mt-0.5 block text-xs text-zinc-500">Saves an uncompressed PNG of the final frame alongside the MP4 for cleaner future extensions.</span>
|
||
</span>
|
||
</label>
|
||
</div>
|
||
<div class="flex flex-wrap gap-3">
|
||
<button
|
||
type="button"
|
||
class="flex-1 rounded-2xl bg-amber-400 px-5 py-3 font-semibold text-zinc-950 transition hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
|
||
:disabled="studioMode === 'editv2' ? editV2Blocked : (studioMode === 'edit' ? editBlocked : videoGenerateDisabled)"
|
||
@click="studioMode === 'editv2' ? editImageV2() : (studioMode === 'edit' ? editImage() : generate())"
|
||
>
|
||
{{ studioMode === 'editv2' ? editV2SubmitLabel : (studioMode === 'edit' ? editSubmitLabel : generateLabel) }}
|
||
</button>
|
||
<button
|
||
v-if="showWakeGpu"
|
||
type="button"
|
||
class="rounded-2xl border px-4 py-3 text-sm font-semibold disabled:cursor-wait disabled:opacity-80"
|
||
:class="gpuWaking ? 'border-amber-300/40 text-amber-100' : 'border-amber-400/40 text-amber-200 hover:border-amber-300 hover:text-amber-50'"
|
||
:disabled="gpuWaking"
|
||
@click="pokeGpu"
|
||
>
|
||
{{ gpuWaking ? 'Starting GPU…' : 'Wake GPU' }}
|
||
</button>
|
||
<button
|
||
v-if="videoBusy || editBusy"
|
||
type="button"
|
||
class="rounded-2xl border border-red-400/40 px-4 py-3 text-red-300"
|
||
@click="cancelJob"
|
||
>
|
||
Cancel / Interrupt
|
||
</button>
|
||
</div>
|
||
<p v-if="studioMode === 'video' && videoBusy" class="text-xs text-zinc-400">The current job keeps running in Output. This submit waits in the job queue.</p>
|
||
<p v-else-if="(studioMode === 'edit' || studioMode === 'editv2') && (videoBusy || editBusy)" class="text-xs text-zinc-400">The current job keeps running in Output. This submit waits in the job queue.</p>
|
||
<p v-if="studioMode === 'video' && videoBlockReason" class="text-xs text-zinc-400">{{ videoBlockReason }}</p>
|
||
<p v-if="studioMode === 'edit' && editBlockReason" class="text-xs text-zinc-400">{{ editBlockReason }}</p>
|
||
<p v-if="studioMode === 'editv2' && editV2BlockReason" class="text-xs text-zinc-400">{{ editV2BlockReason }}</p>
|
||
<p v-if="gpuSleepHint" class="text-xs text-amber-200/80">{{ gpuSleepHint }}</p>
|
||
|
||
<div v-if="studioJobs.length" class="rounded-2xl border border-white/10 bg-zinc-950/40 p-4">
|
||
<div class="flex items-start justify-between gap-3">
|
||
<div>
|
||
<h3 class="text-sm font-semibold text-zinc-100">Job queue</h3>
|
||
<p class="mt-1 text-xs text-zinc-500">Click a job to inspect it. Shots inside a job live on the Queue page.</p>
|
||
</div>
|
||
<NuxtLink to="/queue" class="shrink-0 text-xs text-amber-200 hover:text-white">Open Queue</NuxtLink>
|
||
</div>
|
||
<ul class="mt-3 space-y-2">
|
||
<li
|
||
v-for="job in studioJobs"
|
||
:key="job.id"
|
||
class="flex items-start gap-3 rounded-xl border px-3 py-2"
|
||
:class="job.status === 'running'
|
||
? 'border-amber-300/40 bg-amber-400/5'
|
||
: job.cutIn
|
||
? 'border-sky-300/30 bg-sky-400/5'
|
||
: 'border-white/10 bg-zinc-950/50'"
|
||
>
|
||
<button type="button" class="min-w-0 flex-1 text-left" @click="inspectJobId = job.id">
|
||
<p class="truncate text-sm font-medium text-zinc-100 hover:text-white">{{ job.name }}</p>
|
||
<p class="mt-0.5 text-xs text-zinc-500">
|
||
<span v-if="job.kind === 'music'" class="mr-1 text-amber-200/80">MUSIC ·</span>
|
||
<span v-else-if="job.kind === 'edit'" class="mr-1 text-amber-200/80">{{ job.imagePipeline === 'v2' ? 'IMAGE V2' : ((job.shotCount || 1) > 1 ? 'EDIT' : 'IMAGE') }} ·</span>
|
||
{{ studioJobStatusLabel(job) }}
|
||
· {{ studioJobShotLine(job) }}
|
||
</p>
|
||
</button>
|
||
<button
|
||
v-if="job.status === 'waiting' && job.kind !== 'music'"
|
||
type="button"
|
||
class="shrink-0 rounded-full border border-sky-300/40 px-2.5 py-1 text-xs text-sky-100 hover:border-sky-200 hover:text-white"
|
||
@click="cutInStudioJob(job.id)"
|
||
>
|
||
{{ job.cutIn ? 'Next' : (canCutInStudioJob ? 'Run next' : 'Start now') }}
|
||
</button>
|
||
<button
|
||
v-if="job.status === 'error' || (job.status === 'held' && !job.holdForCutIn && job.kind !== 'music')"
|
||
type="button"
|
||
class="shrink-0 rounded-full border border-amber-300/40 px-2.5 py-1 text-xs text-amber-100 hover:border-amber-200 hover:text-white"
|
||
@click="retryStudioJob(job.id)"
|
||
>
|
||
Retry
|
||
</button>
|
||
<button
|
||
v-if="job.status === 'waiting' || job.status === 'held' || job.status === 'running' || job.status === 'error'"
|
||
type="button"
|
||
class="shrink-0 text-xs text-zinc-400 hover:text-red-200"
|
||
@click="dismissStudioJob(job.id)"
|
||
>
|
||
{{ job.status === 'running' ? 'Cancel' : 'Remove' }}
|
||
</button>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div v-if="drafts.length" class="rounded-2xl border border-amber-300/20 bg-amber-400/5 p-4">
|
||
<h3 class="text-sm font-semibold text-amber-100">Held jobs</h3>
|
||
<p class="mt-1 text-xs text-zinc-400">Jobs saved to retry later. Click one to restore the still, prompt, and settings.</p>
|
||
<ul class="mt-3 space-y-2">
|
||
<li
|
||
v-for="draft in drafts"
|
||
:key="draft.id"
|
||
class="flex items-start gap-3 rounded-xl border border-white/10 bg-zinc-950/50 px-3 py-2"
|
||
>
|
||
<button type="button" class="min-w-0 flex-1 text-left" @click="loadDraft(draft)">
|
||
<p class="truncate text-sm font-medium text-zinc-100">{{ draft.name || draft.prompt }}</p>
|
||
<p class="mt-0.5 text-xs text-zinc-500">{{ draft.duration }}s · {{ draft.turbo ? 'turbo' : 'quality' }} · {{ draft.sound === false ? 'silent' : 'sound' }} · seed {{ draft.seed }}{{ draft.extensions?.length ? ` · +${draft.extensions.length} ext` : '' }}</p>
|
||
</button>
|
||
<button type="button" class="shrink-0 text-xs text-red-300 hover:text-red-200" @click="dismissDraft(draft)">Dismiss</button>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="flex min-w-0 max-w-full flex-col gap-5 lg:h-full lg:min-h-0">
|
||
<div ref="outputPanel" class="glass min-w-0 max-w-full rounded-3xl p-5 sm:p-6 lg:shrink-0">
|
||
<div class="flex items-center justify-between gap-3">
|
||
<div class="flex items-center gap-3">
|
||
<h2 class="font-display text-2xl font-bold">Output</h2>
|
||
<button
|
||
v-if="canRestoreOutput"
|
||
type="button"
|
||
class="inline-flex h-8 w-8 items-center justify-center rounded-full border border-white/15 text-zinc-300 hover:border-amber-300/50 hover:text-white"
|
||
title="Load this job back into Input"
|
||
@click="restoreOutputJob"
|
||
>
|
||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="h-4 w-4">
|
||
<path d="M13.586 3.586a2 2 0 1 1 2.828 2.828l-.793.793-2.828-2.828.793-.793ZM11.379 5.793 3 14.172V17h2.828l8.38-8.379-2.83-2.828Z" />
|
||
</svg>
|
||
</button>
|
||
<button
|
||
v-if="canClearPreview"
|
||
type="button"
|
||
class="rounded-full border border-white/15 px-2.5 py-0.5 text-xs text-zinc-300 hover:border-amber-300/50 hover:text-white"
|
||
@click="clearPreview"
|
||
>
|
||
Clear preview
|
||
</button>
|
||
</div>
|
||
<p class="text-xs text-zinc-500">
|
||
{{ liveElapsedLabel }}
|
||
<template v-if="outputProgressKind === 'edit' && editChainTotal > 1"> · chain {{ editChainStep }}/{{ editChainTotal }}</template>
|
||
<template v-else-if="outputProgressKind === 'video' && chainTotal > 1"> · chain {{ chainStep }}/{{ chainTotal }}</template>
|
||
· step {{ liveStep }}/{{ liveMaxStep }}
|
||
</p>
|
||
</div>
|
||
|
||
<div class="mt-4">
|
||
<div class="mb-2 flex items-center justify-between gap-3 text-sm">
|
||
<span class="min-w-0 truncate" :class="liveStatusBusy ? 'text-amber-200' : 'text-zinc-300'">
|
||
<span
|
||
v-if="outputProgressKind !== studioMode"
|
||
class="mr-2 inline-flex rounded-full border border-amber-300/30 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-200"
|
||
>{{ outputProgressKind === 'edit' ? 'Edit' : 'Video' }}</span>
|
||
{{ liveStatusMessage }}
|
||
</span>
|
||
<span class="shrink-0 text-amber-200">{{ liveProgressPct }}%</span>
|
||
</div>
|
||
<p v-if="outputProgressKind === 'video' && chainTotal > 1" class="mb-2 text-xs text-amber-200/80">
|
||
Step {{ chainStep }}/{{ chainTotal }}{{ chainLabel ? `: ${chainLabel}` : '' }}
|
||
<template v-if="overallProgress"> · {{ overallProgress }}% overall</template>
|
||
</p>
|
||
<p v-else-if="outputProgressKind === 'edit' && editChainTotal > 1" class="mb-2 text-xs text-amber-200/80">
|
||
Step {{ editChainStep }}/{{ editChainTotal }}{{ editChainLabel ? `: ${editChainLabel}` : '' }}
|
||
<template v-if="editOverallProgress"> · {{ editOverallProgress }}% overall</template>
|
||
</p>
|
||
<ol v-if="outputProgressKind === 'video' && shotChecklist.length > 1 && showShotChecklist" class="mb-3 space-y-1">
|
||
<li
|
||
v-for="(part, index) in shotChecklist"
|
||
:key="index"
|
||
class="flex items-center gap-2 rounded-lg px-2 py-1 text-xs"
|
||
:class="shotCheckClass(part)"
|
||
>
|
||
<span class="flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-[10px] font-bold" :class="shotCheckMarkClass(part)">{{ shotCheckMark(part) }}</span>
|
||
<span class="shrink-0 font-medium">{{ part.label }}</span>
|
||
<span class="min-w-0 truncate text-zinc-400">{{ part.prompt }}</span>
|
||
<span class="shrink-0 text-zinc-500">{{ formatClipDuration(part.duration) }}</span>
|
||
</li>
|
||
</ol>
|
||
<p v-if="shotChecklistResume" class="mb-3 text-xs text-amber-100">
|
||
{{ shotChecklistResume }}
|
||
<NuxtLink to="/queue" class="ml-1 underline decoration-amber-300/50 underline-offset-2 hover:text-white">Open Queue</NuxtLink>
|
||
</p>
|
||
<ol v-else-if="outputProgressKind === 'edit' && editBusy && editActiveChainPlan.length > 1" class="mb-3 space-y-1">
|
||
<li
|
||
v-for="(part, index) in editActiveChainPlan"
|
||
:key="index"
|
||
class="flex items-center gap-2 rounded-lg px-2 py-1 text-xs"
|
||
:class="editChainPartClass(index)"
|
||
>
|
||
<span class="h-2 w-2 shrink-0 rounded-full" :class="editChainPartDot(index)" />
|
||
<span class="shrink-0 font-medium">{{ part.label }}</span>
|
||
<span class="min-w-0 truncate text-zinc-400">{{ part.prompt }}</span>
|
||
</li>
|
||
</ol>
|
||
<div class="h-2 overflow-hidden rounded-full bg-zinc-800">
|
||
<div class="h-full rounded-full bg-amber-400 progress-glow transition-all duration-300" :style="{ width: liveProgressPct + '%' }" />
|
||
</div>
|
||
<p v-if="studioWaitingCount" class="mt-2 text-xs text-zinc-500">
|
||
{{ studioWaitingCount }} waiting in the job queue — Output stays on the clip that is generating.
|
||
</p>
|
||
</div>
|
||
|
||
<div v-show="outputPreviewKind === 'video'">
|
||
<div
|
||
class="relative mx-auto mt-5 w-full max-w-full overflow-hidden rounded-2xl bg-black/50"
|
||
:class="{ 'sensitive-glow': awaitingReveal || lockedSave }"
|
||
:style="{ aspectRatio: `${playerSize.width} / ${playerSize.height}`, maxHeight: 'min(70dvh, 32rem)' }"
|
||
>
|
||
<video
|
||
v-if="videoUrl && !lockedSave"
|
||
ref="player"
|
||
:src="videoUrl"
|
||
class="h-full w-full max-h-[min(70dvh,32rem)] bg-black object-contain"
|
||
:class="{ 'opacity-0': awaitingReveal }"
|
||
:controls="!awaitingReveal"
|
||
:autoplay="autoplayEnabled && !awaitingReveal"
|
||
loop
|
||
playsinline
|
||
preload="metadata"
|
||
@play="onPlayed"
|
||
@loadedmetadata="onVideoMeta"
|
||
@durationchange="onVideoMeta"
|
||
/>
|
||
<button
|
||
v-if="awaitingReveal && !lockedSave"
|
||
type="button"
|
||
class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-amber-100"
|
||
@click="revealAndPlay"
|
||
>
|
||
<span class="font-display text-lg font-bold">Ready</span>
|
||
<span class="text-sm text-amber-100/80">Click to play · preview hidden</span>
|
||
</button>
|
||
<div
|
||
v-else-if="lockedSave"
|
||
class="absolute inset-0 flex flex-col items-center justify-center gap-2 px-6 text-center text-amber-100"
|
||
>
|
||
<span class="font-display text-lg font-bold">Saved to a locked folder</span>
|
||
<span class="text-sm text-amber-100/80">Unlock that folder in the library to view this clip</span>
|
||
</div>
|
||
<div
|
||
v-else-if="!videoUrl"
|
||
class="flex h-full min-h-[12rem] items-center justify-center text-sm text-zinc-500"
|
||
>
|
||
Generated MP4 appears here
|
||
</div>
|
||
</div>
|
||
<p v-if="videoUrl && !lockedSave && !videoSeekable" class="mt-3 text-xs text-amber-200/80">
|
||
Encoding… this file cannot be scrubbed until Comfy finishes writing it.
|
||
</p>
|
||
<p v-else-if="playingLastSavedWhileBusy" class="mt-3 text-xs text-zinc-500">
|
||
Playing the last saved part. The shot still encoding cannot be scrubbed until it saves.
|
||
</p>
|
||
<div v-if="(videoUrl && !lockedSave) || canUseGeneratedStill" class="mt-4 flex flex-wrap gap-3">
|
||
<a
|
||
v-if="videoUrl && !lockedSave && !awaitingReveal"
|
||
:href="videoUrl"
|
||
:download="`${videoDownloadName}.mp4`"
|
||
class="inline-flex rounded-2xl border border-white/15 px-4 py-2 text-sm hover:border-amber-300/50"
|
||
>
|
||
Download MP4
|
||
</a>
|
||
<button
|
||
v-if="canExtend"
|
||
type="button"
|
||
class="inline-flex rounded-2xl border border-amber-300/40 px-4 py-2 text-sm text-amber-100 hover:border-amber-300/70"
|
||
@click="openExtend()"
|
||
>
|
||
Extend Video
|
||
</button>
|
||
<button
|
||
v-if="canUseGeneratedStill"
|
||
type="button"
|
||
class="inline-flex rounded-2xl border border-amber-300/40 px-4 py-2 text-sm text-amber-100 hover:border-amber-300/70"
|
||
@click="useEditAsInput"
|
||
>
|
||
Use the image I just generated as input
|
||
</button>
|
||
<button
|
||
v-if="canUseGeneratedStill"
|
||
type="button"
|
||
class="inline-flex rounded-2xl border border-white/15 px-4 py-2 text-sm hover:border-amber-300/50 disabled:cursor-not-allowed disabled:opacity-50"
|
||
:disabled="keepingInputBusy"
|
||
@click="saveEditAsInput"
|
||
>
|
||
{{ keepingInputBusy ? 'Saving…' : 'Save as input' }}
|
||
</button>
|
||
<button
|
||
v-if="canUseGeneratedStill && backupStudioEnabled"
|
||
type="button"
|
||
class="inline-flex rounded-2xl border border-amber-300/40 px-4 py-2 text-sm text-amber-100 hover:border-amber-300/70 disabled:cursor-not-allowed disabled:opacity-50"
|
||
:disabled="Boolean(backupBusyId)"
|
||
@click="sendOutputStillToBackup"
|
||
>
|
||
{{ backupBusyId ? 'Sending…' : 'Send to Backup' }}
|
||
</button>
|
||
</div>
|
||
<div v-if="extendOpen" id="extend-clip" class="mt-4 space-y-4 rounded-2xl border border-white/10 bg-zinc-950/50 p-4">
|
||
<div class="flex items-start justify-between gap-3">
|
||
<div>
|
||
<h3 class="text-sm font-semibold text-amber-100">Extend this clip</h3>
|
||
<p v-if="extendSourceLabel" class="mt-1 text-xs text-amber-200/90">
|
||
Extending {{ extendSourceLabel }}{{ extendSaveName ? ` · next save “${extendSaveName}”` : '' }}
|
||
</p>
|
||
<p class="mt-1 text-xs text-zinc-500">Continue from the last frame with a new motion prompt, then stitch the segments together. Other jobs finishing will not close this panel.</p>
|
||
</div>
|
||
<button type="button" class="text-xs text-zinc-400 hover:text-white" @click="closeExtend">Close</button>
|
||
</div>
|
||
<div class="relative overflow-hidden rounded-xl bg-black/40" style="min-height: 8rem">
|
||
<img
|
||
v-if="lastFrameUrl && !hideExtendLastFrame"
|
||
:src="lastFrameUrl"
|
||
alt="Last frame"
|
||
class="max-h-48 w-full object-contain"
|
||
>
|
||
<button
|
||
v-else-if="hideExtendLastFrame"
|
||
type="button"
|
||
class="sensitive-glow flex min-h-[8rem] w-full flex-col items-center justify-center gap-1 px-3 py-8 text-amber-100"
|
||
@click="revealExtendLastFrame"
|
||
>
|
||
<span class="text-sm font-medium">Last frame ready</span>
|
||
<span class="text-xs text-amber-100/80">Click to reveal · preview hidden</span>
|
||
</button>
|
||
<p v-else class="px-3 py-8 text-center text-xs text-zinc-500">
|
||
{{ lastFrameLoading ? 'Reading last frame…' : 'Last frame preview unavailable' }}
|
||
</p>
|
||
</div>
|
||
<PromptPartsEditor
|
||
v-model:pre="extendPromptPre"
|
||
v-model:prompt="extendPrompt"
|
||
v-model:post="extendPromptPost"
|
||
prompt-placeholder="Continue the motion from this frame…"
|
||
:prompt-rows="4"
|
||
:keep-items="keepPrompts.video"
|
||
keep-heading="Video prompts"
|
||
/>
|
||
<DurationField
|
||
v-model="extendDuration"
|
||
label="Extension duration"
|
||
/>
|
||
<LoraSelect
|
||
v-model="extendLoraStack"
|
||
:names="videoLoraOptions"
|
||
:triggers="loraTriggers"
|
||
:aliases="loraAliases"
|
||
label="LoRA"
|
||
empty-label="Same as clip / none"
|
||
compact
|
||
:clip-strength="false"
|
||
@insert-trigger="insertLoraTrigger($event, 'extend')"
|
||
@update-trigger="saveLoraTrigger"
|
||
/>
|
||
<div class="space-y-3">
|
||
<label
|
||
class="flex cursor-pointer items-start gap-3 rounded-xl border border-white/10 bg-zinc-950/40 px-3 py-2.5"
|
||
title="Runs a low-denoise Bilateral + Flux cleanup pass on the handoff frame to strip compression artifacts."
|
||
>
|
||
<input v-model="refineExtensionFrame" type="checkbox" class="mt-1">
|
||
<span>
|
||
<span class="block text-sm font-medium text-zinc-200">Refine Extension Frame</span>
|
||
<span class="mt-0.5 block text-xs text-zinc-500">Bilateral + Flux cleanup on the handoff frame.</span>
|
||
</span>
|
||
</label>
|
||
<div v-if="refineExtensionFrame" class="rounded-xl border border-white/10 bg-zinc-950/40 p-3">
|
||
<Thumbwheel
|
||
v-model="refinementDenoise"
|
||
label="Refinement Denoise"
|
||
:min="REFINEMENT_DENOISE_MIN"
|
||
:max="REFINEMENT_DENOISE_MAX"
|
||
:step="0.01"
|
||
:digits="2"
|
||
/>
|
||
</div>
|
||
<label
|
||
class="flex cursor-pointer items-start gap-3 rounded-xl border border-white/10 bg-zinc-950/40 px-3 py-2.5"
|
||
title="Saves an uncompressed PNG of the final frame alongside the MP4 for cleaner future extensions."
|
||
>
|
||
<input v-model="saveLosslessAnchor" type="checkbox" class="mt-1">
|
||
<span>
|
||
<span class="block text-sm font-medium text-zinc-200">Save Lossless Anchor Frame</span>
|
||
<span class="mt-0.5 block text-xs text-zinc-500">Keep a PNG anchor with this extension for cleaner next hops.</span>
|
||
</span>
|
||
</label>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
class="w-full rounded-2xl bg-amber-400 px-4 py-2.5 text-sm font-semibold text-zinc-950 transition hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
|
||
:disabled="!composePromptParts(extendPromptPre, extendPrompt, extendPromptPost)"
|
||
@click="generateExtension"
|
||
>
|
||
{{ watchingLiveVideo() || videoBusy ? 'Queue extension' : 'Generate Extension' }}
|
||
</button>
|
||
<p v-if="watchingLiveVideo() || videoBusy" class="text-xs text-zinc-500">The current job keeps running in Output. This extension waits in the job queue.</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-show="outputPreviewKind === 'edit'">
|
||
<div
|
||
class="relative mt-5 overflow-hidden rounded-2xl bg-black/50"
|
||
:class="{ 'sensitive-glow': editAwaitingReveal || editLockedSave }"
|
||
style="min-height: 12rem"
|
||
>
|
||
<button
|
||
v-if="editResultUrl && !editLockedSave"
|
||
type="button"
|
||
class="group relative mx-auto block w-full cursor-zoom-in bg-black"
|
||
:class="{ 'pointer-events-none opacity-0': editAwaitingReveal }"
|
||
aria-label="View still fullscreen"
|
||
@click="openEditLightbox"
|
||
>
|
||
<img
|
||
:src="editResultUrl"
|
||
alt="Edited still"
|
||
class="mx-auto max-h-[min(70dvh,24rem)] w-full object-contain"
|
||
decoding="async"
|
||
>
|
||
<span
|
||
v-if="!editAwaitingReveal"
|
||
class="pointer-events-none absolute right-3 top-3 rounded-full border border-white/15 bg-zinc-950/75 p-1.5 text-amber-100 opacity-0 shadow-lg transition group-hover:opacity-100"
|
||
aria-hidden="true"
|
||
>
|
||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.75" stroke="currentColor" class="h-4 w-4">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" />
|
||
</svg>
|
||
</span>
|
||
</button>
|
||
<button
|
||
v-if="editAwaitingReveal && !editLockedSave"
|
||
type="button"
|
||
class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-amber-100"
|
||
@click="editAwaitingReveal = false"
|
||
>
|
||
<span class="font-display text-lg font-bold">Ready</span>
|
||
<span class="text-sm text-amber-100/80">Click to reveal · preview hidden</span>
|
||
</button>
|
||
<div
|
||
v-else-if="editLockedSave"
|
||
class="absolute inset-0 flex flex-col items-center justify-center gap-2 px-6 text-center text-amber-100"
|
||
>
|
||
<span class="font-display text-lg font-bold">Saved to a locked folder</span>
|
||
<span class="text-sm text-amber-100/80">Unlock that folder in the library to view this still</span>
|
||
</div>
|
||
<div
|
||
v-else-if="!editResultUrl"
|
||
class="flex h-full min-h-[12rem] items-center justify-center text-sm text-zinc-500"
|
||
>
|
||
Edited still appears here
|
||
</div>
|
||
<FamilyNav
|
||
:count="outputFamilyStills.length"
|
||
:index="outputFamilyIndex"
|
||
@step="stepOutputFamily"
|
||
/>
|
||
</div>
|
||
<div v-if="editResultUrl && !editLockedSave && !editAwaitingReveal" class="mt-4 flex flex-wrap gap-3">
|
||
<a
|
||
:href="editResultUrl"
|
||
:download="`${editDownloadName || 'edited'}.png`"
|
||
class="inline-flex rounded-2xl border border-white/15 px-4 py-2 text-sm hover:border-amber-300/50"
|
||
>
|
||
Download still
|
||
</a>
|
||
<button
|
||
type="button"
|
||
class="inline-flex rounded-2xl border border-amber-300/40 px-4 py-2 text-sm text-amber-100 hover:border-amber-300/70"
|
||
@click="useEditAsInput"
|
||
>
|
||
Use as input still
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="inline-flex rounded-2xl border border-white/15 px-4 py-2 text-sm hover:border-amber-300/50 disabled:cursor-not-allowed disabled:opacity-50"
|
||
:disabled="keepingInputBusy"
|
||
@click="saveEditAsInput"
|
||
>
|
||
{{ keepingInputBusy ? 'Saving…' : 'Save as input' }}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="inline-flex rounded-2xl border border-amber-300/40 px-4 py-2 text-sm text-amber-100 hover:border-amber-300/70"
|
||
@click="sendToEdit"
|
||
>
|
||
Send to Edit
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="inline-flex rounded-2xl border border-amber-300/40 px-4 py-2 text-sm text-amber-100 hover:border-amber-300/70"
|
||
@click="sendToRefine"
|
||
>
|
||
Send to Refine
|
||
</button>
|
||
<button
|
||
v-if="backupStudioEnabled"
|
||
type="button"
|
||
class="inline-flex rounded-2xl border border-amber-300/40 px-4 py-2 text-sm text-amber-100 hover:border-amber-300/70 disabled:cursor-not-allowed disabled:opacity-50"
|
||
:disabled="Boolean(backupBusyId)"
|
||
@click="sendOutputStillToBackup"
|
||
>
|
||
{{ backupBusyId ? 'Sending…' : 'Send to Backup' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="glass rounded-3xl p-5 sm:p-6 lg:flex lg:min-h-[24rem] lg:flex-1 lg:flex-col">
|
||
<div class="flex items-center justify-between gap-3">
|
||
<h2 class="font-display text-xl font-bold">Library</h2>
|
||
<div class="flex items-center gap-3">
|
||
<NuxtLink to="/library" class="text-xs text-amber-300 hover:text-amber-100">Open full library</NuxtLink>
|
||
<button
|
||
v-if="visibleLibraryEntries.length"
|
||
type="button"
|
||
class="text-xs text-zinc-400 hover:text-white"
|
||
@click="selectedKeys.length === visibleLibraryEntries.length ? clearSelection() : selectAllLibrary()"
|
||
>
|
||
{{ selectedKeys.length && selectedKeys.length === visibleLibraryEntries.length ? 'Clear selection' : 'Select all' }}
|
||
</button>
|
||
<button
|
||
v-if="browseFolder?.protected && browseFolder.unlocked"
|
||
type="button"
|
||
class="text-xs text-zinc-400 hover:text-white"
|
||
@click="lockFolder(browseFolderId)"
|
||
>
|
||
Lock folder
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<form v-if="browseFolder?.protected && !browseFolder.unlocked" class="mt-4 space-y-3" @submit.prevent="unlockFolder(browseFolderId)">
|
||
<p class="text-sm text-zinc-400">This folder is locked. Unlock to view {{ folderOutputCount(browseFolder) }} output{{ folderOutputCount(browseFolder) === 1 ? '' : 's' }}.</p>
|
||
<input v-model="unlockPassword" type="password" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm" placeholder="Folder password">
|
||
<button type="submit" class="w-full rounded-xl bg-amber-400 px-4 py-2 font-semibold text-zinc-950">Unlock</button>
|
||
</form>
|
||
|
||
<template v-else>
|
||
<div class="lg:flex lg:min-h-0 lg:flex-1 lg:flex-col">
|
||
<div class="mt-4 flex flex-wrap items-center justify-between gap-2">
|
||
<div class="flex min-w-0 flex-wrap gap-1.5">
|
||
<button
|
||
v-for="folder in folders"
|
||
:key="folder.id"
|
||
type="button"
|
||
class="rounded-full border px-2.5 py-1 text-[11px]"
|
||
:class="browseFolderId === folder.id ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400'"
|
||
@click="browseFolderId = folder.id"
|
||
>
|
||
{{ folder.name }}
|
||
<span class="text-zinc-500">{{ folderOutputCount(folder) }}</span>
|
||
<span v-if="folder.protected && !folder.unlocked"> · lock</span>
|
||
</button>
|
||
</div>
|
||
<div class="inline-flex rounded-full border border-white/10 p-0.5">
|
||
<button
|
||
v-for="option in [{ id: 'all', label: 'All' }, { id: 'images', label: 'Images' }, { id: 'videos', label: 'Videos' }] as const"
|
||
:key="option.id"
|
||
type="button"
|
||
class="rounded-full px-2.5 py-1 text-[11px]"
|
||
:class="libraryFilter === option.id ? 'bg-amber-400/15 text-amber-100' : 'text-zinc-500 hover:text-zinc-300'"
|
||
@click="libraryFilter = option.id"
|
||
>
|
||
{{ option.label }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<p v-if="!libraryEntries.length" class="mt-4 text-sm text-zinc-500">{{ libraryEmptyLabel }}</p>
|
||
<div
|
||
v-if="selectedKeys.length"
|
||
class="mt-3 flex flex-wrap items-center gap-2 rounded-xl border border-amber-300/20 bg-amber-400/5 px-3 py-2 text-xs"
|
||
>
|
||
<span class="font-medium text-amber-100">{{ selectedKeys.length }} selected</span>
|
||
<select
|
||
v-if="folders.length > 1"
|
||
class="max-w-[9.5rem] rounded-lg border border-white/10 bg-zinc-950 px-1.5 py-0.5 text-amber-300"
|
||
:value="''"
|
||
@change="onMoveSelected($event)"
|
||
>
|
||
<option disabled value="">Move to…</option>
|
||
<option
|
||
v-for="folder in folders.filter(folder => folder.id !== browseFolderId)"
|
||
:key="folder.id"
|
||
:value="folder.id"
|
||
>
|
||
{{ folder.name }}{{ folder.protected && !folder.unlocked ? ' · locked' : '' }}
|
||
</option>
|
||
</select>
|
||
<button type="button" class="text-red-300 hover:text-red-200" @click="askRemoveSelected">Delete</button>
|
||
<button type="button" class="text-zinc-400 hover:text-white" @click="clearSelection">Clear</button>
|
||
</div>
|
||
<ul class="mt-4 max-h-[min(28rem,50vh)] space-y-3 overflow-y-auto lg:max-h-none lg:min-h-0 lg:flex-1">
|
||
<li
|
||
v-for="entry in visibleLibraryEntries"
|
||
:key="entry.key"
|
||
class="studio-library-card cursor-pointer rounded-2xl border p-3"
|
||
:class="libraryEntryClass(entry)"
|
||
:aria-current="isLibraryEntryFocused(entry) ? 'true' : undefined"
|
||
@click="onLibraryCardClick(entry, $event)"
|
||
>
|
||
<template v-if="entry.kind === 'clip'">
|
||
<div class="flex gap-3">
|
||
<button
|
||
type="button"
|
||
role="checkbox"
|
||
class="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center self-start rounded-[4px] border"
|
||
:class="selectedKeys.includes(entry.key)
|
||
? 'border-white bg-amber-400 text-zinc-950'
|
||
: 'border-white/30 bg-black/45'"
|
||
:aria-checked="selectedKeys.includes(entry.key)"
|
||
:aria-label="selectedKeys.includes(entry.key) ? 'Deselect' : 'Select'"
|
||
@click.stop="onSelectClick(entry.key, $event)"
|
||
>
|
||
<svg
|
||
v-if="selectedKeys.includes(entry.key)"
|
||
class="h-3 w-3"
|
||
viewBox="0 0 16 16"
|
||
fill="none"
|
||
aria-hidden="true"
|
||
>
|
||
<path d="M3.2 8.2 6.4 11.3 12.8 4.6" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" />
|
||
</svg>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="relative h-16 w-16 shrink-0 overflow-hidden rounded-xl bg-zinc-900"
|
||
:title="entry.group.head.hideThumbnail ? 'Preview hidden · click to open' : undefined"
|
||
@click="openClip(entry.group.head)"
|
||
>
|
||
<span v-if="entry.group.head.hideThumbnail" class="sensitive-glow block h-full w-full" />
|
||
<img v-else :src="`/api/library/clips/${entry.group.head.id}/thumb`" alt="" loading="lazy" decoding="async" class="h-full w-full object-cover">
|
||
</button>
|
||
<div class="min-w-0 flex-1">
|
||
<div class="flex items-start gap-2">
|
||
<input
|
||
v-if="renamingId === entry.group.head.id && renamingFamily"
|
||
ref="renameInput"
|
||
v-model="renameDraft"
|
||
class="min-w-0 flex-1 rounded-lg border border-amber-300/40 bg-zinc-950 px-2 py-1 text-sm outline-none ring-amber-300/40 focus:ring-2"
|
||
maxlength="80"
|
||
@keydown.enter.prevent="saveRename(entry.group.head, true)"
|
||
@keydown.escape.prevent="cancelRename"
|
||
@blur="saveRename(entry.group.head, true)"
|
||
>
|
||
<p v-else class="min-w-0 flex-1 truncate text-sm font-medium">{{ collectionTitle(entry.group) }}</p>
|
||
<PromptPreviewTip
|
||
v-if="formatPromptParts(entry.group.head, familyScript(entry.group.parts))"
|
||
:text="formatPromptParts(entry.group.head, familyScript(entry.group.parts))"
|
||
:aria-label="'Copy prompt for ' + collectionTitle(entry.group)"
|
||
@copy="copyPrompt"
|
||
/>
|
||
</div>
|
||
<p class="truncate text-xs text-zinc-500">{{ clipMetaLine(entry.group.head) }}</p>
|
||
<p v-if="familyBranchSummary(entry.group.parts)" class="mt-1 truncate text-[11px] text-amber-200/80">{{ familyBranchSummary(entry.group.parts) }}</p>
|
||
<p v-if="entry.group.head.globalLocks" class="mt-1 text-[11px] text-zinc-600">Global locks saved with this collection.</p>
|
||
<PermanenceRefStrip
|
||
v-if="entry.group.parts.length === 1 && !entry.group.head.hideThumbnail"
|
||
class="mt-2"
|
||
:refs="entry.group.head.permanenceRefs || []"
|
||
:name-options="clipBindingKeys(entry.group.head)"
|
||
cta="Add still"
|
||
:hidden="entry.group.head.hideInput || hideInputPreview"
|
||
@add-ref="openPermanencePicker(undefined, entry.group.head.id)"
|
||
@remove="(refIndex) => removeClipRef(entry.group.head, refIndex)"
|
||
@update-label="(refIndex, label) => updateClipRefLabel(entry.group.head, refIndex, label)"
|
||
/>
|
||
<div class="mt-2 flex flex-wrap items-center gap-3">
|
||
<button v-if="entry.group.parts.length > 1" type="button" class="text-xs text-amber-200" @click="toggleFamily(entry.group.key)">
|
||
{{ familyExpandLabel(entry.group.parts, expandedFamilies[entry.group.key]) }}
|
||
</button>
|
||
<button
|
||
v-if="entry.group.parts.length > 1 && entry.group.parts.some(part => part.hideThumbnail)"
|
||
type="button"
|
||
class="text-xs text-amber-300"
|
||
@click="toggleFamilyFrames(entry.group)"
|
||
>
|
||
{{ familyFramesRevealed(entry.group.parts) ? 'Hide frames' : 'Click to reveal' }}
|
||
</button>
|
||
<button type="button" class="text-xs text-amber-300" @click="openClip(entry.group.head)">Open</button>
|
||
<button type="button" class="text-xs text-amber-300" @click="useClipAsInput(entry.group.head, true)">Use as input</button>
|
||
<button type="button" class="text-xs text-amber-300" @click="openExtend(entry.group.head)">Extend</button>
|
||
<button type="button" class="text-xs text-amber-300" @click="startRename(entry.group.head, true)">Rename</button>
|
||
<button type="button" class="text-xs text-zinc-400" @click="toggleDetails(entry.key)">
|
||
{{ expandedDetails[entry.key] ? 'Hide details' : 'Details' }}
|
||
</button>
|
||
<select
|
||
v-if="folders.length > 1"
|
||
class="max-w-[9.5rem] rounded-lg border border-white/10 bg-zinc-950 px-1.5 py-0.5 text-xs text-amber-300"
|
||
:value="''"
|
||
@change="onMoveClip(entry.group.head, $event, true)"
|
||
>
|
||
<option disabled value="">Move to…</option>
|
||
<option
|
||
v-for="folder in folders.filter(folder => folder.id !== entry.group.head.folderId)"
|
||
:key="folder.id"
|
||
:value="folder.id"
|
||
>
|
||
{{ folder.name }}{{ folder.protected && !folder.unlocked ? ' · locked' : '' }}
|
||
</option>
|
||
</select>
|
||
<button type="button" class="text-xs text-red-300" @click="askRemoveClip(entry.group.head, true)">Delete</button>
|
||
</div>
|
||
<LibrarySettingsDetails
|
||
v-if="expandedDetails[entry.key]"
|
||
:rows="clipSettingsRows(entry.group.head)"
|
||
empty="No generator settings were saved with this clip."
|
||
/>
|
||
</div>
|
||
</div>
|
||
<ul v-if="expandedFamilies[entry.group.key] && entry.group.parts.length > 1" class="mt-3 space-y-2 border-t border-white/5 pt-3">
|
||
<template v-for="node in familyDisplayNodes(entry.group.parts)" :key="node.key">
|
||
<li
|
||
v-if="node.type === 'fork'"
|
||
class="rounded-lg border-l-2 border-amber-300/50 bg-amber-400/5 px-2 py-1.5 text-[11px] font-semibold uppercase tracking-wide text-amber-100"
|
||
:style="node.depth ? { marginLeft: `${Math.min(node.depth, 6) * 0.85}rem` } : undefined"
|
||
>
|
||
{{ node.label }}
|
||
</li>
|
||
<li
|
||
v-else
|
||
class="flex cursor-pointer flex-wrap items-start gap-3 rounded-xl border p-2"
|
||
:class="node.clip.id === reviewingClipId
|
||
? 'border-amber-300/80 bg-amber-400/15 ring-1 ring-amber-300/60'
|
||
: extendOpen && node.clip.id === extendClipId
|
||
? 'border-amber-300/40 bg-amber-400/5'
|
||
: 'border-white/5 bg-black/20'"
|
||
:style="node.depth ? { marginLeft: `${Math.min(node.depth, 6) * 0.85}rem` } : undefined"
|
||
:aria-current="node.clip.id === reviewingClipId ? 'true' : undefined"
|
||
@click="onLibraryClipPartClick(node.clip, $event)"
|
||
>
|
||
<ClipFrameThumbs
|
||
:hidden="node.clip.hideThumbnail"
|
||
:revealed="!!stitchReveal[node.clip.id]"
|
||
:still-id="node.depth === 0 ? node.clip.stillId : undefined"
|
||
:from-clip-id="previousClipIdFor(node.clip, entry.group.parts)"
|
||
:clip-id="node.clip.id"
|
||
:show-first-frame="node.depth > 0 || !node.clip.stillId || Boolean(node.clip.parentClipId)"
|
||
@update:revealed="stitchReveal[node.clip.id] = $event"
|
||
/>
|
||
<div class="min-w-0 flex-1">
|
||
<div class="flex items-start gap-2">
|
||
<input
|
||
v-if="renamingId === node.clip.id && !renamingFamily"
|
||
v-model="renameDraft"
|
||
class="min-w-0 flex-1 rounded-lg border border-amber-300/40 bg-zinc-950 px-2 py-1 text-sm outline-none ring-amber-300/40 focus:ring-2"
|
||
maxlength="80"
|
||
@keydown.enter.prevent="saveRename(node.clip)"
|
||
@keydown.escape.prevent="cancelRename"
|
||
@blur="saveRename(node.clip)"
|
||
>
|
||
<p v-else class="min-w-0 flex-1 truncate text-sm font-medium">
|
||
<span class="text-amber-100">{{ clipFamilyPartTitle(node.clip, entry.group.parts) }}</span>
|
||
<span v-if="node.fromLabel" class="ml-1.5 text-[11px] font-normal text-amber-200/80">{{ node.fromLabel }}</span>
|
||
<span v-if="node.branchLabel" class="ml-1.5 text-[11px] font-normal text-zinc-400">{{ node.branchLabel }}</span>
|
||
<span v-if="extendOpen && node.clip.id === extendClipId" class="ml-1.5 text-[10px] font-semibold uppercase tracking-wide text-amber-200">extending</span>
|
||
</p>
|
||
<PromptPreviewTip
|
||
v-if="formatPromptParts(node.clip)"
|
||
:text="formatPromptParts(node.clip)"
|
||
:aria-label="'Copy prompt for ' + (node.clip.name || 'this clip')"
|
||
@copy="copyPrompt"
|
||
/>
|
||
</div>
|
||
<p class="truncate text-xs text-zinc-500">{{ node.clip.name || node.clip.prompt }} · {{ clipMetaLine(node.clip) }}</p>
|
||
<PermanenceRefStrip
|
||
v-if="!node.clip.hideThumbnail"
|
||
class="mt-2"
|
||
:refs="node.clip.permanenceRefs || []"
|
||
:name-options="clipBindingKeys(node.clip, entry.group.head)"
|
||
cta="Add still"
|
||
:hidden="node.clip.hideInput || hideInputPreview"
|
||
@add-ref="openPermanencePicker(undefined, node.clip.id)"
|
||
@remove="(refIndex) => removeClipRef(node.clip, refIndex)"
|
||
@update-label="(refIndex, label) => updateClipRefLabel(node.clip, refIndex, label)"
|
||
/>
|
||
<div class="mt-1.5 flex flex-wrap items-center gap-3">
|
||
<button
|
||
v-if="node.clip.hideThumbnail"
|
||
type="button"
|
||
class="text-xs text-amber-300"
|
||
@click="stitchReveal[node.clip.id] = !stitchReveal[node.clip.id]"
|
||
>
|
||
{{ stitchReveal[node.clip.id] ? 'Hide frames' : 'Click to reveal' }}
|
||
</button>
|
||
<button type="button" class="text-xs text-amber-300" @click="openClip(node.clip)">Open</button>
|
||
<button type="button" class="text-xs text-amber-300" @click="useClipAsInput(node.clip)">Use as input</button>
|
||
<button type="button" class="text-xs text-amber-300" @click="openExtend(node.clip)">Extend</button>
|
||
<button type="button" class="text-xs text-amber-300" @click="startRename(node.clip)">Rename</button>
|
||
<button type="button" class="text-xs text-zinc-400" @click="toggleDetails(node.clip.id)">
|
||
{{ expandedDetails[node.clip.id] ? 'Hide details' : 'Details' }}
|
||
</button>
|
||
<select
|
||
v-if="folders.length > 1"
|
||
class="max-w-[9.5rem] rounded-lg border border-white/10 bg-zinc-950 px-1.5 py-0.5 text-xs text-amber-300"
|
||
:value="''"
|
||
@change="onMoveClip(node.clip, $event)"
|
||
>
|
||
<option disabled value="">Move to…</option>
|
||
<option
|
||
v-for="folder in folders.filter(folder => folder.id !== node.clip.folderId)"
|
||
:key="folder.id"
|
||
:value="folder.id"
|
||
>
|
||
{{ folder.name }}{{ folder.protected && !folder.unlocked ? ' · locked' : '' }}
|
||
</option>
|
||
</select>
|
||
<button type="button" class="text-xs text-red-300" @click="askRemoveClip(node.clip)">Delete</button>
|
||
</div>
|
||
<LibrarySettingsDetails
|
||
v-if="expandedDetails[node.clip.id]"
|
||
:rows="clipSettingsRows(node.clip)"
|
||
empty="No generator settings were saved with this clip."
|
||
/>
|
||
</div>
|
||
</li>
|
||
</template>
|
||
</ul>
|
||
</template>
|
||
<template v-else-if="entry.kind === 'still' && entry.stillGroup">
|
||
<div class="flex gap-3">
|
||
<button
|
||
type="button"
|
||
role="checkbox"
|
||
class="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center self-start rounded-[4px] border"
|
||
:class="selectedKeys.includes(entry.key)
|
||
? 'border-white bg-amber-400 text-zinc-950'
|
||
: 'border-white/30 bg-black/45'"
|
||
:aria-checked="selectedKeys.includes(entry.key)"
|
||
:aria-label="selectedKeys.includes(entry.key) ? 'Deselect' : 'Select'"
|
||
@click.stop="onSelectClick(entry.key, $event)"
|
||
>
|
||
<svg
|
||
v-if="selectedKeys.includes(entry.key)"
|
||
class="h-3 w-3"
|
||
viewBox="0 0 16 16"
|
||
fill="none"
|
||
aria-hidden="true"
|
||
>
|
||
<path d="M3.2 8.2 6.4 11.3 12.8 4.6" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" />
|
||
</svg>
|
||
</button>
|
||
<button type="button" class="relative h-16 w-16 shrink-0 overflow-hidden rounded-xl bg-zinc-900" @click="openStill(entry.stillGroup.head)">
|
||
<span v-if="entry.stillGroup.head.hideInput" class="sensitive-glow block h-full w-full" />
|
||
<img
|
||
v-else
|
||
:src="`/api/library/stills/${entry.stillGroup.head.id}?thumb=1`"
|
||
alt=""
|
||
loading="lazy"
|
||
decoding="async"
|
||
class="h-full w-full object-cover"
|
||
>
|
||
</button>
|
||
<div class="min-w-0 flex-1">
|
||
<div class="flex items-start gap-2">
|
||
<input
|
||
v-if="renamingId === entry.stillGroup.head.id && renamingFamily"
|
||
v-model="renameDraft"
|
||
class="min-w-0 flex-1 rounded-lg border border-amber-300/40 bg-zinc-950 px-2 py-1 text-sm outline-none ring-amber-300/40 focus:ring-2"
|
||
maxlength="80"
|
||
@keydown.enter.prevent="saveRenameStill(entry.stillGroup.head, true)"
|
||
@keydown.escape.prevent="cancelRename"
|
||
@blur="saveRenameStill(entry.stillGroup.head, true)"
|
||
>
|
||
<p v-else class="min-w-0 flex-1 truncate text-sm font-medium">{{ stillCollectionTitle(entry.stillGroup) }}</p>
|
||
<PromptPreviewTip
|
||
v-if="formatPromptParts(entry.stillGroup.head, stillFamilyScript(entry.stillGroup.parts))"
|
||
:text="formatPromptParts(entry.stillGroup.head, stillFamilyScript(entry.stillGroup.parts))"
|
||
:aria-label="'Copy prompt for ' + stillCollectionTitle(entry.stillGroup)"
|
||
@copy="copyPrompt"
|
||
/>
|
||
</div>
|
||
<p class="truncate text-xs text-zinc-500">{{ stillMetaLine(entry.stillGroup.head) }}</p>
|
||
<div class="mt-2 flex flex-wrap items-center gap-3">
|
||
<button v-if="entry.stillGroup.parts.length > 1" type="button" class="text-xs text-amber-200" @click="toggleFamily(entry.stillGroup.key)">
|
||
{{ expandedFamilies[entry.stillGroup.key] ? 'Collapse' : `Expand ${entry.stillGroup.parts.length} ${entry.stillGroup.head.settings?.mode === 'iterate' ? 'iterations' : 'passes'}` }}
|
||
</button>
|
||
<button type="button" class="text-xs text-amber-300" @click="openStill(entry.stillGroup.head)">Open</button>
|
||
<button type="button" class="text-xs text-amber-300" @click="useStillAsInput(entry.stillGroup.head)">Use as input</button>
|
||
<button
|
||
v-if="entry.stillGroup.head.role !== 'input'"
|
||
type="button"
|
||
class="text-xs text-amber-300"
|
||
:disabled="keepingInputBusy"
|
||
@click="saveStillAsInput(entry.stillGroup.head)"
|
||
>Save as input</button>
|
||
<button
|
||
v-if="backupStudioEnabled"
|
||
type="button"
|
||
class="text-xs text-amber-300"
|
||
:disabled="backupBusyId === entry.stillGroup.head.id"
|
||
@click="sendStillToBackup(entry.stillGroup.head)"
|
||
>{{ backupBusyId === entry.stillGroup.head.id ? 'Sending…' : 'Send to Backup' }}</button>
|
||
<button type="button" class="text-xs text-zinc-400" @click="toggleDetails(entry.key)">
|
||
{{ expandedDetails[entry.key] ? 'Hide details' : 'Details' }}
|
||
</button>
|
||
<a
|
||
:href="`/api/library/stills/${entry.stillGroup.head.id}`"
|
||
:download="stillTitle(entry.stillGroup.head)"
|
||
class="text-xs text-amber-300"
|
||
>Download</a>
|
||
<button type="button" class="text-xs text-amber-300" @click="startRenameStill(entry.stillGroup.head, true)">Rename</button>
|
||
<select
|
||
v-if="folders.length > 1"
|
||
class="max-w-[9.5rem] rounded-lg border border-white/10 bg-zinc-950 px-1.5 py-0.5 text-xs text-amber-300"
|
||
:value="''"
|
||
@change="onMoveStill(entry.stillGroup.head, $event, true)"
|
||
>
|
||
<option disabled value="">Move to…</option>
|
||
<option
|
||
v-for="folder in folders.filter(folder => folder.id !== entry.stillGroup.head.folderId)"
|
||
:key="folder.id"
|
||
:value="folder.id"
|
||
>
|
||
{{ folder.name }}{{ folder.protected && !folder.unlocked ? ' · locked' : '' }}
|
||
</option>
|
||
</select>
|
||
<button type="button" class="text-xs text-red-300" @click="askRemoveStill(entry.stillGroup.head, true)">Delete</button>
|
||
</div>
|
||
<LibrarySettingsDetails
|
||
v-if="expandedDetails[entry.key]"
|
||
:rows="stillSettingsRows(entry.stillGroup.head)"
|
||
empty="Settings were not saved with this still. New generations will keep them."
|
||
/>
|
||
</div>
|
||
</div>
|
||
<ul v-if="expandedFamilies[entry.stillGroup.key] && entry.stillGroup.parts.length > 1" class="mt-3 space-y-2 border-t border-white/5 pt-3">
|
||
<li
|
||
v-for="item in chronologicalStills(entry.stillGroup.parts)"
|
||
:key="item.id"
|
||
class="flex cursor-pointer gap-3 rounded-xl border p-2"
|
||
:class="item.id === reviewingStillId ? 'border-amber-300/80 bg-amber-400/15 ring-1 ring-amber-300/60' : 'border-white/5 bg-black/20'"
|
||
:aria-current="item.id === reviewingStillId ? 'true' : undefined"
|
||
@click="onLibraryStillPartClick(item, $event)"
|
||
>
|
||
<button type="button" class="relative h-12 w-12 shrink-0 overflow-hidden rounded-lg bg-zinc-900" @click="openStill(item)">
|
||
<span v-if="item.hideInput" class="sensitive-glow block h-full w-full" />
|
||
<img v-else :src="`/api/library/stills/${item.id}?thumb=1`" alt="" loading="lazy" decoding="async" class="h-full w-full object-cover">
|
||
</button>
|
||
<div class="min-w-0 flex-1">
|
||
<div class="flex items-start gap-2">
|
||
<input
|
||
v-if="renamingId === item.id && !renamingFamily"
|
||
v-model="renameDraft"
|
||
class="min-w-0 flex-1 rounded-lg border border-amber-300/40 bg-zinc-950 px-2 py-1 text-sm outline-none ring-amber-300/40 focus:ring-2"
|
||
maxlength="80"
|
||
@keydown.enter.prevent="saveRenameStill(item)"
|
||
@keydown.escape.prevent="cancelRename"
|
||
@blur="saveRenameStill(item)"
|
||
>
|
||
<p v-else class="min-w-0 flex-1 truncate text-sm font-medium">{{ stillTitle(item) }}</p>
|
||
<PromptPreviewTip
|
||
v-if="formatPromptParts(item)"
|
||
:text="formatPromptParts(item)"
|
||
:aria-label="'Copy prompt for ' + stillTitle(item)"
|
||
@copy="copyPrompt"
|
||
/>
|
||
</div>
|
||
<p class="truncate text-xs text-zinc-500">{{ stillMetaLine(item) }}</p>
|
||
<div class="mt-1.5 flex flex-wrap items-center gap-3">
|
||
<button type="button" class="text-xs text-amber-300" @click="openStill(item)">Open</button>
|
||
<button type="button" class="text-xs text-amber-300" @click="useStillAsInput(item)">Use as input</button>
|
||
<button
|
||
v-if="item.role !== 'input'"
|
||
type="button"
|
||
class="text-xs text-amber-300"
|
||
:disabled="keepingInputBusy"
|
||
@click="saveStillAsInput(item)"
|
||
>Save as input</button>
|
||
<button
|
||
v-if="backupStudioEnabled"
|
||
type="button"
|
||
class="text-xs text-amber-300"
|
||
:disabled="backupBusyId === item.id"
|
||
@click="sendStillToBackup(item)"
|
||
>{{ backupBusyId === item.id ? 'Sending…' : 'Send to Backup' }}</button>
|
||
<button type="button" class="text-xs text-zinc-400" @click="toggleDetails(item.id)">
|
||
{{ expandedDetails[item.id] ? 'Hide details' : 'Details' }}
|
||
</button>
|
||
<a :href="`/api/library/stills/${item.id}`" :download="stillTitle(item)" class="text-xs text-amber-300">Download</a>
|
||
<button type="button" class="text-xs text-amber-300" @click="startRenameStill(item)">Rename</button>
|
||
<select
|
||
v-if="folders.length > 1"
|
||
class="max-w-[9.5rem] rounded-lg border border-white/10 bg-zinc-950 px-1.5 py-0.5 text-xs text-amber-300"
|
||
:value="''"
|
||
@change="onMoveStill(item, $event)"
|
||
>
|
||
<option disabled value="">Move to…</option>
|
||
<option
|
||
v-for="folder in folders.filter(folder => folder.id !== item.folderId)"
|
||
:key="folder.id"
|
||
:value="folder.id"
|
||
>
|
||
{{ folder.name }}{{ folder.protected && !folder.unlocked ? ' · locked' : '' }}
|
||
</option>
|
||
</select>
|
||
<button type="button" class="text-xs text-red-300" @click="askRemoveStill(item)">Delete</button>
|
||
</div>
|
||
<LibrarySettingsDetails
|
||
v-if="expandedDetails[item.id]"
|
||
:rows="stillSettingsRows(item)"
|
||
empty="Settings were not saved with this still. New generations will keep them."
|
||
/>
|
||
</div>
|
||
</li>
|
||
</ul>
|
||
</template>
|
||
</li>
|
||
</ul>
|
||
<p v-if="libraryCapped" class="mt-3 text-xs text-zinc-500">
|
||
Showing {{ visibleLibraryEntries.length }} of {{ libraryEntries.length }}.
|
||
<NuxtLink to="/library" class="text-amber-300 hover:text-amber-100">Open full library</NuxtLink>
|
||
</p>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</main>
|
||
|
||
<ImageBatchModal
|
||
:open="imageBatchOpen"
|
||
v-model:text="imageBatchDraft"
|
||
@close="imageBatchOpen = false"
|
||
@apply="applyImageBatch"
|
||
/>
|
||
<ImagePickerModal
|
||
:open="pickerOpen"
|
||
:folders="folders"
|
||
:stills="stills"
|
||
:folder-id="folderId"
|
||
:hide-input-preview="hideInputPreview"
|
||
:privacy="showPrivacyToggles"
|
||
:heading="pickerHeading"
|
||
:still-only="permanencePicker"
|
||
@close="pickerOpen = false"
|
||
@updated="applyLibrary"
|
||
@error="toast"
|
||
@select="onPickedImage"
|
||
@pick="onPickedPermanence"
|
||
/>
|
||
|
||
<Teleport to="body">
|
||
<div
|
||
v-if="editLightboxUrl"
|
||
ref="editLightbox"
|
||
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/85 p-4"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label="Edited still fullscreen"
|
||
@click.self="closeEditLightbox"
|
||
>
|
||
<div class="relative max-h-[90vh] max-w-[min(96vw,80rem)]" @click.stop>
|
||
<img
|
||
:src="editLightboxUrl"
|
||
alt="Edited still"
|
||
class="max-h-[90vh] max-w-[min(96vw,80rem)] object-contain"
|
||
decoding="async"
|
||
>
|
||
<FamilyNav
|
||
:count="outputFamilyStills.length"
|
||
:index="outputFamilyIndex"
|
||
@step="stepOutputFamily"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</Teleport>
|
||
|
||
<Teleport to="body">
|
||
<div
|
||
v-if="pendingLibraryDelete"
|
||
class="fixed inset-0 z-[70] flex items-center justify-center bg-black/70 p-4"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="library-delete-title"
|
||
@click.self="pendingLibraryDelete = null"
|
||
>
|
||
<div class="glass w-full max-w-md rounded-3xl p-5 sm:p-6">
|
||
<h2 id="library-delete-title" class="font-display text-xl font-bold">{{ pendingLibraryDelete.title }}</h2>
|
||
<p class="mt-2 text-sm font-medium text-zinc-100">{{ pendingLibraryDelete.name }}</p>
|
||
<p v-if="pendingLibraryDelete.detail" class="mt-1 text-xs text-zinc-500">{{ pendingLibraryDelete.detail }}</p>
|
||
<p class="mt-3 text-sm text-zinc-400">{{ pendingLibraryDelete.consequence }}</p>
|
||
<div class="mt-5 flex flex-wrap justify-end gap-2">
|
||
<button
|
||
type="button"
|
||
class="rounded-full border border-white/15 px-4 py-2 text-sm text-zinc-300 hover:text-white"
|
||
@click="pendingLibraryDelete = null"
|
||
>
|
||
Keep it
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="rounded-full border border-red-400/40 bg-red-400/10 px-4 py-2 text-sm font-semibold text-red-200 hover:border-red-300 hover:text-red-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||
:disabled="libraryDeleteBusy"
|
||
@click="confirmLibraryDelete"
|
||
>
|
||
Delete
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Teleport>
|
||
|
||
<QueuedJobModal
|
||
:job-id="inspectJobId"
|
||
@close="inspectJobId = ''"
|
||
@saved="refreshStudioQueue"
|
||
@loaded="onQueuedJobLoaded"
|
||
/>
|
||
|
||
<div class="pointer-events-none fixed bottom-4 right-4 z-50 space-y-2">
|
||
<div
|
||
v-for="toast in toasts"
|
||
:key="toast.id"
|
||
class="pointer-events-auto max-w-[min(20rem,calc(100vw-2rem))] rounded-2xl border border-red-400/30 bg-zinc-950/90 px-4 py-3 text-sm text-red-100 shadow-2xl [overflow-wrap:anywhere]"
|
||
>
|
||
{{ toast.message }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { imageModeForInput, videoStartForInput } from '~/utils/generationInput'
|
||
import { normalizeImageIterations, type ImageIteration } from '~/utils/imageIterations'
|
||
import { composePromptParts, formatPromptParts, resolvePromptWrappers, restorePromptParts } from '~/utils/promptParts'
|
||
import { parseEditPasses, looksLikeShotScript } from '~/utils/parseRecommend'
|
||
import { parseImageBatchScript, parseShotScript } from '~/utils/parseShots'
|
||
import type { DirectorScript } from '~/utils/directorScripts'
|
||
import {
|
||
composeShotPrompt,
|
||
extractGlobalLocks,
|
||
isBlankGlobalLocks,
|
||
mergePermanenceRefs,
|
||
nextPermanenceLabel,
|
||
parseLockBindingKeys,
|
||
permanenceLabels,
|
||
sanitizeRefLabel,
|
||
shouldForceLastFrameI2V,
|
||
type PermanenceRef
|
||
} from '~/utils/globalLocks'
|
||
import {
|
||
filterLoraStackForImageEngine,
|
||
filterLoraStackForStudio,
|
||
filterLorasForImageEngine,
|
||
filterLorasForUse,
|
||
insertActivationInShotScript,
|
||
insertActivationPhrase,
|
||
imageLoraEngineMismatchMessage,
|
||
imageLoraEngineOf,
|
||
loraIdentityKey,
|
||
loraStacksEqual,
|
||
normalizeLoraStack,
|
||
persistLoraFields,
|
||
readLoraStack,
|
||
type LoraStackItem
|
||
} from '~/utils/loras'
|
||
import {
|
||
clampImageCfg,
|
||
clampImageScaleMegapixels,
|
||
clampImageSteps,
|
||
clampVideoCfg,
|
||
IMAGE_CFG_MAX,
|
||
IMAGE_CFG_MIN,
|
||
IMAGE_CFG_STEP,
|
||
IMAGE_SCALE_MP_DEFAULT,
|
||
IMAGE_SCALE_MP_MAX,
|
||
IMAGE_SCALE_MP_MIN,
|
||
IMAGE_SCALE_MP_STEP,
|
||
IMAGE_STEPS_MAX,
|
||
IMAGE_STEPS_MIN,
|
||
type ImagePassMode,
|
||
VIDEO_CFG_MAX,
|
||
VIDEO_CFG_MIN,
|
||
VIDEO_CFG_STEP,
|
||
type GenerationPreset
|
||
} from '~/utils/generationPresets'
|
||
import { LOAD_GENERATION_PAYLOAD_KEY, LOAD_QUEUED_JOB_KEY } from '~/utils/queuedJob'
|
||
import {
|
||
clipSettingsRows,
|
||
inferStillPipeline,
|
||
stillModeLabel,
|
||
stillSettingsRows,
|
||
type StillGenerationSettings
|
||
} from '~/utils/libraryGeneration'
|
||
import { parseImageAspect, generateSizeForAspect } from '~/utils/imageAspect'
|
||
import { buildOutputBaseName, nextUniqueName } from '~/utils/outputNames'
|
||
import { emptyKeepPrompts, insertKeepSnippet, normalizeKeepPrompts, type KeepPrompt, type KeepPromptLists } from '~/utils/keepPrompts'
|
||
import {
|
||
clipFamilyPartTitle,
|
||
clipPartLabel,
|
||
familyActivityAt,
|
||
familyBaseName,
|
||
familyBranchSummary,
|
||
familyDisplayNodes,
|
||
familyRootClip,
|
||
nextClipPartName
|
||
} from '~/utils/libraryBrowse'
|
||
import {
|
||
IMAGE_V2_CFG_DEFAULT,
|
||
IMAGE_V2_CONSISTENCY_LORA,
|
||
IMAGE_V2_DENOISE_DEFAULT,
|
||
IMAGE_V2_DENOISE_MAX,
|
||
IMAGE_V2_DENOISE_MIN,
|
||
IMAGE_V2_DENOISE_STEP,
|
||
IMAGE_V2_GENERATE_HEIGHT,
|
||
IMAGE_V2_GENERATE_WIDTH,
|
||
IMAGE_V2_KREA_CFG,
|
||
IMAGE_V2_KREA_COMPOSE_DENOISE,
|
||
IMAGE_V2_KREA_CONCEPT_LORA,
|
||
IMAGE_V2_KREA_EDIT_DENOISE,
|
||
IMAGE_V2_KREA_STEPS,
|
||
IMAGE_V2_REFINE_FACE,
|
||
IMAGE_V2_REFINE_FACE_PROMPT,
|
||
IMAGE_V2_REFINE_HAND,
|
||
IMAGE_V2_REFINE_HEAVY,
|
||
IMAGE_V2_SNOFS_CLIP,
|
||
IMAGE_V2_SNOFS_LORA,
|
||
IMAGE_V2_SNOFS_MODEL,
|
||
IMAGE_V2_STEPS_DEFAULT,
|
||
clampImageV2Denoise,
|
||
defaultImageV2LoraStack,
|
||
imageV2ConceptLoraName,
|
||
isImageV2ConceptLora,
|
||
isImageV2ConsistencyLora,
|
||
mergeLegacyImageV2Loras,
|
||
parseImageV2Engine,
|
||
removeImageV2ConceptLora,
|
||
upsertImageV2Lora,
|
||
type ImageV2Engine,
|
||
type ImageV2Mode,
|
||
type ImageV2PresetSettings,
|
||
type ImageV2Task
|
||
} from '~/utils/imageV2'
|
||
import {
|
||
coerceVideoWorkflow,
|
||
composeVideoWorkflow,
|
||
isLtxWorkflow,
|
||
isTextToVideo,
|
||
isXaigenStudio,
|
||
ltxWorkflowEnabled,
|
||
minimaxGraphOf,
|
||
parseVideoWorkflow,
|
||
videoEngineOf,
|
||
type MiniMaxGraphId,
|
||
type VideoEngineId,
|
||
type VideoStartId
|
||
} from '~/utils/videoModels'
|
||
import {
|
||
VIDEO_DURATION_DEFAULT,
|
||
clampVideoDuration,
|
||
clipPartDuration,
|
||
defaultVideoDurationPref,
|
||
setDefaultVideoDurationPref
|
||
} from '~/utils/videoDuration'
|
||
import {
|
||
clampRefinementDenoise,
|
||
REFINEMENT_DENOISE_DEFAULT,
|
||
REFINEMENT_DENOISE_MAX,
|
||
REFINEMENT_DENOISE_MIN
|
||
} from '~/utils/extensionFrame'
|
||
|
||
const runtimeConfig = useRuntimeConfig()
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
|
||
type AspectId = 'auto' | '16:9' | '9:16' | '1:1'
|
||
|
||
interface LibraryFolder {
|
||
id: string
|
||
name: string
|
||
createdAt: number
|
||
thumbnailDefault: boolean
|
||
protected: boolean
|
||
unlocked: boolean
|
||
clipCount?: number
|
||
imageCount?: number
|
||
}
|
||
|
||
interface LibraryStill {
|
||
id: string
|
||
folderId: string
|
||
filename: string
|
||
width: number
|
||
height: number
|
||
createdAt: number
|
||
hideInput?: boolean
|
||
role?: 'input' | 'output'
|
||
name?: string
|
||
prompt?: string
|
||
promptPre?: string
|
||
promptPost?: string
|
||
familyId?: string
|
||
parentStillId?: string
|
||
chainIndex?: number
|
||
settings?: StillGenerationSettings
|
||
}
|
||
|
||
interface LibraryClip {
|
||
id: string
|
||
folderId: string
|
||
name?: string
|
||
prompt: string
|
||
aspect: string
|
||
width: number
|
||
height: number
|
||
steps: number
|
||
turbo: boolean
|
||
seed: number
|
||
hideThumbnail: boolean
|
||
hasThumbnail?: boolean
|
||
hasVideo?: boolean
|
||
createdAt: number
|
||
cfg?: number
|
||
fps?: number
|
||
samplerName?: string
|
||
scheduler?: string
|
||
duration?: number
|
||
familyId?: string
|
||
parentClipId?: string
|
||
chainIndex?: number
|
||
workflow?: string
|
||
stillId?: string
|
||
sound?: boolean
|
||
hideInput?: boolean
|
||
globalLocks?: string
|
||
permanenceRefs?: PermanenceRef[]
|
||
loraName?: string
|
||
loraStack?: LoraStackItem[]
|
||
promptPre?: string
|
||
promptPost?: string
|
||
}
|
||
|
||
interface RetryDraft {
|
||
id: string
|
||
createdAt: number
|
||
folderId: string
|
||
stillId?: string
|
||
stillFilename?: string
|
||
name?: string
|
||
prompt: string
|
||
promptPre?: string
|
||
promptPost?: string
|
||
aspect: string
|
||
width: number
|
||
height: number
|
||
steps: number
|
||
turbo: boolean
|
||
seed: number
|
||
duration: number
|
||
hideThumbnail: boolean
|
||
sound: boolean
|
||
hideInput?: boolean
|
||
cfg?: number
|
||
fps?: number
|
||
samplerName?: string
|
||
scheduler?: string
|
||
extensions?: { prompt: string; promptPre?: string; promptPost?: string; duration: number; permanenceRefs?: PermanenceRef[]; loraName?: string; loraStack?: LoraStackItem[] }[]
|
||
workflow?: string
|
||
globalLocks?: string
|
||
permanenceRefs?: PermanenceRef[]
|
||
shotPermanenceRefs?: PermanenceRef[][]
|
||
loraName?: string
|
||
loraStack?: LoraStackItem[]
|
||
shotLoras?: string[]
|
||
shotLoraStacks?: LoraStackItem[][]
|
||
}
|
||
|
||
interface QueuedExtension {
|
||
id: string
|
||
prompt: string
|
||
promptPre: string
|
||
promptPost: string
|
||
duration: number
|
||
loraName?: string
|
||
loraStack?: LoraStackItem[]
|
||
}
|
||
|
||
interface QueuedEditPass extends ImageIteration {
|
||
id: string
|
||
prompt: string
|
||
}
|
||
|
||
type PendingLibraryDelete = {
|
||
title: string
|
||
name: string
|
||
detail: string
|
||
consequence: string
|
||
mode: 'clip' | 'still' | 'selected'
|
||
clip?: LibraryClip
|
||
still?: LibraryStill
|
||
family?: boolean
|
||
}
|
||
|
||
const PRESETS: Record<Exclude<AspectId, 'auto'>, { width: number; height: number }> = {
|
||
'16:9': { width: 1344, height: 768 },
|
||
'9:16': { width: 768, height: 1344 },
|
||
'1:1': { width: 960, height: 960 }
|
||
}
|
||
|
||
const aspects = [
|
||
{ id: 'auto' as const, label: 'Automatic', hint: 'Match the still' },
|
||
{ id: '16:9' as const, label: '16:9 Landscape', hint: '1344 × 768' },
|
||
{ id: '9:16' as const, label: '9:16 Portrait / Shorts', hint: '768 × 1344' },
|
||
{ id: '1:1' as const, label: '1:1 Square', hint: '960 × 960' }
|
||
]
|
||
const imageAspects = computed(() => {
|
||
const generate = studioMode.value === 'editv2' && v2Mode.value === 'generate'
|
||
return [
|
||
{ id: 'auto' as const, label: 'Automatic', hint: generate ? '1:1 · 1024 × 1024' : 'Keep the still’s shape' },
|
||
{ id: '16:9' as const, label: '16:9 Landscape', hint: generate ? '1280 × 768' : 'Fill extra edges — no stretch' },
|
||
{ id: '9:16' as const, label: '9:16 Portrait', hint: generate ? '768 × 1280' : 'Fill extra edges — no stretch' },
|
||
{ id: '1:1' as const, label: '1:1 Square', hint: generate ? '1024 × 1024' : 'Fill extra edges — no stretch' }
|
||
]
|
||
})
|
||
const imageFillWithoutPrompt = computed(() => (
|
||
aspect.value !== 'auto'
|
||
&& (studioMode.value === 'edit' || (studioMode.value === 'editv2' && v2Mode.value !== 'generate'))
|
||
))
|
||
|
||
const player = ref<HTMLVideoElement | null>(null)
|
||
const outputPanel = ref<HTMLElement | null>(null)
|
||
const editLightbox = ref<HTMLElement | null>(null)
|
||
const editLightboxUrl = ref('')
|
||
const file = ref<File | null>(null)
|
||
const inputStillId = ref('')
|
||
const keepingInputBusy = ref(false)
|
||
const renamingInputStill = ref(false)
|
||
const inputStillNameDraft = ref('')
|
||
const inputStillRenameEl = ref<HTMLInputElement | null>(null)
|
||
const preview = ref('')
|
||
const dragging = ref(false)
|
||
const prompt = ref('')
|
||
const promptPre = ref('')
|
||
const promptPost = ref('')
|
||
const backupBusyId = ref('')
|
||
const backupStudioEnabled = computed(() => Boolean(runtimeConfig.public.backupStudioEnabled))
|
||
const shotScriptMode = ref(false)
|
||
const directorScripts = ref<DirectorScript[]>([])
|
||
const directorScriptId = ref('')
|
||
const directorScriptName = ref('')
|
||
const DIRECTOR_IMPORT_KEY = 'aigen-director-import'
|
||
const recommendPresetId = ref('enhance')
|
||
const promptPresets = ref<Array<{ id: string; name: string; description?: string; builtin?: boolean }>>([
|
||
{ id: 'enhance', name: 'Enhance', builtin: true },
|
||
{ id: 'shot-script', name: 'Shot script', builtin: true }
|
||
])
|
||
const visiblePromptPresets = computed(() => {
|
||
if (studioMode.value === 'video') return promptPresets.value
|
||
return promptPresets.value.filter(item => item.id !== 'shot-script')
|
||
})
|
||
const recommendBusy = ref(false)
|
||
const recommendText = ref('')
|
||
const recommendError = ref('')
|
||
const recommendMessage = ref('')
|
||
const recommendJobId = ref('')
|
||
const recommendApplied = ref(false)
|
||
const promptBeforeRecommend = ref('')
|
||
const editPassesBeforeRecommend = ref<QueuedEditPass[]>([])
|
||
const imageBatchBeforeRecommend = ref('')
|
||
const shotScriptBeforeRecommend = ref(false)
|
||
const extensionQueue = ref<QueuedExtension[]>([])
|
||
const editPassQueue = ref<QueuedEditPass[]>([])
|
||
const imagePassMode = ref<ImagePassMode>('batch')
|
||
const imageBatchOpen = ref(false)
|
||
const imageBatchScript = ref('')
|
||
const imageBatchDraft = ref('')
|
||
const clipName = ref('')
|
||
const videoDownloadName = ref('output')
|
||
const editDownloadName = ref('edited')
|
||
const aspect = ref<AspectId>('auto')
|
||
const imageWidth = ref(0)
|
||
const imageHeight = ref(0)
|
||
const turbo = ref(true)
|
||
const videoLoraStack = ref<LoraStackItem[]>([])
|
||
const imageLoraStack = ref<LoraStackItem[]>([])
|
||
const imageV2LoraStack = ref<LoraStackItem[]>([])
|
||
const videoLoras = ref<string[]>([])
|
||
const imageLoras = ref<string[]>([])
|
||
const shotLoraStacks = ref<Record<number, LoraStackItem[]>>({})
|
||
const extendLoraStack = ref<LoraStackItem[]>([])
|
||
const videoGenerationPresets = ref<GenerationPreset[]>([])
|
||
const imageGenerationPresets = ref<GenerationPreset[]>([])
|
||
const imageV2GenerationPresets = ref<GenerationPreset[]>([])
|
||
const videoPresetId = ref('')
|
||
const imagePresetId = ref('')
|
||
const imageV2PresetId = ref('')
|
||
const videoPresetName = ref('')
|
||
const imagePresetName = ref('')
|
||
const imageV2PresetName = ref('')
|
||
const CFG_MIN = VIDEO_CFG_MIN
|
||
const CFG_MAX = VIDEO_CFG_MAX
|
||
const CFG_STEP = VIDEO_CFG_STEP
|
||
const CFG_TURBO = 1.5
|
||
const CFG_QUALITY = 4
|
||
const cfg = ref(CFG_TURBO)
|
||
const cfgTouched = ref(false)
|
||
const fps = ref(24)
|
||
const samplerName = ref('res_multistep')
|
||
const scheduler = ref('simple')
|
||
const duration = ref(VIDEO_DURATION_DEFAULT)
|
||
const seedInput = ref('random')
|
||
const inputSeed = ref('')
|
||
|
||
function rememberInputSeed(raw: unknown) {
|
||
const value = Number(raw)
|
||
inputSeed.value = Number.isFinite(value) ? String(Math.round(value)) : ''
|
||
}
|
||
|
||
function applyInputSeed() {
|
||
if (!inputSeed.value) return
|
||
seedInput.value = inputSeed.value
|
||
}
|
||
const withSound = ref(true)
|
||
const hideThumbnail = ref(false)
|
||
const hideInputPreview = ref(false)
|
||
const autoplayEnabled = ref(false)
|
||
const JOB_STORE = 'aigen-active-job'
|
||
const EDIT_JOB_STORE = 'aigen-active-edit-job'
|
||
const QUEUE_STORE = 'aigen-active-queue'
|
||
const videoBusy = ref(false)
|
||
const editBusy = ref(false)
|
||
const studioMode = ref('video')
|
||
const outputFocus = ref<'video' | 'edit'>('video')
|
||
const showLegacyMinimaxV1 = false
|
||
const imageStudio = computed(() => studioMode.value === 'edit' || studioMode.value === 'editv2')
|
||
|
||
function selectStudioKind(kind: 'video' | 'image') {
|
||
if (kind === 'video') {
|
||
studioMode.value = 'video'
|
||
outputFocus.value = 'video'
|
||
} else {
|
||
studioMode.value = 'editv2'
|
||
outputFocus.value = 'edit'
|
||
}
|
||
const studio = kind === 'video' ? 'video' : 'image'
|
||
if (String(route.query.studio || '') !== studio) {
|
||
void router.replace({ path: '/', query: { ...route.query, studio } })
|
||
}
|
||
}
|
||
|
||
function applyStudioQueryFromRoute() {
|
||
const studio = String(route.query.studio || '').toLowerCase()
|
||
if (studio === 'image') selectStudioKind('image')
|
||
else if (studio === 'video') selectStudioKind('video')
|
||
}
|
||
const imageComfyOk = ref(false)
|
||
const imageComfyBusy = ref(false)
|
||
const imageComfyConfigured = ref(false)
|
||
const imageComfyHost = ref('')
|
||
const imageBeastOk = ref(false)
|
||
const imageBeastBusy = ref(false)
|
||
const imageBeastHost = ref('')
|
||
const imageSidecarOk = ref(false)
|
||
const imageSidecarBusy = ref(false)
|
||
const imageSidecarConfigured = ref(false)
|
||
const imageSidecarHost = ref('')
|
||
const progress = ref(0)
|
||
const statusMessage = ref('Waiting for a still.')
|
||
const statusBusy = ref(false)
|
||
const step = ref(0)
|
||
const maxStep = ref(8)
|
||
const chainStep = ref(1)
|
||
const chainTotal = ref(1)
|
||
const chainLabel = ref('')
|
||
const overallProgress = ref(0)
|
||
const completedChainStep = ref(0)
|
||
const activeChainPlan = ref<{ label: string; prompt: string; duration: number }[]>([])
|
||
const expandedFamilies = ref<Record<string, boolean>>({})
|
||
const expandedDetails = ref<Record<string, boolean>>({})
|
||
const stitchReveal = reactive<Record<string, boolean>>({})
|
||
const elapsedMs = ref(0)
|
||
const videoUrl = ref('')
|
||
const videoSeekable = ref(true)
|
||
const editResultUrl = ref('')
|
||
const editProgress = ref(0)
|
||
const editStatusMessage = ref('Waiting for a still.')
|
||
const editStatusBusy = ref(false)
|
||
const editStep = ref(0)
|
||
const editMaxStep = ref(20)
|
||
const editElapsedMs = ref(0)
|
||
const editAwaitingReveal = ref(false)
|
||
const editLockedSave = ref(false)
|
||
const editSteps = ref(20)
|
||
const editCfg = ref(1)
|
||
const editNegative = ref('')
|
||
const editScaleToTotalPixels = ref(false)
|
||
const editScaleMegapixels = ref(1)
|
||
const editRefFile = ref<File | null>(null)
|
||
const editRefPreview = ref('')
|
||
const selectedV2Mode = ref<ImageV2Mode>('edit')
|
||
const v2Mode = computed<ImageV2Mode>({
|
||
get: () => imageModeForInput(selectedV2Mode.value, Boolean(file.value), editPassQueue.value.length > 0),
|
||
set: mode => { selectedV2Mode.value = mode }
|
||
})
|
||
const v2Engine = ref<ImageV2Engine>('flux')
|
||
const v2Task = ref<ImageV2Task>('scene')
|
||
const v2Negative = ref('')
|
||
const v2Steps = ref(IMAGE_V2_STEPS_DEFAULT)
|
||
const v2Cfg = ref(IMAGE_V2_CFG_DEFAULT)
|
||
const v2Megapixels = ref(IMAGE_SCALE_MP_DEFAULT)
|
||
const v2ScaleOn = ref(true)
|
||
const v2Turbo = ref(false)
|
||
const v2Width = ref(IMAGE_V2_GENERATE_WIDTH)
|
||
const v2Height = ref(IMAGE_V2_GENERATE_HEIGHT)
|
||
const v2Denoise = ref(IMAGE_V2_DENOISE_DEFAULT)
|
||
const refineMaskDirty = ref(false)
|
||
const refinePainter = ref<{ exportPng: () => Promise<Blob | null>; clear: () => void } | null>(null)
|
||
const lastV2StillId = ref('')
|
||
const v2JobPending = ref(false)
|
||
const editChainStep = ref(1)
|
||
const editChainTotal = ref(1)
|
||
const editChainLabel = ref('')
|
||
const editOverallProgress = ref(0)
|
||
const editCompletedChainStep = ref(0)
|
||
const editActiveChainPlan = ref<{ label: string; prompt: string }[]>([])
|
||
const videoJobId = ref('')
|
||
const editJobId = ref('')
|
||
const currentClipId = ref('')
|
||
const extendOpen = ref(false)
|
||
const extendClipId = ref('')
|
||
const extendPrompt = ref('')
|
||
const extendPromptPre = ref('')
|
||
const extendPromptPost = ref('')
|
||
const extendDuration = ref(VIDEO_DURATION_DEFAULT)
|
||
const lastFrameUrl = ref('')
|
||
const lastFrameLoading = ref(false)
|
||
const lastFrameRevealed = ref(false)
|
||
const watchingLibrary = ref(false)
|
||
let lastFrameObjectUrl = ''
|
||
let lastFrameGen = 0
|
||
const awaitingReveal = ref(false)
|
||
const concealOutput = ref(false)
|
||
const concealEditOutput = ref(false)
|
||
const lockedSave = ref(false)
|
||
const playerSize = ref({ width: 1344, height: 768 })
|
||
const toasts = ref<{ id: string; message: string }[]>([])
|
||
const comfyOk = ref(false)
|
||
const comfyBusy = ref(false)
|
||
const comfyAsleep = ref(false)
|
||
const gpuWaking = ref(false)
|
||
const gpuWakeError = ref('')
|
||
const instanceName = ref(String(runtimeConfig.public.instanceName || 'AIGen'))
|
||
const ltxEnabled = computed(() => ltxWorkflowEnabled({
|
||
studio: runtimeConfig.public.studio,
|
||
instanceName: instanceName.value
|
||
}))
|
||
const showPrivacyToggles = computed(() => isXaigenStudio({
|
||
studio: runtimeConfig.public.studio,
|
||
instanceName: instanceName.value
|
||
}))
|
||
const v2ConceptLoraEnabled = computed(() => isXaigenStudio({
|
||
studio: runtimeConfig.public.studio,
|
||
instanceName: instanceName.value
|
||
}))
|
||
|
||
function imageV2EngineLoras(stack?: unknown) {
|
||
return filterLoraStackForImageEngine(
|
||
filterLoraStackForStudio(stack ?? imageV2LoraStack.value, ltxEnabled.value),
|
||
v2Engine.value
|
||
)
|
||
}
|
||
|
||
function imageV2ConceptName() {
|
||
return imageV2ConceptLoraName(
|
||
v2Engine.value,
|
||
String(runtimeConfig.public.kreaConceptLora || IMAGE_V2_KREA_CONCEPT_LORA || '').trim()
|
||
)
|
||
}
|
||
|
||
function seedImageV2LorasIfEmpty() {
|
||
if (imageV2LoraStack.value.length) return
|
||
try {
|
||
if (localStorage.getItem(imageV2LoraStoreKey(v2Engine.value)) != null) return
|
||
} catch { /* ignore */ }
|
||
imageV2LoraStack.value = defaultImageV2LoraStack({
|
||
engine: v2Engine.value,
|
||
mode: v2Mode.value,
|
||
xaigen: v2ConceptLoraEnabled.value,
|
||
kreaConcept: String(runtimeConfig.public.kreaConceptLora || IMAGE_V2_KREA_CONCEPT_LORA || '').trim()
|
||
})
|
||
}
|
||
const folders = ref<LibraryFolder[]>([])
|
||
const clips = ref<LibraryClip[]>([])
|
||
const stills = ref<LibraryStill[]>([])
|
||
const drafts = ref<RetryDraft[]>([])
|
||
const loraAliases = ref<Record<string, string>>({})
|
||
const loraTriggers = ref<Record<string, string>>({})
|
||
const keepPrompts = ref<KeepPromptLists>(emptyKeepPrompts())
|
||
const activeKeepPrompts = computed(() => studioMode.value === 'video' ? keepPrompts.value.video : keepPrompts.value.image)
|
||
const keepPromptHeading = computed(() => studioMode.value === 'video' ? 'Video prompts' : 'Image prompts')
|
||
const folderId = ref('')
|
||
const browseFolderId = ref('')
|
||
const renamingId = ref('')
|
||
const renamingFamily = ref(false)
|
||
const renameDraft = ref('')
|
||
const renameInput = ref<HTMLInputElement | null>(null)
|
||
const unlockPassword = ref('')
|
||
const queueAutoRun = ref(false)
|
||
const refineExtensionFrame = ref(true)
|
||
const saveLosslessAnchor = ref(false)
|
||
const refinementDenoise = ref(REFINEMENT_DENOISE_DEFAULT)
|
||
const queuePendingCount = ref(0)
|
||
const activeQueueId = ref('')
|
||
const liveQueue = ref<{
|
||
id: string
|
||
status: string
|
||
pendingCount: number
|
||
completedCount: number
|
||
totalCount: number
|
||
segments: Array<{ index: number; prompt: string; duration: number; status: string; clipId?: string }>
|
||
} | null>(null)
|
||
const studioJobs = ref<Array<{
|
||
id: string
|
||
status: string
|
||
kind?: string
|
||
name: string
|
||
prompt: string
|
||
shotCount: number
|
||
liveJobId?: string
|
||
shotQueueId?: string
|
||
stillId?: string
|
||
hideThumbnail?: boolean
|
||
folderLocked?: boolean
|
||
cutIn?: boolean
|
||
holdForCutIn?: boolean
|
||
lastError?: string
|
||
completedCount?: number
|
||
pendingCount?: number
|
||
imagePipeline?: string
|
||
}>>([])
|
||
const inspectJobId = ref('')
|
||
const outputStudioJobId = ref('')
|
||
const videoAwaitingBeast = ref(false)
|
||
const pickerOpen = ref(false)
|
||
const pickerKind = ref<'image' | 'permanence'>('image')
|
||
const pickerSlot = ref<'main' | 'editRef' | number | 'perm' | `perm:${number}` | `clip:${string}`>('main')
|
||
const libraryFilter = ref<'all' | 'images' | 'videos'>('all')
|
||
const selectedKeys = ref<string[]>([])
|
||
const lastSelectedKey = ref('')
|
||
const pendingLibraryDelete = ref<PendingLibraryDelete | null>(null)
|
||
const libraryDeleteBusy = ref(false)
|
||
const videoEngine = ref<VideoEngineId>('minimax')
|
||
const videoStart = computed<VideoStartId>(() => videoStartForInput(Boolean(file.value)))
|
||
const minimaxGraph = ref<MiniMaxGraphId>('v2')
|
||
const videoWorkflow = computed(() => composeVideoWorkflow(videoEngine.value, videoStart.value, minimaxGraph.value))
|
||
const textToVideo = computed(() => isTextToVideo(videoWorkflow.value))
|
||
const ltxVideo = computed(() => isLtxWorkflow(videoWorkflow.value))
|
||
const videoLoraOptions = computed(() => mergeLoraOptions(
|
||
filterLorasForUse(videoLoras.value, ltxVideo.value ? 'ltx' : 'minimax'),
|
||
[
|
||
...videoLoraStack.value.map(item => item.name),
|
||
...extendLoraStack.value.map(item => item.name),
|
||
...Object.values(shotLoraStacks.value).flatMap(stack => stack.map(item => item.name)),
|
||
...extensionQueue.value.flatMap(item => (item.loraStack || []).map(entry => entry.name))
|
||
]
|
||
))
|
||
const imageLoraOptions = computed(() => mergeLoraOptions(
|
||
filterLorasForUse(imageLoras.value, 'flux'),
|
||
imageLoraStack.value.map(item => item.name)
|
||
))
|
||
const imageV2ConceptFile = computed(() => imageV2ConceptName())
|
||
const imageV2SnofsOn = computed(() => imageV2LoraStack.value.some(item => isImageV2ConceptLora(item.name, v2Engine.value)))
|
||
const imageV2LoraOptions = computed(() => mergeLoraOptions(
|
||
filterLorasForUse(filterLorasForImageEngine(imageLoras.value, v2Engine.value), v2Engine.value),
|
||
filterLorasForImageEngine([
|
||
...imageV2LoraStack.value.map(item => item.name),
|
||
...(v2ConceptLoraEnabled.value && imageV2ConceptFile.value ? [imageV2ConceptFile.value] : [])
|
||
], v2Engine.value)
|
||
))
|
||
const autoOutputName = computed(() => {
|
||
const engine = studioMode.value === 'editv2'
|
||
? v2Engine.value
|
||
: studioMode.value === 'edit'
|
||
? 'klein'
|
||
: videoEngine.value
|
||
const stack = studioMode.value === 'editv2'
|
||
? imageV2EngineLoras()
|
||
: studioMode.value === 'edit'
|
||
? imageLoraStack.value
|
||
: videoLoraStack.value
|
||
const base = buildOutputBaseName({
|
||
inputFilename: file.value?.name || '',
|
||
engine,
|
||
loraStack: stack,
|
||
aliases: loraAliases.value
|
||
})
|
||
const existing = [
|
||
...stills.value.filter(item => item.folderId === folderId.value).map(item => item.name || ''),
|
||
...clips.value.filter(item => item.folderId === folderId.value).map(item => item.name || '')
|
||
]
|
||
return nextUniqueName(existing, base)
|
||
})
|
||
const identityRefs = ref<(File | null)[]>([null, null, null, null])
|
||
const identityPreviews = ref(['', '', '', ''])
|
||
const globalLocks = ref('')
|
||
const familyPermanenceRefs = ref<PermanenceRef[]>([])
|
||
const shotPermanenceRefs = ref<Record<number, PermanenceRef[]>>({})
|
||
const LOCKS_STORE = 'aigen-global-locks'
|
||
const REFS_STORE = 'aigen-permanence-refs'
|
||
const pickerHeading = computed(() => {
|
||
if (pickerKind.value === 'permanence') return 'Choose a still to name'
|
||
if (pickerSlot.value === 'main') return studioMode.value === 'edit' || studioMode.value === 'editv2' ? (studioMode.value === 'editv2' ? 'Choose still A' : 'Choose image 1') : 'Choose an image'
|
||
if (pickerSlot.value === 'editRef') return studioMode.value === 'editv2' ? 'Choose still B' : 'Choose image 2'
|
||
return `Choose Picture ${Number(pickerSlot.value) + 2}`
|
||
})
|
||
const permanencePicker = computed(() => pickerKind.value === 'permanence')
|
||
const lockBindingKeys = computed(() => parseLockBindingKeys(globalLocks.value))
|
||
const familyRefLabelLine = computed(() => {
|
||
const labels = permanenceLabels(familyPermanenceRefs.value)
|
||
if (!labels.length) return ''
|
||
return `In the prompt: Permanence: ${labels.join(', ')} stay the same.`
|
||
})
|
||
const extraPersonLock = computed(() => shouldForceLastFrameI2V(
|
||
mergePermanenceRefs(familyPermanenceRefs.value, Object.values(shotPermanenceRefs.value).flat()),
|
||
globalLocks.value
|
||
))
|
||
const useIdentityRefs = computed(() => !extraPersonLock.value && identityRefs.value.some(Boolean))
|
||
const permanenceComfyNote = computed(() => {
|
||
if (extraPersonLock.value) {
|
||
return 'A second named person keeps last-frame I2V on. MiniMax never sees the man/bench photos; those names only go in the inherit line.'
|
||
}
|
||
if (studioMode.value === 'video' && videoWorkflow.value === 'v2' && useIdentityRefs.value) {
|
||
return 'Character stills are extra views of the same person. Picture 1 is the last frame after shot 1. Permanence stills only name the lock text.'
|
||
}
|
||
return 'Last-frame I2V does not send these stills to MiniMax as extra pictures. Continuity is the previous clip’s last frame; the names only go in the lock line.'
|
||
})
|
||
let videoTimer: ReturnType<typeof setInterval> | null = null
|
||
let editTimer: ReturnType<typeof setInterval> | null = null
|
||
let videoSource: EventSource | null = null
|
||
let editSource: EventSource | null = null
|
||
let videoPollTimer: ReturnType<typeof setInterval> | null = null
|
||
let editPollTimer: ReturnType<typeof setInterval> | null = null
|
||
let healthTimer: ReturnType<typeof setTimeout> | null = null
|
||
let studioPollAlive = false
|
||
let loraPollAt = 0
|
||
let concealPointers = 0
|
||
let concealPinch = false
|
||
let concealGestureAt = 0
|
||
let concealStart: { pointerId: number; x: number; y: number; moved: boolean } | null = null
|
||
const CONCEAL_MOVE_PX = 12
|
||
const CONCEAL_GESTURE_MS = 500
|
||
let videoSettledUi = false
|
||
let editSettledUi = false
|
||
let videoListenGen = 0
|
||
let editListenGen = 0
|
||
let activeDraftId = ''
|
||
|
||
function watchingLiveVideo() {
|
||
return Boolean(videoJobId.value) && !videoSettledUi && (videoBusy.value || Boolean(videoSource))
|
||
}
|
||
|
||
function watchingLiveEdit() {
|
||
return Boolean(editJobId.value) && !editSettledUi && (editBusy.value || Boolean(editSource))
|
||
}
|
||
|
||
function queuedOffLiveOutput(
|
||
started: { queued?: boolean; jobId?: string },
|
||
kind: 'video' | 'edit'
|
||
) {
|
||
if (started.queued) return true
|
||
if (kind === 'edit') {
|
||
return watchingLiveEdit() && Boolean(started.jobId) && started.jobId !== editJobId.value
|
||
}
|
||
return watchingLiveVideo() && Boolean(started.jobId) && started.jobId !== videoJobId.value
|
||
}
|
||
|
||
function formatElapsed(ms: number) {
|
||
const seconds = Math.floor(ms / 1000)
|
||
const mm = String(Math.floor(seconds / 60)).padStart(2, '0')
|
||
const ss = String(seconds % 60).padStart(2, '0')
|
||
return `${mm}:${ss}`
|
||
}
|
||
|
||
const elapsedLabel = computed(() => formatElapsed(elapsedMs.value))
|
||
const editElapsedLabel = computed(() => formatElapsed(editElapsedMs.value))
|
||
|
||
const outputProgressKind = computed<'video' | 'edit'>(() => {
|
||
if (editBusy.value && !videoBusy.value) return 'edit'
|
||
if (videoBusy.value && !editBusy.value) return 'video'
|
||
return outputFocus.value
|
||
})
|
||
|
||
const outputPreviewKind = computed<'video' | 'edit'>(() => {
|
||
if (outputFocus.value === 'video') {
|
||
if (editBusy.value && !videoBusy.value && !videoUrl.value) return 'edit'
|
||
return 'video'
|
||
}
|
||
if (videoBusy.value && !editBusy.value && !editResultUrl.value) return 'video'
|
||
return 'edit'
|
||
})
|
||
|
||
const liveElapsedLabel = computed(() => (
|
||
outputProgressKind.value === 'edit' ? editElapsedLabel.value : elapsedLabel.value
|
||
))
|
||
const playingLastSavedWhileBusy = computed(() => (
|
||
videoBusy.value && Boolean(videoUrl.value) && !watchingLibrary.value && !lockedSave.value
|
||
))
|
||
const hasInputStill = computed(() => {
|
||
if (file.value || inputStillId.value) return true
|
||
const selected = selectedLibraryEntries().filter(entry => entry.kind === 'still')
|
||
return selected.length === 1 && Boolean(selected[0].stillGroup?.head.id)
|
||
})
|
||
const outputQueuedJob = computed(() => {
|
||
const id = outputStudioJobId.value
|
||
if (!id) return null
|
||
return studioJobs.value.find(job => job.id === id && (job.status === 'waiting' || job.status === 'held')) || null
|
||
})
|
||
function idleInputStatus(kind: 'video' | 'edit') {
|
||
if (kind === 'edit') return hasInputStill.value ? 'Still ready. Edit when you are ready.' : 'Waiting for a still.'
|
||
if (textToVideo.value) {
|
||
return hasInputStill.value
|
||
? 'Still loaded (optional). Generate when you are ready.'
|
||
: 'Ready for text-to-video.'
|
||
}
|
||
return hasInputStill.value ? 'Still ready. Generate when you are ready.' : 'Waiting for a still.'
|
||
}
|
||
|
||
const liveStatusMessage = computed(() => {
|
||
if (outputProgressKind.value === 'edit') {
|
||
if (editBusy.value || editResultUrl.value || editLockedSave.value) return editStatusMessage.value
|
||
return idleInputStatus('edit')
|
||
}
|
||
if (videoBusy.value || videoUrl.value || lockedSave.value) return statusMessage.value
|
||
if (videoAwaitingBeast.value || outputQueuedJob.value) {
|
||
return statusMessage.value || 'Queued. Waiting for Beast to be free.'
|
||
}
|
||
return idleInputStatus('video')
|
||
})
|
||
const liveStatusBusy = computed(() => (
|
||
outputProgressKind.value === 'edit'
|
||
? editStatusBusy.value
|
||
: (statusBusy.value || videoAwaitingBeast.value)
|
||
))
|
||
const liveProgressPct = computed(() => (
|
||
outputProgressKind.value === 'edit'
|
||
? (editOverallProgress.value || editProgress.value)
|
||
: progress.value
|
||
))
|
||
const liveStep = computed(() => (
|
||
outputProgressKind.value === 'edit' ? editStep.value : step.value
|
||
))
|
||
const liveMaxStep = computed(() => (
|
||
outputProgressKind.value === 'edit'
|
||
? editMaxStep.value
|
||
: (maxStep.value || defaultVideoSteps(turbo.value, videoWorkflow.value))
|
||
))
|
||
|
||
const showWakeGpu = computed(() => gpuWaking.value || (!comfyOk.value && !imageComfyOk.value))
|
||
|
||
const canClearPreview = computed(() => Boolean(
|
||
videoUrl.value || editResultUrl.value || extendOpen.value || lockedSave.value || editLockedSave.value
|
||
))
|
||
|
||
function snap32(value: number) {
|
||
return Math.max(32, Math.round(value / 32) * 32)
|
||
}
|
||
|
||
function autoResolution(srcWidth: number, srcHeight: number) {
|
||
const ratio = srcWidth / Math.max(srcHeight, 1)
|
||
let width: number
|
||
let height: number
|
||
if (ratio >= 1) {
|
||
height = 768
|
||
width = Math.min(1344, height * ratio)
|
||
} else {
|
||
width = 768
|
||
height = Math.min(1344, width / ratio)
|
||
}
|
||
width = snap32(width)
|
||
height = snap32(height)
|
||
if (width > 1344) width = 1344
|
||
if (height > 1344) height = 1344
|
||
return { width: Math.max(32, width), height: Math.max(32, height) }
|
||
}
|
||
|
||
function normalizeAspect(raw?: string): AspectId {
|
||
if (raw === 'auto' || raw === '16:9' || raw === '9:16' || raw === '1:1') return raw
|
||
return 'auto'
|
||
}
|
||
|
||
const outputSize = computed(() => {
|
||
if (aspect.value !== 'auto') return PRESETS[aspect.value]
|
||
if (imageWidth.value && imageHeight.value) return autoResolution(imageWidth.value, imageHeight.value)
|
||
return { width: 1344, height: 768 }
|
||
})
|
||
|
||
const autoHint = computed(() => {
|
||
if (!imageWidth.value || !imageHeight.value) {
|
||
return textToVideo.value ? '16:9 without a still' : 'Match the still'
|
||
}
|
||
const size = autoResolution(imageWidth.value, imageHeight.value)
|
||
return `${size.width} × ${size.height} · from still`
|
||
})
|
||
const videoAspects = computed(() => aspects.map(option => ({
|
||
...option,
|
||
hint: option.id === 'auto' ? autoHint.value : option.hint
|
||
})))
|
||
const videoModelHint = computed(() => {
|
||
if (ltxVideo.value) {
|
||
return textToVideo.value
|
||
? 'PinkCherry text-to-video. Distilled LoRA stays on. Silent MP4 — LTX has no audio VAE here.'
|
||
: 'PinkCherry image-to-video. Distilled LoRA stays on. Silent MP4 — LTX has no audio VAE here.'
|
||
}
|
||
if (textToVideo.value) return 'MiniMax H3 text-to-video with native stereo audio. Shot 2+ switches to last-frame image-to-video.'
|
||
if (videoWorkflow.value === 'v2') return 'MiniMax H3 image-to-video. Shot 2+ starts from the last frame. Character stills are optional extras.'
|
||
return 'MiniMax H3 image-to-video with native stereo audio.'
|
||
})
|
||
const frameCount = computed(() => Math.max(5, Math.floor(duration.value * fps.value)))
|
||
const cfgPct = computed(() => ((cfg.value - CFG_MIN) / (CFG_MAX - CFG_MIN)) * 100)
|
||
const currentClip = computed(() => clips.value.find(clip => clip.id === currentClipId.value))
|
||
const hideExtendLastFrame = computed(() => (
|
||
showPrivacyToggles.value
|
||
&& !lastFrameRevealed.value
|
||
&& (hideInputPreview.value || hideThumbnail.value || currentClip.value?.hideThumbnail === true)
|
||
))
|
||
const canExtend = computed(() => Boolean(
|
||
videoUrl.value
|
||
&& !lockedSave.value
|
||
&& (currentClipId.value || clipIdFromPlayer())
|
||
))
|
||
const extendSourceClip = computed(() => clips.value.find(clip => clip.id === extendClipId.value) || currentClip.value)
|
||
const extendSourceLabel = computed(() => {
|
||
const clip = extendSourceClip.value
|
||
if (!clip) return ''
|
||
return clip.name || clipPartLabel(clip.name) || 'this clip'
|
||
})
|
||
const extendSaveName = computed(() => {
|
||
const clip = extendSourceClip.value
|
||
if (!clip) return ''
|
||
const reserved = [
|
||
...familyPartsFor(clip).map(part => part.name || ''),
|
||
...studioJobs.value.map(job => job.name || '')
|
||
]
|
||
return nextClipPartName(clip.name || clip.prompt || '', reserved)
|
||
})
|
||
const queueReady = computed(() => {
|
||
if (shotScriptMode.value) return parsedShots.value.length > 0 && parsedShots.value.every(shot => shot.prompt.trim())
|
||
return extensionQueue.value.every(item => composePromptParts(item.promptPre, item.prompt, item.promptPost))
|
||
})
|
||
const canUseGeneratedStill = computed(() => Boolean(
|
||
editResultUrl.value && !editLockedSave.value && !editAwaitingReveal.value
|
||
))
|
||
const videoBlockReason = computed(() => {
|
||
if (!textToVideo.value && !hasInputStill.value) return 'Load a still first. Image-to-video needs an input still.'
|
||
if (![promptPre.value, prompt.value, promptPost.value].some(part => part.trim())) return 'Write a motion prompt first.'
|
||
if (!folderId.value) return 'Choose a library folder before generating.'
|
||
if (!queueReady.value) {
|
||
return shotScriptMode.value ? 'Each shot in the script needs a prompt.' : 'Each queued extension needs a prompt.'
|
||
}
|
||
return ''
|
||
})
|
||
const videoGenerateDisabled = computed(() => Boolean(videoBlockReason.value))
|
||
const recommendDisabled = computed(() => {
|
||
if (recommendBusy.value) return true
|
||
if (!file.value || !prompt.value.trim()) return true
|
||
if (!imageSidecarConfigured.value) return true
|
||
return false
|
||
})
|
||
const recommendHint = computed(() => {
|
||
if (!imageSidecarConfigured.value) return 'Qwen VL is not configured. Set COMFY_HOST or IMAGE_COMFY_HOST to Beast.'
|
||
if (!file.value) return 'Load a still first.'
|
||
if (!prompt.value.trim()) return 'Type a draft prompt to enhance.'
|
||
if (!imageSidecarOk.value) return 'Beast Comfy is offline. Recommend needs Qwen VL on that instance.'
|
||
if (imageSidecarBusy.value) return 'Beast Comfy is busy — this may wait in its queue.'
|
||
return 'Send this still and draft to Beast Qwen VL.'
|
||
})
|
||
const gpuSleepHint = computed(() => {
|
||
if (comfyOk.value || imageComfyOk.value) return ''
|
||
if (gpuWaking.value) return 'Starting Comfy on the GPU. Generate will wait in the job queue until it answers.'
|
||
if (comfyAsleep.value) return 'GPU is asleep. Generate will wake it — the first job can take a few minutes. Or open the menu and click Poke.'
|
||
return 'GPU is offline. Generate will try to start Comfy. Or open the menu and click Poke.'
|
||
})
|
||
const editBlocked = computed(() => {
|
||
if (!file.value || !folderId.value) return true
|
||
if (!prompt.value.trim() && !imageFillWithoutPrompt.value) return true
|
||
if (editPassQueue.value.some(item => !item.prompt.trim())) return true
|
||
return false
|
||
})
|
||
const editBlockReason = computed(() => {
|
||
if (!file.value) return 'Load a still first.'
|
||
if (!prompt.value.trim() && !imageFillWithoutPrompt.value) return 'Write an edit prompt first, or pick Landscape / Portrait / Square to fill.'
|
||
if (!folderId.value) return 'Choose a library folder before editing.'
|
||
if (editPassQueue.value.some(item => !item.prompt.trim())) return 'Each extra pass needs a prompt.'
|
||
if (!imageComfyConfigured.value && !comfyOk.value) return 'Image edit is not configured. Set COMFY_HOST.'
|
||
return ''
|
||
})
|
||
const stillBHint = computed(() => {
|
||
if (v2Task.value === 'outfit') return 'Clothing only from still B. Person, body, pose, and background stay on still A.'
|
||
if (v2Task.value === 'face_lock') return 'Exact face from still B. Body, pose, and scene stay on still A.'
|
||
if (v2Task.value === 'identity') return 'Still B reinforces the face. Still A is the person.'
|
||
return 'Still A is the edit image. Still B is style or background.'
|
||
})
|
||
const editV2Blocked = computed(() => {
|
||
if (!folderId.value) return true
|
||
if (!prompt.value.trim() && !imageFillWithoutPrompt.value) return true
|
||
if (v2Mode.value !== 'generate' && v2Mode.value !== 'iterate' && !file.value) return true
|
||
if (v2Mode.value === 'compose' && !editRefFile.value) return true
|
||
if (v2Mode.value === 'refine' && !refineMaskDirty.value) return true
|
||
if (v2Mode.value !== 'iterate' && v2PassChaining.value && editPassQueue.value.some(item => !item.prompt.trim())) return true
|
||
if (v2Mode.value === 'iterate' && !editPassQueue.value.length) return true
|
||
return false
|
||
})
|
||
const editV2BlockReason = computed(() => {
|
||
if (v2Mode.value !== 'generate' && v2Mode.value !== 'iterate' && !file.value) return 'Load still A first.'
|
||
if (v2Mode.value === 'compose' && !editRefFile.value) return 'Compose requires still B. This will not fall back to one-image generation.'
|
||
if (v2Mode.value === 'refine' && !refineMaskDirty.value) return 'Paint a mask on Still A first. Refine will not fall back to Edit.'
|
||
if (!prompt.value.trim() && !imageFillWithoutPrompt.value) {
|
||
return v2Mode.value === 'generate' || (v2Mode.value === 'iterate' && !file.value)
|
||
? 'Write a prompt first.'
|
||
: 'Write a prompt, or pick Landscape / Portrait / Square to fill the frame.'
|
||
}
|
||
if (v2Mode.value === 'iterate' && !editPassQueue.value.length) return 'Add a variation or build a steps/CFG sweep.'
|
||
if (v2Mode.value !== 'iterate' && v2PassChaining.value && editPassQueue.value.some(item => !item.prompt.trim())) return 'Each extra pass needs a prompt.'
|
||
if (!folderId.value) return 'Choose a library folder before generating.'
|
||
if (!imageComfyConfigured.value && !comfyOk.value) return 'Image v2 is not configured. Set COMFY_HOST.'
|
||
return ''
|
||
})
|
||
const v2PassChaining = computed(() => studioMode.value === 'editv2' && (v2Mode.value === 'edit' || v2Mode.value === 'compose' || v2Mode.value === 'iterate'))
|
||
const v2ModeLocked = computed(() => v2PassChaining.value && editPassQueue.value.length > 0)
|
||
const extraPassButtonLabel = computed(() => {
|
||
if (v2Mode.value === 'iterate') return '+ Iteration'
|
||
if (imagePassMode.value === 'batch') return '+ Prompt'
|
||
return studioMode.value === 'editv2' && v2Mode.value === 'compose' ? '+ Compose pass' : '+ Edit pass'
|
||
})
|
||
const imageBatchButtonHint = computed(() => {
|
||
const n = 1 + editPassQueue.value.length
|
||
if (imageBatchScript.value.trim() && n > 1) {
|
||
return v2Mode.value === 'iterate'
|
||
? `${n} iterations. Same format as a video shot list.`
|
||
: `${n} prompts on the start still. Same format as a video shot list.`
|
||
}
|
||
return v2Mode.value === 'iterate'
|
||
? 'Paste shot 1, shot 2, shot 3. Each line becomes one nested still.'
|
||
: 'Paste shot 1, shot 2, shot 3. Each image is a new prompt on the original still.'
|
||
})
|
||
const extraPassHint = computed(() => {
|
||
if (v2Mode.value === 'iterate') {
|
||
if (!file.value) return 'Each prompt is a separate generate. Outputs nest under one library collection.'
|
||
if (imagePassMode.value === 'batch') return 'Every prompt runs from the start still. Outputs nest under one library collection.'
|
||
return 'Each prompt runs from the last output. Outputs nest under one library collection.'
|
||
}
|
||
if (imagePassMode.value === 'batch') {
|
||
return 'Every prompt runs from the start still. Paste a shot list, or add prompts one at a time. Outputs do not feed the next prompt.'
|
||
}
|
||
if (studioMode.value === 'editv2' && v2Mode.value === 'compose') {
|
||
return 'Each extra pass is another Compose on the last output, with the same still B. You cannot switch graphs between passes.'
|
||
}
|
||
return 'Each extra pass is another Edit on the last output. You cannot switch graphs between passes.'
|
||
})
|
||
const extraPassPlaceholder = computed(() => {
|
||
if (v2Mode.value === 'iterate') return 'Leave blank to use the first prompt…'
|
||
if (imagePassMode.value === 'batch') return 'Another prompt for the same start still…'
|
||
return studioMode.value === 'editv2' && v2Mode.value === 'compose'
|
||
? 'Next compose prompt, applied to the previous pass output…'
|
||
: 'Next edit prompt, applied to the previous pass output…'
|
||
})
|
||
const editV2SubmitLabel = computed(() => {
|
||
const occupied = videoBusy.value || editBusy.value || studioJobs.value.some(job => job.status === 'running')
|
||
const n = v2PassChaining.value ? editPassQueue.value.length : 0
|
||
const label = v2Mode.value === 'generate'
|
||
? 'Generate image'
|
||
: v2Mode.value === 'iterate'
|
||
? 'Iterate'
|
||
: v2Mode.value === 'refine'
|
||
? 'Refine image'
|
||
: v2Mode.value === 'compose'
|
||
? 'Compose image'
|
||
: 'Edit image'
|
||
if (!occupied) {
|
||
if (v2Mode.value === 'iterate') return n ? `Iterate × ${n + 1}` : 'Iterate'
|
||
if (!n) return label
|
||
if (imagePassMode.value === 'batch') return n === 1 ? `${label} × 2` : `${label} × ${n + 1}`
|
||
return n === 1 ? `${label} + 1 pass` : `${label} + ${n} passes`
|
||
}
|
||
if (n) {
|
||
return v2Mode.value === 'iterate'
|
||
? `Queue ${n + 1} iterations`
|
||
: imagePassMode.value === 'batch' ? `Queue ${n + 1} from start still` : `Queue ${n + 1} passes`
|
||
}
|
||
return `Queue ${label.toLowerCase()}`
|
||
})
|
||
const generateLabel = computed(() => {
|
||
const n = queuedExtensionCount.value
|
||
if (videoBusy.value) return n ? `Queue ${n + 1} shots` : 'Queue job'
|
||
if (!n) return 'Generate Video'
|
||
return n === 1 ? 'Generate Initial + 1 Extension' : `Generate Initial + ${n} Extensions`
|
||
})
|
||
const studioWaitingCount = computed(() => studioJobs.value.filter(job => job.status === 'waiting').length)
|
||
const queueBadgeCount = computed(() => studioWaitingCount.value || queuePendingCount.value)
|
||
const canCutInStudioJob = computed(() => studioJobs.value.some(job => job.status === 'running' || job.status === 'held') || videoBusy.value)
|
||
const outputRestoreId = computed(() => (
|
||
outputStudioJobId.value
|
||
|| studioJobs.value.find(job => job.liveJobId && job.liveJobId === videoJobId.value)?.id
|
||
|| ''
|
||
))
|
||
const canRestoreOutput = computed(() => Boolean(outputRestoreId.value && outputProgressKind.value === 'video'))
|
||
const queuedExtensionCount = computed(() => (
|
||
shotScriptMode.value ? Math.max(0, parsedShots.value.length - 1) : extensionQueue.value.length
|
||
))
|
||
const editLabel = computed(() => {
|
||
const n = editPassQueue.value.length
|
||
if (!n) return 'Edit image'
|
||
if (imagePassMode.value === 'batch') return n === 1 ? 'Edit image × 2' : `Edit image × ${n + 1}`
|
||
return n === 1 ? 'Edit image + 1 pass' : `Edit image + ${n} passes`
|
||
})
|
||
const editSubmitLabel = computed(() => {
|
||
const occupied = videoBusy.value || editBusy.value || studioJobs.value.some(job => job.status === 'running')
|
||
if (!occupied) return editLabel.value
|
||
const n = editPassQueue.value.length
|
||
if (!n) return 'Queue edit'
|
||
return imagePassMode.value === 'batch' ? `Queue ${n + 1} from start still` : `Queue ${n + 1} passes`
|
||
})
|
||
const parsedShots = computed(() => parseShotScript(prompt.value))
|
||
const recommendedShots = computed(() => {
|
||
if (!recommendText.value || recommendBusy.value) return []
|
||
return parseShotScript(recommendText.value)
|
||
})
|
||
const identityPrompting = computed(() => studioMode.value === 'video' && videoWorkflow.value === 'v2' && useIdentityRefs.value && !extraPersonLock.value)
|
||
const extraIdentityPictures = computed(() => identityRefs.value.flatMap((item, index) => item ? [index + 2] : []))
|
||
function composedShotPreview(text: string, index: number, shotN?: number) {
|
||
const composed = composeShotPrompt({
|
||
globalLocks: globalLocks.value,
|
||
prompt: composePromptParts(promptPre.value, text, promptPost.value),
|
||
shotIndex: index,
|
||
familyRefs: familyPermanenceRefs.value,
|
||
shotRefs: shotPermanenceRefs.value[shotN ?? index + 1]
|
||
})
|
||
return identityPrompting.value ? buildIdentityPrompt(composed, extraIdentityPictures.value) : composed
|
||
}
|
||
const identityActionPrompt = computed(() => {
|
||
if (shotScriptMode.value) return parsedShots.value[0]?.prompt || prompt.value
|
||
return prompt.value
|
||
})
|
||
const composedIdentityPrompt = computed(() => {
|
||
return buildIdentityPrompt(identityActionPrompt.value, extraIdentityPictures.value)
|
||
})
|
||
const promptPlaceholder = computed(() => {
|
||
if (studioMode.value === 'editv2') {
|
||
if (v2Mode.value === 'generate') return 'A red cube on a white table, studio light.'
|
||
if (v2Mode.value === 'refine') return 'Prompt only what should change in the painted area.'
|
||
if (v2Mode.value === 'compose' && v2Task.value === 'outfit') return 'Keep everything the same except the clothes from still B.'
|
||
if (v2Mode.value === 'compose' && v2Task.value === 'face_lock') return 'Keep the body and scene from still A. Use the face from still B.'
|
||
if (v2Mode.value === 'compose' && v2Task.value === 'identity') return 'Same person as still A. Change the pose and scene.'
|
||
return 'Make the lighting warmer. Keep the face unchanged.'
|
||
}
|
||
if (studioMode.value === 'edit') {
|
||
return editRefFile.value
|
||
? 'Apply the dress in image 2 to the person in image 1. Preserve the exact features, proportions, pose, and setting of image 1.'
|
||
: 'Make the lighting warmer. Replace the background with a rainy street at night. Keep the face unchanged.'
|
||
}
|
||
if (identityPrompting.value) {
|
||
return shotScriptMode.value
|
||
? `${IDENTITY_ACTION_TEMPLATE}\n\nshot 2\nThe subject from <Picture 1> continues along the path as the camera eases past her.`
|
||
: IDENTITY_ACTION_TEMPLATE
|
||
}
|
||
if (shotScriptMode.value) {
|
||
return 'A woman walks into the village.\n\nshot 2\nShe crosses the stone bridge at dusk.\n\nshot 3\nShe opens the shop door and steps inside.'
|
||
}
|
||
return 'Camera slowly pushes in. Soft daylight, fabric ripples, dust motes in the air. Audio: quiet room tone, distant birds.'
|
||
})
|
||
const plannedChain = computed(() => {
|
||
if (shotScriptMode.value) {
|
||
return parsedShots.value.map((shot, index) => ({
|
||
label: index === 0 ? 'Initial' : `Extension ${index}`,
|
||
prompt: shot.prompt,
|
||
duration: duration.value
|
||
}))
|
||
}
|
||
return [
|
||
{ label: 'Initial', prompt: composePromptParts(promptPre.value, prompt.value, promptPost.value), duration: duration.value },
|
||
...extensionQueue.value.map((item, index) => ({
|
||
label: `Extension ${index + 1}`,
|
||
prompt: composePromptParts(item.promptPre, item.prompt, item.promptPost),
|
||
duration: item.duration
|
||
}))
|
||
]
|
||
})
|
||
const shotChecklist = computed(() => {
|
||
if (liveQueue.value?.segments?.length) {
|
||
return liveQueue.value.segments.map(segment => ({
|
||
label: `Shot ${segment.index + 1}`,
|
||
prompt: segment.prompt,
|
||
duration: segment.duration,
|
||
status: segment.status,
|
||
clipId: segment.clipId
|
||
}))
|
||
}
|
||
return activeChainPlan.value.map((part, index) => ({
|
||
label: part.label,
|
||
prompt: part.prompt,
|
||
duration: part.duration,
|
||
status: index < completedChainStep.value
|
||
? 'complete'
|
||
: (videoBusy.value && index === completedChainStep.value ? 'running' : (index === completedChainStep.value ? 'pending' : 'pending')),
|
||
clipId: undefined as string | undefined
|
||
}))
|
||
})
|
||
const showShotChecklist = computed(() => (
|
||
shotChecklist.value.length > 1 && (videoBusy.value || shotChecklist.value.some(part => part.status !== 'complete'))
|
||
))
|
||
const shotChecklistResume = computed(() => {
|
||
if (videoBusy.value) return ''
|
||
const next = shotChecklist.value.find(part => part.status === 'pending' || part.status === 'error' || part.status === 'running')
|
||
const done = shotChecklist.value.filter(part => part.status === 'complete').length
|
||
if (!next || !done) return ''
|
||
return `Left off after shot ${done} of ${shotChecklist.value.length}. Continue with ${next.label}.`
|
||
})
|
||
|
||
const visibleClips = computed(() => clips.value.filter(clip => clip.folderId === browseFolderId.value && clip.hasVideo !== false))
|
||
const visibleOutputStills = computed(() => stills.value.filter(still => still.folderId === browseFolderId.value && still.role === 'output'))
|
||
const stillGroups = computed(() => {
|
||
const groups = new Map<string, LibraryStill[]>()
|
||
for (const still of visibleOutputStills.value) {
|
||
const key = stillFamilyKey(still)
|
||
const list = groups.get(key) || []
|
||
list.push(still)
|
||
groups.set(key, list)
|
||
}
|
||
return [...groups.entries()]
|
||
.map(([key, parts]) => {
|
||
const sorted = [...parts].sort(sortStillParts)
|
||
return { key, head: sorted[0], parts: sorted }
|
||
})
|
||
.sort((a, b) => familyActivityAt(b.parts) - familyActivityAt(a.parts))
|
||
})
|
||
const libraryGroups = computed(() => {
|
||
const groups = new Map<string, LibraryClip[]>()
|
||
for (const clip of visibleClips.value) {
|
||
const key = clipFamilyKey(clip)
|
||
const list = groups.get(key) || []
|
||
list.push(clip)
|
||
groups.set(key, list)
|
||
}
|
||
return [...groups.entries()]
|
||
.map(([key, parts]) => {
|
||
const head = familyRootClip(parts)
|
||
return { key, head, parts: [...parts] }
|
||
})
|
||
.sort((a, b) => familyActivityAt(b.parts) - familyActivityAt(a.parts))
|
||
})
|
||
const libraryEntries = computed(() => {
|
||
const videos = libraryFilter.value === 'images'
|
||
? []
|
||
: libraryGroups.value.map(group => ({
|
||
kind: 'clip' as const,
|
||
key: group.key,
|
||
createdAt: familyActivityAt(group.parts),
|
||
group
|
||
}))
|
||
const images = libraryFilter.value === 'videos'
|
||
? []
|
||
: stillGroups.value.map(group => ({
|
||
kind: 'still' as const,
|
||
key: group.key,
|
||
createdAt: familyActivityAt(group.parts),
|
||
stillGroup: group
|
||
}))
|
||
return [...videos, ...images].sort((a, b) => b.createdAt - a.createdAt)
|
||
})
|
||
const reviewingClipId = computed(() => {
|
||
if (outputPreviewKind.value !== 'video' || lockedSave.value) return ''
|
||
const fromUrl = String(videoUrl.value || '').match(/\/api\/library\/clips\/([^/?#]+)\/video/)
|
||
return currentClipId.value || fromUrl?.[1] || ''
|
||
})
|
||
const reviewingStillId = computed(() => {
|
||
if (outputPreviewKind.value !== 'edit' || editLockedSave.value) return ''
|
||
return stillIdFromLibraryUrl(editResultUrl.value)
|
||
})
|
||
const STUDIO_LIBRARY_CAP = 36
|
||
const visibleLibraryEntries = computed(() => libraryEntries.value.slice(0, STUDIO_LIBRARY_CAP))
|
||
const libraryCapped = computed(() => libraryEntries.value.length > STUDIO_LIBRARY_CAP)
|
||
const outputFamilyStills = computed(() => {
|
||
const id = reviewingStillId.value
|
||
if (!id) return [] as LibraryStill[]
|
||
const group = stillGroups.value.find(entry => entry.parts.some(part => part.id === id))
|
||
let ordered = chronologicalStills(group?.parts || [])
|
||
if (ordered.length < 2) {
|
||
const current = stills.value.find(still => still.id === id)
|
||
if (current?.familyId) {
|
||
ordered = chronologicalStills(stills.value.filter(still => still.familyId === current.familyId && still.role !== 'input'))
|
||
} else if (current) {
|
||
ordered = [current]
|
||
}
|
||
}
|
||
return ordered.length > 1 ? ordered : []
|
||
})
|
||
const outputFamilyIndex = computed(() => {
|
||
const id = reviewingStillId.value
|
||
if (!id) return 0
|
||
const index = outputFamilyStills.value.findIndex(still => still.id === id)
|
||
return index < 0 ? 0 : index
|
||
})
|
||
type LibraryEntry = (typeof libraryEntries.value)[number]
|
||
function isLibraryEntryFocused(entry: LibraryEntry) {
|
||
if (entry.kind === 'clip' && reviewingClipId.value) {
|
||
return entry.group.parts.some(part => part.id === reviewingClipId.value)
|
||
}
|
||
if (entry.kind === 'still' && reviewingStillId.value && entry.stillGroup) {
|
||
return entry.stillGroup.parts.some(part => part.id === reviewingStillId.value)
|
||
}
|
||
return false
|
||
}
|
||
function libraryEntryClass(entry: LibraryEntry) {
|
||
if (isLibraryEntryFocused(entry)) {
|
||
return 'border-amber-300 bg-amber-400/15 ring-2 ring-amber-300/70'
|
||
}
|
||
if (selectedKeys.value.includes(entry.key)) {
|
||
return 'border-amber-300/30 bg-amber-400/5'
|
||
}
|
||
return 'border-white/5 bg-zinc-950/40'
|
||
}
|
||
function libraryClickIgnored(event: MouseEvent) {
|
||
const target = event.target as HTMLElement | null
|
||
return Boolean(target?.closest('button, a, input, select, textarea, label'))
|
||
}
|
||
function onLibraryCardClick(entry: LibraryEntry, event: MouseEvent) {
|
||
if (libraryClickIgnored(event)) return
|
||
if (entry.kind === 'clip') openClip(entry.group.head)
|
||
else if (entry.stillGroup) openStill(entry.stillGroup.head)
|
||
}
|
||
function onLibraryClipPartClick(item: LibraryClip, event: MouseEvent) {
|
||
event.stopPropagation()
|
||
if (libraryClickIgnored(event)) return
|
||
openClip(item)
|
||
}
|
||
function onLibraryStillPartClick(item: LibraryStill, event: MouseEvent) {
|
||
event.stopPropagation()
|
||
if (libraryClickIgnored(event)) return
|
||
openStill(item)
|
||
}
|
||
const libraryEmptyLabel = computed(() => {
|
||
if (libraryFilter.value === 'images') return 'No generated stills in this folder yet.'
|
||
if (libraryFilter.value === 'videos') return 'No generated clips in this folder yet.'
|
||
return 'Nothing in this folder yet. Generate into it from the left.'
|
||
})
|
||
const browseFolder = computed(() => folders.value.find(folder => folder.id === browseFolderId.value))
|
||
const currentFolder = computed(() => folders.value.find(folder => folder.id === folderId.value))
|
||
|
||
function clipsIn(id: string) {
|
||
return clips.value.filter(clip => clip.folderId === id)
|
||
}
|
||
|
||
function stillsIn(id: string) {
|
||
return stills.value.filter(still => still.folderId === id && still.role === 'output')
|
||
}
|
||
|
||
function folderOutputCount(folder: LibraryFolder) {
|
||
if (!folder.unlocked) return (folder.clipCount || 0) + (folder.imageCount || 0)
|
||
const videos = clipsIn(folder.id).length
|
||
const images = stillsIn(folder.id).length
|
||
if (libraryFilter.value === 'videos') return videos
|
||
if (libraryFilter.value === 'images') return images
|
||
return videos + images
|
||
}
|
||
|
||
function clipFamilyKey(clip: LibraryClip) {
|
||
if (clip.familyId) return `family:${clip.folderId}:${clip.familyId}`
|
||
const base = familyBaseName(clip.name || '').toLowerCase()
|
||
if (base) return `name:${clip.folderId}:${base}`
|
||
return `solo:${clip.id}`
|
||
}
|
||
|
||
function stillFamilyKey(still: LibraryStill) {
|
||
if (still.familyId) return `still-family:${still.folderId}:${still.familyId}`
|
||
return `still-solo:${still.id}`
|
||
}
|
||
|
||
function sortStillParts(a: LibraryStill, b: LibraryStill) {
|
||
const chainDelta = (b.chainIndex || 0) - (a.chainIndex || 0)
|
||
if (chainDelta) return chainDelta
|
||
return b.createdAt - a.createdAt
|
||
}
|
||
|
||
function chronologicalStills(parts: LibraryStill[]) {
|
||
return [...parts].sort((a, b) => {
|
||
const chain = (a.chainIndex || 0) - (b.chainIndex || 0)
|
||
if (chain) return chain
|
||
return a.createdAt - b.createdAt
|
||
})
|
||
}
|
||
|
||
function chronologicalParts(parts: LibraryClip[]) {
|
||
return [...parts].sort((a, b) => {
|
||
const chain = (a.chainIndex || 0) - (b.chainIndex || 0)
|
||
if (chain) return chain
|
||
return a.createdAt - b.createdAt
|
||
})
|
||
}
|
||
|
||
function previousClipIdFor(item: LibraryClip, parts: LibraryClip[]) {
|
||
if (item.parentClipId) return item.parentClipId
|
||
const ordered = chronologicalParts(parts)
|
||
const index = ordered.findIndex(part => part.id === item.id)
|
||
if (index <= 0) return undefined
|
||
const currentChain = item.chainIndex || 0
|
||
for (let i = index - 1; i >= 0; i--) {
|
||
if ((ordered[i].chainIndex || 0) < currentChain) return ordered[i].id
|
||
}
|
||
return ordered[index - 1]?.id
|
||
}
|
||
|
||
function familyExpandLabel(parts: LibraryClip[], expanded?: boolean) {
|
||
if (expanded) return 'Collapse'
|
||
const summary = familyBranchSummary(parts)
|
||
return summary ? `Expand · ${summary}` : `Expand ${parts.length} parts`
|
||
}
|
||
|
||
function ensureFamilyExpanded(item: LibraryClip) {
|
||
const key = clipFamilyKey(item)
|
||
if (expandedFamilies.value[key]) return
|
||
expandedFamilies.value = { ...expandedFamilies.value, [key]: true }
|
||
}
|
||
|
||
function collectionTitle(group: { head: LibraryClip; parts: LibraryClip[] }) {
|
||
const named = group.parts.map(part => familyBaseName(part.name || '')).find(name => name)
|
||
return named || group.head.name || group.head.prompt
|
||
}
|
||
|
||
function familyScript(parts: LibraryClip[]) {
|
||
const ordered = chronologicalParts(parts)
|
||
if (ordered.length <= 1) return ordered[0]?.prompt || ''
|
||
return ordered.map((part, index) => (
|
||
index === 0 ? part.prompt.trim() : `shot ${index + 1}\n${part.prompt.trim()}`
|
||
)).join('\n\n')
|
||
}
|
||
|
||
function formatClipDuration(value?: number) {
|
||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return ''
|
||
const rounded = Math.round(value * 10) / 10
|
||
return Number.isInteger(rounded) ? `${rounded}s` : `${rounded.toFixed(1)}s`
|
||
}
|
||
|
||
function clipMetaLine(item: LibraryClip) {
|
||
const res = item.width && item.height ? `${item.width}×${item.height}` : 'size unknown'
|
||
const duration = formatClipDuration(item.duration)
|
||
const steps = `${item.steps || '—'} steps`
|
||
const seed = `seed ${item.seed || '—'}`
|
||
const engine = item.workflow === 'ltx' || item.workflow === 'ltx-t2v'
|
||
? 'LTX'
|
||
: item.workflow === 't2v'
|
||
? 'T2V'
|
||
: item.workflow === 'v2'
|
||
? 'MiniMax v2'
|
||
: item.workflow === 'v1'
|
||
? 'MiniMax'
|
||
: ''
|
||
return [res, duration, engine, steps, seed].filter(Boolean).join(' · ')
|
||
}
|
||
|
||
function stillTitle(still: LibraryStill) {
|
||
return still.name || still.filename.replace(/\.[a-z0-9]+$/i, '') || 'Edited still'
|
||
}
|
||
|
||
function stillCollectionTitle(group: { head: LibraryStill; parts: LibraryStill[] }) {
|
||
const named = group.parts.map(part => familyBaseName(part.name || '')).find(name => name)
|
||
return named || stillTitle(group.head)
|
||
}
|
||
|
||
function stillFamilyScript(parts: LibraryStill[]) {
|
||
const ordered = chronologicalStills(parts)
|
||
if (ordered.length <= 1) return ordered[0]?.prompt || ''
|
||
return ordered.map((part, index) => (
|
||
index === 0 ? (part.prompt || '').trim() : `pass ${index + 1}\n${(part.prompt || '').trim()}`
|
||
)).join('\n\n')
|
||
}
|
||
|
||
function stillMetaLine(still: LibraryStill) {
|
||
const res = still.width && still.height ? `${still.width}×${still.height}` : 'image'
|
||
const mode = stillModeLabel(still)
|
||
const unit = still.settings?.mode === 'iterate' ? 'iteration' : 'pass'
|
||
const pass = typeof still.chainIndex === 'number' ? `${unit} ${still.chainIndex + 1}` : ''
|
||
return [res, mode, pass].filter(Boolean).join(' · ')
|
||
}
|
||
|
||
function toggleDetails(key: string) {
|
||
expandedDetails.value = { ...expandedDetails.value, [key]: !expandedDetails.value[key] }
|
||
}
|
||
|
||
function scrollToStudioInput() {
|
||
document.getElementById('studio-input')?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||
}
|
||
|
||
function toggleFamily(key: string) {
|
||
expandedFamilies.value = { ...expandedFamilies.value, [key]: !expandedFamilies.value[key] }
|
||
}
|
||
|
||
function familyFramesRevealed(parts: LibraryClip[]) {
|
||
const hidden = parts.filter(part => part.hideThumbnail)
|
||
return hidden.length > 0 && hidden.every(part => stitchReveal[part.id])
|
||
}
|
||
|
||
function toggleFamilyFrames(group: { key: string; parts: LibraryClip[] }) {
|
||
const hidden = group.parts.filter(part => part.hideThumbnail)
|
||
if (!hidden.length) return
|
||
const next = !familyFramesRevealed(group.parts)
|
||
if (next && group.parts.length > 1) {
|
||
expandedFamilies.value = { ...expandedFamilies.value, [group.key]: true }
|
||
}
|
||
for (const part of hidden) stitchReveal[part.id] = next
|
||
}
|
||
|
||
function chainPartDone(index: number) {
|
||
return index < completedChainStep.value
|
||
}
|
||
|
||
function chainPartCurrent(index: number) {
|
||
return videoBusy.value && index === completedChainStep.value
|
||
}
|
||
|
||
function chainPartClass(index: number) {
|
||
if (chainPartDone(index)) return 'border border-emerald-400/20 bg-emerald-400/10 text-emerald-100'
|
||
if (chainPartCurrent(index)) return 'border border-amber-300/40 bg-amber-400/10 text-amber-100'
|
||
return 'border border-white/5 text-zinc-400'
|
||
}
|
||
|
||
function chainPartDot(index: number) {
|
||
if (chainPartDone(index)) return 'bg-emerald-400'
|
||
if (chainPartCurrent(index)) return 'bg-amber-300 animate-pulse'
|
||
return 'bg-zinc-600'
|
||
}
|
||
|
||
function shotCheckClass(part: { status: string }) {
|
||
if (part.status === 'complete') return 'border border-emerald-400/20 bg-emerald-400/10 text-emerald-100'
|
||
if (part.status === 'running') return 'border border-amber-300/40 bg-amber-400/10 text-amber-100'
|
||
if (part.status === 'error') return 'border border-red-400/30 bg-red-400/10 text-red-200'
|
||
return 'border border-white/5 text-zinc-400'
|
||
}
|
||
|
||
function shotCheckMarkClass(part: { status: string }) {
|
||
if (part.status === 'complete') return 'bg-emerald-400 text-zinc-950'
|
||
if (part.status === 'running') return 'bg-amber-400 text-zinc-950'
|
||
if (part.status === 'error') return 'bg-red-400 text-zinc-950'
|
||
return 'border border-white/20 text-zinc-500'
|
||
}
|
||
|
||
function shotCheckMark(part: { status: string; label: string }) {
|
||
if (part.status === 'complete') return '✓'
|
||
if (part.status === 'running') return '▶'
|
||
if (part.status === 'error') return '!'
|
||
return part.label.replace(/\D/g, '') || '•'
|
||
}
|
||
|
||
function editChainPartDone(index: number) {
|
||
return index < editCompletedChainStep.value
|
||
}
|
||
|
||
function editChainPartCurrent(index: number) {
|
||
return editBusy.value && index === editCompletedChainStep.value
|
||
}
|
||
|
||
function editChainPartClass(index: number) {
|
||
if (editChainPartDone(index)) return 'border border-emerald-400/20 bg-emerald-400/10 text-emerald-100'
|
||
if (editChainPartCurrent(index)) return 'border border-amber-300/40 bg-amber-400/10 text-amber-100'
|
||
return 'border border-white/5 text-zinc-400'
|
||
}
|
||
|
||
function editChainPartDot(index: number) {
|
||
if (editChainPartDone(index)) return 'bg-emerald-400'
|
||
if (editChainPartCurrent(index)) return 'bg-amber-300 animate-pulse'
|
||
return 'bg-zinc-600'
|
||
}
|
||
|
||
function readPrivacyToggle(key: string, fallback: boolean) {
|
||
try {
|
||
const raw = localStorage.getItem(key)
|
||
if (raw === 'true') return true
|
||
if (raw === 'false') return false
|
||
} catch { /* ignore */ }
|
||
return fallback
|
||
}
|
||
|
||
function applyThumbnailDefault() {
|
||
if (!showPrivacyToggles.value) {
|
||
hideThumbnail.value = false
|
||
return
|
||
}
|
||
hideThumbnail.value = readPrivacyToggle('aigen-hide-output-preview', true)
|
||
}
|
||
|
||
function applyInstancePrivacy() {
|
||
if (!showPrivacyToggles.value) {
|
||
hideInputPreview.value = false
|
||
hideThumbnail.value = false
|
||
return
|
||
}
|
||
hideInputPreview.value = readPrivacyToggle('aigen-hide-input-preview', true)
|
||
hideThumbnail.value = readPrivacyToggle('aigen-hide-output-preview', true)
|
||
}
|
||
|
||
function applyLibrary(data: { folders?: LibraryFolder[]; clips?: LibraryClip[]; stills?: LibraryStill[]; drafts?: RetryDraft[]; loraAliases?: Record<string, string>; loraTriggers?: Record<string, string>; keepPrompts?: KeepPromptLists } | unknown) {
|
||
const payload = (data || {}) as {
|
||
id?: string
|
||
hideInput?: boolean
|
||
hideThumbnail?: boolean
|
||
folders?: LibraryFolder[]
|
||
clips?: LibraryClip[]
|
||
stills?: LibraryStill[]
|
||
drafts?: RetryDraft[]
|
||
loraAliases?: Record<string, string>
|
||
loraTriggers?: Record<string, string>
|
||
keepPrompts?: KeepPromptLists
|
||
}
|
||
if (payload.id && !payload.folders && !payload.clips && !payload.stills) {
|
||
if (typeof payload.hideInput === 'boolean') {
|
||
const still = stills.value.find(item => item.id === payload.id)
|
||
if (still) still.hideInput = payload.hideInput
|
||
}
|
||
if (typeof payload.hideThumbnail === 'boolean') {
|
||
const clip = clips.value.find(item => item.id === payload.id)
|
||
if (clip) clip.hideThumbnail = payload.hideThumbnail
|
||
}
|
||
return
|
||
}
|
||
folders.value = payload.folders || []
|
||
clips.value = payload.clips || []
|
||
stills.value = payload.stills || []
|
||
drafts.value = payload.drafts || []
|
||
if (payload.loraAliases && typeof payload.loraAliases === 'object') {
|
||
loraAliases.value = payload.loraAliases
|
||
}
|
||
if (payload.loraTriggers && typeof payload.loraTriggers === 'object') {
|
||
loraTriggers.value = payload.loraTriggers
|
||
}
|
||
if (payload.keepPrompts) {
|
||
keepPrompts.value = normalizeKeepPrompts(payload.keepPrompts)
|
||
}
|
||
if (!folderId.value || !folders.value.some(folder => folder.id === folderId.value)) {
|
||
folderId.value = folders.value[0]?.id || ''
|
||
}
|
||
if (!browseFolderId.value || !folders.value.some(folder => folder.id === browseFolderId.value)) {
|
||
browseFolderId.value = folderId.value
|
||
}
|
||
}
|
||
|
||
async function loadLibrary(light = false) {
|
||
const data = await $fetch<{ folders?: LibraryFolder[]; clips?: LibraryClip[]; stills?: LibraryStill[]; drafts?: RetryDraft[]; loraAliases?: Record<string, string>; loraTriggers?: Record<string, string>; keepPrompts?: KeepPromptLists }>(light ? '/api/library?light=1' : '/api/library')
|
||
applyLibrary(data)
|
||
if (light) return
|
||
const queued = await $fetch<{ pendingCount?: number }>('/api/queues').catch(() => ({ pendingCount: 0 }))
|
||
queuePendingCount.value = queued.pendingCount || 0
|
||
await refreshStudioQueue()
|
||
await refreshLiveQueue()
|
||
}
|
||
|
||
let libraryRefreshTimer: ReturnType<typeof setTimeout> | null = null
|
||
function scheduleLibraryRefresh() {
|
||
if (libraryRefreshTimer) return
|
||
libraryRefreshTimer = setTimeout(() => {
|
||
libraryRefreshTimer = null
|
||
loadLibrary(true).catch(() => null)
|
||
}, 1500)
|
||
}
|
||
|
||
let healthFailStreak = 0
|
||
function applyComfyHealth(health: {
|
||
comfy?: { ok?: boolean; busy?: boolean; asleep?: boolean }
|
||
imageComfy?: {
|
||
ok?: boolean
|
||
busy?: boolean
|
||
configured?: boolean
|
||
host?: string
|
||
beast?: { ok?: boolean; busy?: boolean; host?: string; configured?: boolean }
|
||
sidecar?: { ok?: boolean; busy?: boolean; host?: string; configured?: boolean }
|
||
}
|
||
} | null | undefined) {
|
||
const nextComfyOk = Boolean(health?.comfy?.ok)
|
||
const nextImageOk = Boolean(health?.imageComfy?.ok)
|
||
const reachable = nextComfyOk || nextImageOk
|
||
if (reachable) {
|
||
healthFailStreak = 0
|
||
gpuWaking.value = false
|
||
gpuWakeError.value = ''
|
||
}
|
||
else healthFailStreak += 1
|
||
const keepLast = !reachable && (healthFailStreak < 3 || videoBusy.value || editBusy.value)
|
||
if (!keepLast) {
|
||
comfyOk.value = nextComfyOk
|
||
imageComfyOk.value = nextImageOk
|
||
imageBeastOk.value = Boolean(health?.imageComfy?.beast?.ok)
|
||
imageSidecarOk.value = Boolean(health?.imageComfy?.sidecar?.ok)
|
||
comfyAsleep.value = Boolean(health?.comfy?.asleep) && !reachable
|
||
}
|
||
comfyBusy.value = Boolean((keepLast ? comfyOk.value : nextComfyOk) && health?.comfy?.busy)
|
||
imageComfyConfigured.value = Boolean(health?.imageComfy?.configured)
|
||
imageComfyBusy.value = Boolean((keepLast ? imageComfyOk.value : nextImageOk) && health?.imageComfy?.busy)
|
||
imageComfyHost.value = String(health?.imageComfy?.host || imageComfyHost.value || '')
|
||
imageBeastBusy.value = Boolean(health?.imageComfy?.beast?.busy)
|
||
imageBeastHost.value = String(health?.imageComfy?.beast?.host || imageBeastHost.value || '')
|
||
imageSidecarBusy.value = Boolean(health?.imageComfy?.sidecar?.busy)
|
||
imageSidecarConfigured.value = Boolean(health?.imageComfy?.sidecar?.configured)
|
||
imageSidecarHost.value = String(health?.imageComfy?.sidecar?.host || imageSidecarHost.value || '')
|
||
}
|
||
|
||
function readStoredLoraNames(key: string) {
|
||
try {
|
||
const parsed = JSON.parse(localStorage.getItem(key) || '[]')
|
||
if (!Array.isArray(parsed)) return [] as string[]
|
||
return parsed.filter((name): name is string => typeof name === 'string' && Boolean(name.trim()))
|
||
} catch {
|
||
return [] as string[]
|
||
}
|
||
}
|
||
|
||
function rememberLoraNames(key: string, names: string[]) {
|
||
if (!names.length) return
|
||
try { localStorage.setItem(key, JSON.stringify(names)) } catch { /* ignore */ }
|
||
}
|
||
|
||
async function pollLoras() {
|
||
const listed = await $fetch<{ image?: string[]; video?: string[] }>('/api/loras?fresh=1').catch(() => ({ image: [], video: [] }))
|
||
const nextVideo = listed.video || []
|
||
const nextImage = listed.image || []
|
||
const sameNameSet = (a: string[], b: string[]) => {
|
||
if (a.length !== b.length) return false
|
||
const left = new Set(a)
|
||
return b.every(name => left.has(name))
|
||
}
|
||
// A successful non-empty Comfy list always wins — including when it shrank after a delete.
|
||
if (nextVideo.length) {
|
||
if (!sameNameSet(videoLoras.value, nextVideo)) {
|
||
videoLoras.value = nextVideo
|
||
rememberLoraNames('aigen-lora-names-video', nextVideo)
|
||
}
|
||
} else if (!videoLoras.value.length) {
|
||
videoLoras.value = readStoredLoraNames('aigen-lora-names-video')
|
||
}
|
||
if (nextImage.length) {
|
||
if (!sameNameSet(imageLoras.value, nextImage)) {
|
||
imageLoras.value = nextImage
|
||
rememberLoraNames('aigen-lora-names-image', nextImage)
|
||
}
|
||
} else if (!imageLoras.value.length) {
|
||
imageLoras.value = readStoredLoraNames('aigen-lora-names-image')
|
||
}
|
||
}
|
||
|
||
async function pollComfyHealth() {
|
||
const health = await $fetch<{ comfy?: { ok?: boolean; busy?: boolean; asleep?: boolean } }>('/api/health').catch(() => ({ comfy: { ok: false, busy: false } }))
|
||
applyComfyHealth(health)
|
||
}
|
||
|
||
async function pollStudioBackground() {
|
||
await pollComfyHealth()
|
||
if (Date.now() - loraPollAt > 30_000) {
|
||
loraPollAt = Date.now()
|
||
void pollLoras()
|
||
}
|
||
void refreshStudioQueue()
|
||
if (activeQueueId.value) void refreshLiveQueue()
|
||
}
|
||
|
||
function studioBackgroundBusy() {
|
||
return videoBusy.value
|
||
|| editBusy.value
|
||
|| comfyBusy.value
|
||
|| imageComfyBusy.value
|
||
|| studioJobs.value.some(job => job.status === 'running' || job.status === 'waiting' || job.status === 'held')
|
||
}
|
||
|
||
function scheduleStudioBackground() {
|
||
if (!studioPollAlive) return
|
||
const ms = studioBackgroundBusy() ? 8000 : 20000
|
||
healthTimer = setTimeout(() => {
|
||
if (!studioPollAlive) return
|
||
const run = typeof document === 'undefined' || !document.hidden
|
||
const done = run ? pollStudioBackground() : Promise.resolve()
|
||
void done.finally(() => {
|
||
if (studioPollAlive) scheduleStudioBackground()
|
||
})
|
||
}, ms)
|
||
}
|
||
|
||
async function pokeGpu() {
|
||
if (gpuWaking.value) return
|
||
gpuWaking.value = true
|
||
gpuWakeError.value = ''
|
||
try {
|
||
await $fetch('/api/comfy/wake', { method: 'POST' })
|
||
const deadline = Date.now() + 180_000
|
||
while (Date.now() < deadline) {
|
||
await pollComfyHealth()
|
||
if (comfyOk.value || imageComfyOk.value) {
|
||
await refreshStudioQueue().catch(() => null)
|
||
gpuWaking.value = false
|
||
return
|
||
}
|
||
await new Promise(resolve => setTimeout(resolve, 3000))
|
||
}
|
||
gpuWakeError.value = 'Started Comfy but it is not answering yet. If the instance picker is showing, open ComfyUI (1).'
|
||
} catch (error: any) {
|
||
gpuWakeError.value = error?.data?.statusMessage || error?.statusMessage || error?.message || 'Could not wake the GPU.'
|
||
} finally {
|
||
gpuWaking.value = false
|
||
}
|
||
}
|
||
|
||
onMounted(async () => {
|
||
try {
|
||
withSound.value = localStorage.getItem('aigen-generate-sound') !== 'false'
|
||
autoplayEnabled.value = localStorage.getItem('aigen-autoplay') === 'true'
|
||
videoEngine.value = 'minimax'
|
||
minimaxGraph.value = 'v2'
|
||
try {
|
||
const saved = parseVideoWorkflow(localStorage.getItem('aigen-video-workflow') || '')
|
||
videoEngine.value = videoEngineOf(saved)
|
||
minimaxGraph.value = minimaxGraphOf(saved) === 'v1' ? 'v2' : minimaxGraphOf(saved)
|
||
const engine = localStorage.getItem('aigen-video-engine')
|
||
if (engine === 'ltx' || engine === 'minimax') videoEngine.value = engine
|
||
if (!ltxEnabled.value && videoEngine.value === 'ltx') {
|
||
videoEngine.value = 'minimax'
|
||
applyEngineDefaults('minimax')
|
||
} else if (videoEngine.value === 'ltx') {
|
||
applyEngineDefaults('ltx')
|
||
}
|
||
} catch { /* ignore */ }
|
||
videoLoraStack.value = readStoredLoraStack('aigen-video-lora')
|
||
imageLoraStack.value = readStoredLoraStack('aigen-image-lora')
|
||
videoLoras.value = readStoredLoraNames('aigen-lora-names-video')
|
||
imageLoras.value = readStoredLoraNames('aigen-lora-names-image')
|
||
imageV2LoraStack.value = readImageV2LoraStack(v2Engine.value)
|
||
seedImageV2LorasIfEmpty()
|
||
globalLocks.value = localStorage.getItem(LOCKS_STORE) || ''
|
||
try {
|
||
const savedRefs = JSON.parse(localStorage.getItem(REFS_STORE) || '')
|
||
familyPermanenceRefs.value = Array.isArray(savedRefs?.family) ? savedRefs.family : []
|
||
shotPermanenceRefs.value = savedRefs?.shots && typeof savedRefs.shots === 'object' ? savedRefs.shots : {}
|
||
} catch { /* ignore */ }
|
||
activeQueueId.value = localStorage.getItem(QUEUE_STORE) || ''
|
||
const savedPreset = localStorage.getItem('aigen-prompt-preset')
|
||
if (savedPreset) recommendPresetId.value = savedPreset
|
||
duration.value = defaultVideoDurationPref()
|
||
extendDuration.value = duration.value
|
||
} catch { /* ignore */ }
|
||
applyInstancePrivacy()
|
||
bindConcealListeners()
|
||
const [health, me] = await Promise.all([
|
||
$fetch<{ comfy?: { ok?: boolean; busy?: boolean } }>('/api/health').catch(() => ({ comfy: { ok: false, busy: false } })),
|
||
$fetch<{ user?: { name?: string; email?: string }; authEnabled?: boolean; authMode?: string; instanceName?: string }>('/api/auth/me').catch(() => ({ user: null })),
|
||
loadLibrary().catch(() => applyLibrary({ folders: [], clips: [], stills: [], drafts: [] }))
|
||
])
|
||
applyComfyHealth(health)
|
||
void pollLoras()
|
||
loraPollAt = Date.now()
|
||
void loadGenerationPresets()
|
||
await loadDirectorScripts().catch(() => null)
|
||
instanceName.value = me.instanceName || instanceName.value || 'AIGen'
|
||
applyInstancePrivacy()
|
||
setTimeout(() => { void loadLibrary(true).catch(() => null) }, 6000)
|
||
studioPollAlive = true
|
||
scheduleStudioBackground()
|
||
await resumeActiveJob()
|
||
await refreshStudioQueue()
|
||
await refreshLiveQueue()
|
||
await loadPromptPresets()
|
||
if (route.query.clip || route.query.still) await applyIncomingLibraryItem()
|
||
else applyIncomingDirectorScript()
|
||
await applyIncomingQueuedJob()
|
||
applyStudioQueryFromRoute()
|
||
window.addEventListener('keydown', onStudioKey)
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
unbindConcealListeners()
|
||
window.removeEventListener('keydown', onStudioKey)
|
||
studioPollAlive = false
|
||
if (healthTimer) clearTimeout(healthTimer)
|
||
healthTimer = null
|
||
if (libraryRefreshTimer) clearTimeout(libraryRefreshTimer)
|
||
libraryRefreshTimer = null
|
||
stopTimer()
|
||
stopListen()
|
||
closeExtend()
|
||
})
|
||
|
||
watch(folderId, () => {
|
||
applyThumbnailDefault()
|
||
})
|
||
|
||
watch(recommendPresetId, (id) => {
|
||
try { localStorage.setItem('aigen-prompt-preset', id) } catch { /* ignore */ }
|
||
})
|
||
|
||
watch(studioMode, () => {
|
||
if (studioMode.value !== 'video' && recommendPresetId.value === 'shot-script') {
|
||
recommendPresetId.value = 'enhance'
|
||
}
|
||
})
|
||
|
||
watch(() => route.query.studio, () => {
|
||
applyStudioQueryFromRoute()
|
||
})
|
||
|
||
watch([browseFolderId, libraryFilter], () => {
|
||
selectedKeys.value = []
|
||
lastSelectedKey.value = ''
|
||
})
|
||
|
||
watch(editBusy, (busy) => {
|
||
if (busy) outputFocus.value = 'edit'
|
||
})
|
||
|
||
watch(videoBusy, (busy) => {
|
||
if (busy) outputFocus.value = 'video'
|
||
})
|
||
|
||
watch([editResultUrl, editAwaitingReveal, editLockedSave], () => {
|
||
if (!editResultUrl.value || editAwaitingReveal.value || editLockedSave.value) closeEditLightbox()
|
||
})
|
||
|
||
watch(hideInputPreview, (value) => {
|
||
try {
|
||
localStorage.setItem('aigen-hide-input-preview', String(value))
|
||
} catch { /* ignore */ }
|
||
})
|
||
|
||
watch(hideThumbnail, (value) => {
|
||
if (!showPrivacyToggles.value) return
|
||
try {
|
||
localStorage.setItem('aigen-hide-output-preview', String(value))
|
||
} catch { /* ignore */ }
|
||
})
|
||
|
||
watch(hideExtendLastFrame, (hidden) => {
|
||
if (hidden || !extendOpen.value) return
|
||
const id = extendClipId.value || currentClipId.value || clipIdFromPlayer()
|
||
if (id && !lastFrameUrl.value && !lastFrameLoading.value) void loadLastFrame(id)
|
||
})
|
||
|
||
watch(withSound, (value) => {
|
||
try {
|
||
localStorage.setItem('aigen-generate-sound', String(value))
|
||
} catch { /* ignore */ }
|
||
})
|
||
|
||
watch(videoUrl, () => {
|
||
videoSeekable.value = true
|
||
})
|
||
|
||
watch(autoplayEnabled, (value) => {
|
||
try {
|
||
localStorage.setItem('aigen-autoplay', String(value))
|
||
} catch { /* ignore */ }
|
||
if (value && videoUrl.value && !awaitingReveal.value) {
|
||
player.value?.play().catch(() => null)
|
||
}
|
||
})
|
||
|
||
watch(ltxEnabled, (on) => {
|
||
if (!on && videoEngine.value === 'ltx') {
|
||
videoEngine.value = 'minimax'
|
||
applyEngineDefaults('minimax')
|
||
}
|
||
})
|
||
|
||
watch(videoWorkflow, (value) => {
|
||
try {
|
||
localStorage.setItem('aigen-video-workflow', value)
|
||
} catch { /* ignore */ }
|
||
})
|
||
|
||
watch(videoEngine, (value) => {
|
||
try {
|
||
localStorage.setItem('aigen-video-engine', value)
|
||
} catch { /* ignore */ }
|
||
})
|
||
|
||
watch(videoStart, (value) => {
|
||
try {
|
||
localStorage.setItem('aigen-video-start', value)
|
||
} catch { /* ignore */ }
|
||
})
|
||
|
||
watch([globalLocks, familyPermanenceRefs, shotPermanenceRefs], () => {
|
||
try {
|
||
localStorage.setItem(LOCKS_STORE, globalLocks.value)
|
||
localStorage.setItem(REFS_STORE, JSON.stringify({ family: familyPermanenceRefs.value, shots: shotPermanenceRefs.value }))
|
||
} catch { /* ignore */ }
|
||
}, { deep: true })
|
||
|
||
watch(turbo, (on) => {
|
||
if (cfgTouched.value) return
|
||
cfg.value = ltxVideo.value ? 3.5 : (on ? CFG_TURBO : CFG_QUALITY)
|
||
})
|
||
|
||
watch(videoLoraStack, (value) => {
|
||
try { localStorage.setItem('aigen-video-lora', JSON.stringify(value)) } catch { /* ignore */ }
|
||
}, { deep: true })
|
||
|
||
watch(imageLoraStack, (value) => {
|
||
try { localStorage.setItem('aigen-image-lora', JSON.stringify(value)) } catch { /* ignore */ }
|
||
}, { deep: true })
|
||
|
||
watch(imageV2LoraStack, (value) => {
|
||
persistImageV2LoraStack(value, v2Engine.value)
|
||
}, { deep: true })
|
||
|
||
function clampDuration(value: number) {
|
||
return clampVideoDuration(value, defaultVideoDurationPref())
|
||
}
|
||
|
||
function saveDurationAsDefault() {
|
||
const next = setDefaultVideoDurationPref(duration.value)
|
||
duration.value = next
|
||
toast(`Default video length set to ${next}s`)
|
||
}
|
||
|
||
function clampCfg(value: number) {
|
||
return clampVideoCfg(value, turbo.value ? CFG_TURBO : CFG_QUALITY)
|
||
}
|
||
|
||
function onEditStepsNumber() {
|
||
editSteps.value = clampImageSteps(editSteps.value)
|
||
}
|
||
|
||
function onEditCfgNumber() {
|
||
editCfg.value = clampImageCfg(editCfg.value)
|
||
}
|
||
|
||
function onEditScaleMegapixels() {
|
||
editScaleMegapixels.value = clampImageScaleMegapixels(editScaleMegapixels.value)
|
||
}
|
||
|
||
function setV2ScaleOn(on: boolean) {
|
||
v2ScaleOn.value = on
|
||
if (on) v2Megapixels.value = IMAGE_SCALE_MP_DEFAULT
|
||
}
|
||
|
||
function onCfgSlider(event: Event) {
|
||
cfgTouched.value = true
|
||
cfg.value = clampCfg(Number((event.target as HTMLInputElement).value))
|
||
}
|
||
|
||
function onCfgNumber() {
|
||
cfgTouched.value = true
|
||
cfg.value = clampCfg(Number(cfg.value))
|
||
}
|
||
|
||
function restoreFps(raw?: number) {
|
||
return raw === 12 || raw === 30 || raw === 24 ? raw : 24
|
||
}
|
||
|
||
function restoreSampler(raw?: string) {
|
||
return raw === 'euler' || raw === 'dpmpp_2m' || raw === 'res_multistep' ? raw : 'res_multistep'
|
||
}
|
||
|
||
function restoreScheduler(raw?: string) {
|
||
return raw === 'ddim_uniform' || raw === 'sgm_uniform' || raw === 'simple' ? raw : 'simple'
|
||
}
|
||
|
||
function readStoredLoraStack(key: string) {
|
||
try {
|
||
return readLoraStack(localStorage.getItem(key) || '')
|
||
} catch {
|
||
return [] as LoraStackItem[]
|
||
}
|
||
}
|
||
|
||
function imageV2LoraStoreKey(engine: ImageV2Engine) {
|
||
return `aigen-image-v2-lora-${engine}`
|
||
}
|
||
|
||
function persistImageV2LoraStack(stack: LoraStackItem[], engine: ImageV2Engine) {
|
||
try {
|
||
const payload = JSON.stringify(stack)
|
||
localStorage.setItem(imageV2LoraStoreKey(engine), payload)
|
||
localStorage.setItem('aigen-image-v2-lora', payload)
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
function readStoredLoraStackIfPresent(key: string) {
|
||
try {
|
||
if (localStorage.getItem(key) == null) return null
|
||
return readLoraStack(localStorage.getItem(key) || '')
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
function readImageV2LoraStack(engine: ImageV2Engine) {
|
||
const keyed = readStoredLoraStackIfPresent(imageV2LoraStoreKey(engine))
|
||
if (keyed) return filterLoraStackForImageEngine(keyed, engine)
|
||
const legacy = readStoredLoraStackIfPresent('aigen-image-v2-lora')
|
||
if (!legacy) return [] as LoraStackItem[]
|
||
return filterLoraStackForImageEngine(legacy, engine)
|
||
}
|
||
|
||
function setImageV2Snofs(on: boolean) {
|
||
const name = imageV2ConceptName()
|
||
if (!name) return
|
||
if (on) {
|
||
imageV2LoraStack.value = upsertImageV2Lora(imageV2LoraStack.value, name, IMAGE_V2_SNOFS_MODEL, IMAGE_V2_SNOFS_CLIP)
|
||
return
|
||
}
|
||
imageV2LoraStack.value = removeImageV2ConceptLora(imageV2LoraStack.value, v2Engine.value)
|
||
}
|
||
|
||
function onImageV2SnofsToggle(event: Event) {
|
||
setImageV2Snofs((event.target as HTMLInputElement).checked)
|
||
}
|
||
|
||
function mergeLoraOptions(listed: string[], extra: string[]) {
|
||
const names = [...listed]
|
||
for (const name of extra) {
|
||
const value = String(name || '').trim()
|
||
if (value && !names.includes(value)) names.push(value)
|
||
}
|
||
return names
|
||
}
|
||
|
||
async function saveLoraTrigger(name: string, trigger: string) {
|
||
try {
|
||
applyLibrary(await $fetch('/api/library/lora-triggers', {
|
||
method: 'PUT',
|
||
body: { key: loraIdentityKey(name), trigger }
|
||
}))
|
||
} catch (error: any) {
|
||
toast(error?.data?.statusMessage || 'Could not save activation word')
|
||
}
|
||
}
|
||
|
||
function insertKeepPrompt(item: KeepPrompt) {
|
||
prompt.value = insertKeepSnippet(prompt.value, item.text)
|
||
}
|
||
|
||
function applyPromptRestore(saved: string, stored?: { prompt?: string; promptPre?: string; promptPost?: string } | null) {
|
||
const wrappers = resolvePromptWrappers(stored || undefined)
|
||
const mid = String(saved || stored?.prompt || '').trim()
|
||
const parts = restorePromptParts(mid, {
|
||
pre: wrappers.promptPre || stored?.promptPre,
|
||
prompt: mid,
|
||
post: wrappers.promptPost || stored?.promptPost
|
||
})
|
||
promptPre.value = parts.pre
|
||
prompt.value = parts.prompt
|
||
promptPost.value = parts.post
|
||
}
|
||
|
||
function promptPartsForClip(
|
||
clip: LibraryClip | null | undefined,
|
||
midFallback = ''
|
||
) {
|
||
const family = clip ? familyPartsFor(clip) : []
|
||
const wrappers = resolvePromptWrappers(
|
||
clip || undefined,
|
||
family[0],
|
||
{
|
||
promptPre: promptPre.value,
|
||
promptPost: promptPost.value
|
||
}
|
||
)
|
||
const mid = String(clip?.prompt || midFallback || '').trim()
|
||
return restorePromptParts(mid, {
|
||
pre: wrappers.promptPre,
|
||
prompt: mid,
|
||
post: wrappers.promptPost
|
||
})
|
||
}
|
||
|
||
function queuedExtensionFrom(
|
||
item: { prompt?: string; promptPre?: string; promptPost?: string; duration?: number; loraName?: string; loraStack?: LoraStackItem[] },
|
||
duration: number,
|
||
loraStack: LoraStackItem[]
|
||
): QueuedExtension {
|
||
const parts = restorePromptParts(item.prompt || '', {
|
||
pre: item.promptPre,
|
||
prompt: item.prompt,
|
||
post: item.promptPost
|
||
})
|
||
return {
|
||
id: crypto.randomUUID(),
|
||
prompt: parts.prompt,
|
||
promptPre: parts.pre,
|
||
promptPost: parts.post,
|
||
duration,
|
||
loraStack
|
||
}
|
||
}
|
||
|
||
function insertKeepExtend(item: KeepPrompt) {
|
||
extendPrompt.value = insertKeepSnippet(extendPrompt.value, item.text)
|
||
}
|
||
|
||
function insertKeepIntoExtension(id: string, item: KeepPrompt) {
|
||
const row = extensionQueue.value.find(entry => entry.id === id)
|
||
if (row) row.prompt = insertKeepSnippet(row.prompt, item.text)
|
||
}
|
||
|
||
function insertLoraTrigger(phrase: string, target: 'main' | 'extend' | { shot: number } | { extensionId: string } = 'main') {
|
||
const token = String(phrase || '').trim()
|
||
if (!token) return
|
||
if (target === 'extend') {
|
||
extendPrompt.value = insertActivationPhrase(extendPrompt.value, token)
|
||
return
|
||
}
|
||
if (typeof target === 'object' && 'extensionId' in target) {
|
||
const item = extensionQueue.value.find(entry => entry.id === target.extensionId)
|
||
if (item) item.prompt = insertActivationPhrase(item.prompt, token)
|
||
return
|
||
}
|
||
if (typeof target === 'object' && 'shot' in target) {
|
||
prompt.value = insertActivationInShotScript(prompt.value, target.shot, token)
|
||
return
|
||
}
|
||
prompt.value = insertActivationPhrase(prompt.value, token)
|
||
}
|
||
|
||
function setShotLoraStack(shotN: number, stack: LoraStackItem[]) {
|
||
const next = { ...shotLoraStacks.value }
|
||
const items = normalizeLoraStack(stack)
|
||
if (items.length) next[shotN] = items
|
||
else delete next[shotN]
|
||
shotLoraStacks.value = next
|
||
}
|
||
|
||
function applyLoraSelection(stack?: unknown, shotStacks?: Array<unknown>) {
|
||
videoLoraStack.value = readLoraStack(stack as { loraStack?: unknown; loraName?: unknown } | string | null)
|
||
const next: Record<number, LoraStackItem[]> = {}
|
||
;(shotStacks || []).forEach((item, index) => {
|
||
const value = readLoraStack(item as { loraStack?: unknown; loraName?: unknown } | string | null)
|
||
if (value.length && !loraStacksEqual(value, videoLoraStack.value)) next[index + 1] = value
|
||
})
|
||
shotLoraStacks.value = next
|
||
}
|
||
|
||
const currentPresetKind = computed(() => (
|
||
studioMode.value === 'editv2' ? 'imagev2' : studioMode.value === 'edit' ? 'image' : 'video'
|
||
))
|
||
|
||
async function loadDirectorScripts() {
|
||
const listed = await $fetch<{ scripts?: DirectorScript[] }>('/api/director-scripts').catch(() => ({ scripts: [] }))
|
||
directorScripts.value = listed.scripts || []
|
||
}
|
||
|
||
function applyDirectorScript(script: { id?: string; name?: string; text: string }) {
|
||
prompt.value = script.text
|
||
studioMode.value = 'video'
|
||
outputFocus.value = 'video'
|
||
shotScriptMode.value = true
|
||
directorScriptId.value = script.id || ''
|
||
directorScriptName.value = script.name || ''
|
||
}
|
||
|
||
function loadDirectorScript(id: string) {
|
||
const script = directorScripts.value.find(item => item.id === id)
|
||
if (!script) return
|
||
applyDirectorScript(script)
|
||
}
|
||
|
||
async function saveDirectorScript() {
|
||
const name = directorScriptName.value.trim()
|
||
if (!name) {
|
||
toast('Name the script before saving.')
|
||
return
|
||
}
|
||
if (!prompt.value.trim()) {
|
||
toast('Write a shot script before saving.')
|
||
return
|
||
}
|
||
const existing = directorScripts.value.find(item => item.name.toLowerCase() === name.toLowerCase())
|
||
if (existing && !window.confirm(`Overwrite script “${existing.name}”?`)) return
|
||
try {
|
||
const saved = await $fetch<{ script: DirectorScript; scripts: DirectorScript[] }>('/api/director-scripts', {
|
||
method: 'POST',
|
||
body: { name, text: prompt.value, overwrite: Boolean(existing) }
|
||
})
|
||
directorScripts.value = saved.scripts
|
||
directorScriptId.value = saved.script.id
|
||
directorScriptName.value = saved.script.name
|
||
toast(existing ? `Overwrote “${saved.script.name}”.` : `Saved “${saved.script.name}”.`)
|
||
} catch (error: any) {
|
||
toast(error?.data?.statusMessage || 'Could not save that script')
|
||
}
|
||
}
|
||
|
||
async function deleteDirectorScript() {
|
||
const id = directorScriptId.value
|
||
const script = directorScripts.value.find(item => item.id === id)
|
||
if (!id || !script) return
|
||
if (!window.confirm(`Delete script “${script.name}”?`)) return
|
||
try {
|
||
const saved = await $fetch<{ scripts: DirectorScript[] }>(`/api/director-scripts/${id}`, { method: 'DELETE' })
|
||
directorScripts.value = saved.scripts
|
||
directorScriptId.value = ''
|
||
toast(`Deleted “${script.name}”.`)
|
||
} catch (error: any) {
|
||
toast(error?.data?.statusMessage || 'Could not delete that script')
|
||
}
|
||
}
|
||
|
||
async function applyIncomingLibraryItem() {
|
||
const clipId = String(route.query.clip || '').trim()
|
||
const stillId = String(route.query.still || '').trim()
|
||
const use = String(route.query.use || '') === '1'
|
||
if (clipId) {
|
||
const clip = clips.value.find(item => item.id === clipId)
|
||
if (clip) {
|
||
if (use) await useClipAsInput(clip)
|
||
else openClip(clip)
|
||
}
|
||
} else if (stillId) {
|
||
const still = stills.value.find(item => item.id === stillId)
|
||
if (still) {
|
||
if (use) await useStillAsInput(still)
|
||
else openStill(still)
|
||
}
|
||
}
|
||
void router.replace({ path: '/', query: {} })
|
||
}
|
||
|
||
function applyIncomingDirectorScript() {
|
||
const scriptId = String(route.query.script || '').trim()
|
||
if (scriptId) {
|
||
const script = directorScripts.value.find(item => item.id === scriptId)
|
||
if (script) applyDirectorScript(script)
|
||
if (route.query.script) void router.replace({ path: '/', query: {} })
|
||
return
|
||
}
|
||
try {
|
||
const imported = sessionStorage.getItem(DIRECTOR_IMPORT_KEY)
|
||
if (imported) {
|
||
sessionStorage.removeItem(DIRECTOR_IMPORT_KEY)
|
||
applyDirectorScript({ text: imported })
|
||
}
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
async function applyIncomingQueuedJob() {
|
||
try {
|
||
const raw = sessionStorage.getItem(LOAD_GENERATION_PAYLOAD_KEY)
|
||
if (raw) {
|
||
sessionStorage.removeItem(LOAD_GENERATION_PAYLOAD_KEY)
|
||
const loaded = JSON.parse(raw) as { kind?: string; payload?: Record<string, any> }
|
||
if (loaded?.payload) {
|
||
await restoreStudioPayload(loaded.kind || 'video', loaded.payload)
|
||
toast('Saved generation loaded into Input.')
|
||
return
|
||
}
|
||
}
|
||
} catch { /* fall through to queue id */ }
|
||
|
||
let id = ''
|
||
try {
|
||
id = sessionStorage.getItem(LOAD_QUEUED_JOB_KEY) || ''
|
||
if (id) sessionStorage.removeItem(LOAD_QUEUED_JOB_KEY)
|
||
} catch {
|
||
return
|
||
}
|
||
if (!id) return
|
||
try {
|
||
await restoreStudioJob(id)
|
||
toast('Queued job loaded into Input.')
|
||
} catch (error: any) {
|
||
toast(error?.data?.statusMessage || error?.message || 'Could not load that queued job')
|
||
}
|
||
}
|
||
|
||
function onQueuedJobLoaded() {
|
||
void applyIncomingQueuedJob()
|
||
}
|
||
|
||
async function loadGenerationPresets() {
|
||
const [video, image, imagev2] = await Promise.all([
|
||
$fetch<{ presets?: GenerationPreset[] }>('/api/generation-presets', { query: { kind: 'video' } }).catch(() => ({ presets: [] })),
|
||
$fetch<{ presets?: GenerationPreset[] }>('/api/generation-presets', { query: { kind: 'image' } }).catch(() => ({ presets: [] })),
|
||
$fetch<{ presets?: GenerationPreset[] }>('/api/generation-presets', { query: { kind: 'imagev2' } }).catch(() => ({ presets: [] }))
|
||
])
|
||
videoGenerationPresets.value = video.presets || []
|
||
imageGenerationPresets.value = image.presets || []
|
||
imageV2GenerationPresets.value = imagev2.presets || []
|
||
}
|
||
|
||
function currentPresetSnapshot() {
|
||
if (studioMode.value === 'editv2') {
|
||
return {
|
||
kind: 'imagev2' as const,
|
||
loraStack: filterLoraStackForImageEngine(
|
||
filterLoraStackForStudio(imageV2LoraStack.value, ltxEnabled.value),
|
||
v2Engine.value
|
||
),
|
||
settings: {
|
||
mode: v2Mode.value,
|
||
engine: v2Engine.value,
|
||
task: v2Mode.value === 'compose' ? v2Task.value : v2Mode.value === 'refine' ? 'refine' : v2Mode.value === 'generate' ? 't2i' : 'scene',
|
||
prompt: prompt.value,
|
||
negative: v2Negative.value,
|
||
snofsModel: imageV2EngineLoras().find(item => isImageV2ConceptLora(item.name))?.strengthModel ?? 0,
|
||
snofsClip: imageV2EngineLoras().find(item => isImageV2ConceptLora(item.name))?.strengthClip ?? 0,
|
||
consistencyModel: imageV2EngineLoras().find(item => isImageV2ConsistencyLora(item.name))?.strengthModel ?? 0,
|
||
consistencyClip: imageV2EngineLoras().find(item => isImageV2ConsistencyLora(item.name))?.strengthClip ?? 0,
|
||
steps: clampImageSteps(v2Steps.value, v2Engine.value === 'krea' ? IMAGE_V2_KREA_STEPS : IMAGE_V2_STEPS_DEFAULT),
|
||
cfg: clampImageCfg(v2Cfg.value, v2Engine.value === 'krea' ? IMAGE_V2_KREA_CFG : IMAGE_V2_CFG_DEFAULT),
|
||
megapixels: v2Mode.value === 'generate' || !v2ScaleOn.value ? undefined : clampImageScaleMegapixels(v2Megapixels.value, 1),
|
||
scaleToTotalPixels: v2Mode.value === 'generate' ? undefined : v2ScaleOn.value,
|
||
turbo: v2Turbo.value === true,
|
||
strength: v2Mode.value === 'generate' || (v2Mode.value !== 'refine' && v2Engine.value !== 'krea') ? undefined : clampImageV2Denoise(v2Denoise.value),
|
||
aspect: aspect.value,
|
||
width: v2Mode.value === 'generate' ? generateSizeForAspect(aspect.value).width : undefined,
|
||
height: v2Mode.value === 'generate' ? generateSizeForAspect(aspect.value).height : undefined
|
||
}
|
||
}
|
||
}
|
||
if (studioMode.value === 'edit') {
|
||
return {
|
||
kind: 'image' as const,
|
||
loraStack: filterLoraStackForStudio(imageLoraStack.value, ltxEnabled.value),
|
||
settings: {
|
||
prompt: prompt.value,
|
||
steps: clampImageSteps(editSteps.value),
|
||
cfg: clampImageCfg(editCfg.value),
|
||
negative: editNegative.value,
|
||
scaleToTotalPixels: editScaleToTotalPixels.value,
|
||
scaleMegapixels: clampImageScaleMegapixels(editScaleMegapixels.value),
|
||
aspect: aspect.value
|
||
}
|
||
}
|
||
}
|
||
return {
|
||
kind: 'video' as const,
|
||
loraStack: filterLoraStackForStudio(videoLoraStack.value, ltxEnabled.value),
|
||
settings: {
|
||
workflow: videoWorkflow.value,
|
||
turbo: turbo.value,
|
||
cfg: cfg.value,
|
||
fps: fps.value,
|
||
samplerName: samplerName.value,
|
||
scheduler: scheduler.value,
|
||
duration: duration.value,
|
||
aspect: aspect.value,
|
||
sound: withSound.value
|
||
}
|
||
}
|
||
}
|
||
|
||
function applyGenerationPreset(preset: GenerationPreset) {
|
||
const stack = filterLoraStackForStudio(preset.loraStack, ltxEnabled.value)
|
||
const skipped = preset.loraStack.length - stack.length
|
||
if (preset.kind === 'imagev2') {
|
||
const settings = preset.settings as ImageV2PresetSettings
|
||
persistImageV2LoraStack(imageV2LoraStack.value, v2Engine.value)
|
||
v2Mode.value = settings.mode === 'compose'
|
||
? 'compose'
|
||
: settings.mode === 'refine'
|
||
? 'refine'
|
||
: settings.mode === 'generate'
|
||
? 'generate'
|
||
: settings.mode === 'iterate'
|
||
? 'iterate'
|
||
: 'edit'
|
||
if (v2Mode.value !== 'edit' && v2Mode.value !== 'compose') clearEditPasses()
|
||
v2Engine.value = parseImageV2Engine(settings.engine, 'flux')
|
||
v2Task.value = v2Mode.value === 'compose' ? (settings.task || 'scene') : v2Mode.value === 'refine' ? 'refine' : v2Mode.value === 'generate' ? 't2i' : 'scene'
|
||
if (typeof settings.prompt === 'string') prompt.value = settings.prompt
|
||
if (typeof settings.negative === 'string') v2Negative.value = settings.negative
|
||
imageV2LoraStack.value = mergeLegacyImageV2Loras(filterLoraStackForImageEngine(stack, v2Engine.value), {
|
||
engine: v2Engine.value,
|
||
mode: v2Mode.value,
|
||
xaigen: v2ConceptLoraEnabled.value,
|
||
snofsModel: settings.snofsModel,
|
||
snofsClip: settings.snofsClip,
|
||
consistencyModel: settings.consistencyModel,
|
||
consistencyClip: settings.consistencyClip,
|
||
kreaConcept: String(runtimeConfig.public.kreaConceptLora || IMAGE_V2_KREA_CONCEPT_LORA || '').trim()
|
||
})
|
||
v2Steps.value = clampImageSteps(settings.steps, v2Engine.value === 'krea' ? IMAGE_V2_KREA_STEPS : IMAGE_V2_STEPS_DEFAULT)
|
||
v2Cfg.value = clampImageCfg(settings.cfg, v2Engine.value === 'krea' ? IMAGE_V2_KREA_CFG : IMAGE_V2_CFG_DEFAULT)
|
||
v2Megapixels.value = clampImageScaleMegapixels(settings.megapixels, 1)
|
||
v2ScaleOn.value = v2Mode.value !== 'generate' && settings.scaleToTotalPixels !== false
|
||
v2Turbo.value = settings.turbo === true
|
||
if (v2Mode.value === 'refine' || (v2Engine.value === 'krea' && v2Mode.value !== 'generate')) v2Denoise.value = clampImageV2Denoise(settings.strength)
|
||
aspect.value = parseImageAspect(settings.aspect)
|
||
if (v2Mode.value === 'generate') {
|
||
const size = generateSizeForAspect(aspect.value)
|
||
v2Width.value = size.width
|
||
v2Height.value = size.height
|
||
}
|
||
void ensureRefineCanvas()
|
||
imageV2PresetId.value = preset.id
|
||
imageV2PresetName.value = preset.name
|
||
} else if (preset.kind === 'image') {
|
||
imageLoraStack.value = stack
|
||
const settings = preset.settings as {
|
||
prompt?: string
|
||
steps?: number
|
||
cfg?: number
|
||
negative?: string
|
||
scaleToTotalPixels?: boolean
|
||
scaleMegapixels?: number
|
||
aspect?: string
|
||
}
|
||
if (typeof settings.prompt === 'string') prompt.value = settings.prompt
|
||
if (typeof settings.steps === 'number') editSteps.value = clampImageSteps(settings.steps)
|
||
if (typeof settings.cfg === 'number') editCfg.value = clampImageCfg(settings.cfg)
|
||
if (typeof settings.negative === 'string') editNegative.value = settings.negative
|
||
editScaleToTotalPixels.value = settings.scaleToTotalPixels === true
|
||
editScaleMegapixels.value = clampImageScaleMegapixels(settings.scaleMegapixels)
|
||
aspect.value = parseImageAspect(settings.aspect)
|
||
imagePresetId.value = preset.id
|
||
imagePresetName.value = preset.name
|
||
} else {
|
||
videoLoraStack.value = stack
|
||
const settings = preset.settings as {
|
||
workflow?: string
|
||
turbo?: boolean
|
||
cfg?: number
|
||
fps?: number
|
||
samplerName?: string
|
||
scheduler?: string
|
||
duration?: number
|
||
aspect?: string
|
||
sound?: boolean
|
||
}
|
||
const wantedWorkflow = String(settings.workflow || '')
|
||
applyVideoWorkflow(wantedWorkflow)
|
||
if (!ltxEnabled.value && isLtxWorkflow(parseVideoWorkflow(wantedWorkflow))) {
|
||
toast('LTX Pink Cherry is not available on this instance')
|
||
}
|
||
restoreAdvanced({
|
||
turbo: settings.turbo !== false,
|
||
aspect: settings.aspect,
|
||
cfg: settings.cfg,
|
||
fps: settings.fps,
|
||
samplerName: settings.samplerName,
|
||
scheduler: settings.scheduler
|
||
})
|
||
if (typeof settings.duration === 'number') duration.value = clampDuration(settings.duration)
|
||
if (typeof settings.sound === 'boolean') withSound.value = settings.sound
|
||
videoPresetId.value = preset.id
|
||
videoPresetName.value = preset.name
|
||
}
|
||
toast(skipped
|
||
? `Loaded “${preset.name}”. Skipped ${skipped} xAIGen-only LoRA${skipped === 1 ? '' : 's'}.`
|
||
: `Loaded “${preset.name}”.`)
|
||
console.info('[aigen preset apply]', {
|
||
kind: preset.kind,
|
||
name: preset.name,
|
||
hasFile: Boolean(file.value),
|
||
inputStillId: inputStillId.value,
|
||
hasPreview: Boolean(preview.value)
|
||
})
|
||
}
|
||
|
||
function currentPresetList() {
|
||
if (currentPresetKind.value === 'imagev2') return imageV2GenerationPresets.value
|
||
if (currentPresetKind.value === 'image') return imageGenerationPresets.value
|
||
return videoGenerationPresets.value
|
||
}
|
||
|
||
function loadGenerationPreset(id: string) {
|
||
const preset = currentPresetList().find(item => item.id === id)
|
||
if (!preset) return
|
||
applyGenerationPreset(preset)
|
||
}
|
||
|
||
async function saveGenerationPreset() {
|
||
const name = (
|
||
currentPresetKind.value === 'imagev2'
|
||
? imageV2PresetName.value
|
||
: currentPresetKind.value === 'image'
|
||
? imagePresetName.value
|
||
: videoPresetName.value
|
||
).trim()
|
||
if (!name) {
|
||
toast('Name the preset before saving.')
|
||
return
|
||
}
|
||
const list = currentPresetList()
|
||
const existing = list.find(item => item.name.toLowerCase() === name.toLowerCase())
|
||
if (existing && !window.confirm(`Overwrite preset “${existing.name}”?`)) return
|
||
try {
|
||
const body = { name, overwrite: Boolean(existing), ...currentPresetSnapshot() }
|
||
const saved = await $fetch<{ preset: GenerationPreset; presets: GenerationPreset[] }>('/api/generation-presets', {
|
||
method: 'POST',
|
||
body
|
||
})
|
||
if (saved.preset.kind === 'imagev2') {
|
||
imageV2GenerationPresets.value = saved.presets
|
||
imageV2PresetId.value = saved.preset.id
|
||
imageV2PresetName.value = saved.preset.name
|
||
} else if (saved.preset.kind === 'image') {
|
||
imageGenerationPresets.value = saved.presets
|
||
imagePresetId.value = saved.preset.id
|
||
imagePresetName.value = saved.preset.name
|
||
} else {
|
||
videoGenerationPresets.value = saved.presets
|
||
videoPresetId.value = saved.preset.id
|
||
videoPresetName.value = saved.preset.name
|
||
}
|
||
toast(existing ? `Overwrote “${saved.preset.name}”.` : `Saved “${saved.preset.name}”.`)
|
||
} catch (error: any) {
|
||
toast(error?.data?.statusMessage || 'Could not save that preset')
|
||
}
|
||
}
|
||
|
||
async function deleteGenerationPreset() {
|
||
const id = currentPresetKind.value === 'imagev2'
|
||
? imageV2PresetId.value
|
||
: currentPresetKind.value === 'image'
|
||
? imagePresetId.value
|
||
: videoPresetId.value
|
||
const list = currentPresetList()
|
||
const preset = list.find(item => item.id === id)
|
||
if (!id || !preset) return
|
||
if (!window.confirm(`Delete preset “${preset.name}”?`)) return
|
||
try {
|
||
const saved = await $fetch<{ presets: GenerationPreset[] }>(`/api/generation-presets/${id}`, {
|
||
method: 'DELETE',
|
||
query: { kind: currentPresetKind.value }
|
||
})
|
||
if (currentPresetKind.value === 'imagev2') {
|
||
imageV2GenerationPresets.value = saved.presets
|
||
imageV2PresetId.value = ''
|
||
} else if (currentPresetKind.value === 'image') {
|
||
imageGenerationPresets.value = saved.presets
|
||
imagePresetId.value = ''
|
||
} else {
|
||
videoGenerationPresets.value = saved.presets
|
||
videoPresetId.value = ''
|
||
}
|
||
toast(`Deleted “${preset.name}”.`)
|
||
} catch (error: any) {
|
||
toast(error?.data?.statusMessage || 'Could not delete that preset')
|
||
}
|
||
}
|
||
|
||
function restoreAdvanced(item: { turbo: boolean; aspect?: string; width?: number; height?: number; cfg?: number; fps?: number; samplerName?: string; scheduler?: string }) {
|
||
cfgTouched.value = true
|
||
turbo.value = item.turbo
|
||
aspect.value = normalizeAspect(item.aspect)
|
||
if (typeof item.cfg === 'number') {
|
||
cfg.value = clampCfg(item.cfg)
|
||
} else {
|
||
cfg.value = item.turbo ? CFG_TURBO : CFG_QUALITY
|
||
cfgTouched.value = false
|
||
}
|
||
fps.value = restoreFps(item.fps)
|
||
samplerName.value = restoreSampler(item.samplerName)
|
||
scheduler.value = restoreScheduler(item.scheduler)
|
||
}
|
||
|
||
function insertIdentityTemplate() {
|
||
prompt.value = shotScriptMode.value
|
||
? `${IDENTITY_ACTION_TEMPLATE}\n\nshot 2\nThe subject from <Picture 1> continues along the path, turning to take in the wider view.\n\n[AUDIO CUES]\nAmbient forest breeze, soft footsteps, warm cinematic strings.`
|
||
: IDENTITY_ACTION_TEMPLATE
|
||
}
|
||
|
||
interface PromptRecommendSnap {
|
||
jobId?: string
|
||
status?: string
|
||
message?: string
|
||
text?: string
|
||
error?: string
|
||
}
|
||
|
||
async function loadPromptPresets() {
|
||
const data = await $fetch<{ presets?: Array<{ id: string; name: string; description?: string; builtin?: boolean }> }>('/api/prompt/presets').catch(() => ({ presets: [] }))
|
||
const presets = data.presets || []
|
||
if (presets.length) promptPresets.value = presets
|
||
const saved = (() => {
|
||
try { return localStorage.getItem('aigen-prompt-preset') || '' }
|
||
catch { return '' }
|
||
})()
|
||
if (saved && promptPresets.value.some(item => item.id === saved)) {
|
||
recommendPresetId.value = saved
|
||
} else if (!promptPresets.value.some(item => item.id === recommendPresetId.value)) {
|
||
recommendPresetId.value = promptPresets.value[0]?.id || 'enhance'
|
||
}
|
||
if (studioMode.value !== 'video' && recommendPresetId.value === 'shot-script') {
|
||
recommendPresetId.value = 'enhance'
|
||
}
|
||
}
|
||
|
||
async function recommendPrompt() {
|
||
if (recommendDisabled.value || !file.value) return
|
||
recommendBusy.value = true
|
||
recommendError.value = ''
|
||
recommendText.value = ''
|
||
recommendApplied.value = false
|
||
recommendMessage.value = imageSidecarOk.value
|
||
? 'Uploading still to Beast Qwen VL…'
|
||
: 'Checking Beast Comfy…'
|
||
const body = new FormData()
|
||
body.append('image', file.value)
|
||
body.append('prompt', prompt.value)
|
||
body.append('presetId', recommendPresetId.value)
|
||
body.append(
|
||
'mode',
|
||
studioMode.value === 'edit' || studioMode.value === 'editv2' ? 'edit' : (shotScriptMode.value ? 'shot-script' : 'video')
|
||
)
|
||
try {
|
||
const started = await $fetch<PromptRecommendSnap>('/api/prompt/recommend', { method: 'POST', body })
|
||
const jobId = String(started.jobId || '')
|
||
recommendJobId.value = jobId
|
||
if (!jobId) throw new Error('Recommend job did not start')
|
||
while (recommendJobId.value === jobId) {
|
||
const snap = await $fetch<PromptRecommendSnap>(`/api/prompt/recommend/${jobId}`)
|
||
recommendMessage.value = snap.message || recommendMessage.value
|
||
if (snap.status === 'complete') {
|
||
recommendText.value = String(snap.text || '').trim()
|
||
if (!recommendText.value) recommendError.value = 'Beast Qwen VL returned an empty prompt'
|
||
break
|
||
}
|
||
if (snap.status === 'error') {
|
||
recommendError.value = snap.error || snap.message || 'Prompt recommend failed'
|
||
toast(recommendError.value)
|
||
break
|
||
}
|
||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||
}
|
||
} catch (error: any) {
|
||
recommendError.value = error?.data?.statusMessage || error?.statusMessage || error?.message || 'Prompt recommend failed'
|
||
toast(recommendError.value)
|
||
} finally {
|
||
recommendBusy.value = false
|
||
if (recommendJobId.value) recommendJobId.value = ''
|
||
void pollComfyHealth()
|
||
}
|
||
}
|
||
|
||
function applyRecommend() {
|
||
const text = recommendText.value.trim()
|
||
if (!text) return
|
||
promptBeforeRecommend.value = prompt.value
|
||
editPassesBeforeRecommend.value = editPassQueue.value.map(item => ({ ...item }))
|
||
imageBatchBeforeRecommend.value = imageBatchScript.value
|
||
shotScriptBeforeRecommend.value = shotScriptMode.value
|
||
if (studioMode.value === 'edit' || v2PassChaining.value) {
|
||
const fromPasses = parseEditPasses(text)
|
||
const fromScript = parseImageBatchScript(text)
|
||
const parts = fromPasses && fromPasses.length > 1
|
||
? fromPasses
|
||
: fromScript.length > 1
|
||
? fromScript.map(item => item.prompt)
|
||
: null
|
||
if (parts && parts.length > 1) {
|
||
prompt.value = parts[0]
|
||
editPassQueue.value = parts.slice(1).map(item => ({ id: crypto.randomUUID(), prompt: item }))
|
||
imagePassMode.value = 'batch'
|
||
imageBatchScript.value = fromPasses ? '' : text
|
||
} else {
|
||
prompt.value = text
|
||
}
|
||
} else {
|
||
const extracted = extractGlobalLocks(text)
|
||
if (extracted.locks && isBlankGlobalLocks(globalLocks.value)) globalLocks.value = extracted.locks
|
||
prompt.value = extracted.script || text
|
||
if (looksLikeShotScript(prompt.value) || parseShotScript(prompt.value).length > 1) shotScriptMode.value = true
|
||
}
|
||
recommendApplied.value = true
|
||
toast('Recommended prompt applied')
|
||
}
|
||
|
||
function revertRecommend() {
|
||
prompt.value = promptBeforeRecommend.value
|
||
editPassQueue.value = editPassesBeforeRecommend.value.map(item => ({ ...item }))
|
||
imageBatchScript.value = imageBatchBeforeRecommend.value
|
||
shotScriptMode.value = shotScriptBeforeRecommend.value
|
||
recommendApplied.value = false
|
||
}
|
||
|
||
function dismissRecommend() {
|
||
recommendText.value = ''
|
||
recommendError.value = ''
|
||
recommendMessage.value = ''
|
||
recommendApplied.value = false
|
||
}
|
||
|
||
function toast(message: string) {
|
||
const id = crypto.randomUUID()
|
||
toasts.value.push({ id, message })
|
||
setTimeout(() => {
|
||
toasts.value = toasts.value.filter(item => item.id !== id)
|
||
}, 7000)
|
||
}
|
||
|
||
async function copyPrompt(text: string) {
|
||
const promptText = (text || '').trim()
|
||
if (!promptText) {
|
||
toast('This clip has no prompt to copy')
|
||
return
|
||
}
|
||
try {
|
||
await navigator.clipboard.writeText(promptText)
|
||
toast('Prompt copied')
|
||
} catch {
|
||
toast('Could not copy the prompt')
|
||
}
|
||
}
|
||
|
||
function resetImage() {
|
||
cancelInputStillRename()
|
||
if (preview.value) URL.revokeObjectURL(preview.value)
|
||
file.value = null
|
||
inputStillId.value = ''
|
||
preview.value = ''
|
||
imageWidth.value = 0
|
||
imageHeight.value = 0
|
||
refineMaskDirty.value = false
|
||
refinePainter.value?.clear()
|
||
}
|
||
|
||
function clearVideoForm() {
|
||
resetImage()
|
||
prompt.value = ''
|
||
promptPre.value = ''
|
||
promptPost.value = ''
|
||
clipName.value = ''
|
||
extensionQueue.value = []
|
||
shotScriptMode.value = false
|
||
globalLocks.value = ''
|
||
familyPermanenceRefs.value = []
|
||
shotPermanenceRefs.value = {}
|
||
shotLoraStacks.value = {}
|
||
identityRefs.value.forEach((_, index) => clearIdentityRef(index))
|
||
recommendText.value = ''
|
||
recommendError.value = ''
|
||
recommendApplied.value = false
|
||
activeDraftId = ''
|
||
}
|
||
|
||
function stillIdFromLibraryUrl(url: string) {
|
||
const match = String(url || '').match(/\/api\/library\/stills\/([^/?#]+)/)
|
||
return match ? decodeURIComponent(match[1]) : ''
|
||
}
|
||
|
||
const inputStill = computed(() => stills.value.find(item => item.id === inputStillId.value) || null)
|
||
|
||
const inputStillLabel = computed(() => {
|
||
if (inputStill.value) return stillTitle(inputStill.value)
|
||
const name = file.value?.name || ''
|
||
return name.replace(/\.[a-z0-9]+$/i, '') || 'Untitled still'
|
||
})
|
||
|
||
function startInputStillRename() {
|
||
renamingInputStill.value = true
|
||
inputStillNameDraft.value = inputStillLabel.value
|
||
nextTick(() => {
|
||
inputStillRenameEl.value?.focus()
|
||
inputStillRenameEl.value?.select()
|
||
})
|
||
}
|
||
|
||
function cancelInputStillRename() {
|
||
renamingInputStill.value = false
|
||
}
|
||
|
||
async function saveInputStillName() {
|
||
if (!renamingInputStill.value) return
|
||
const name = inputStillNameDraft.value.trim().slice(0, 80)
|
||
if (!name) {
|
||
toast('A name is required')
|
||
return
|
||
}
|
||
if (name === inputStillLabel.value) {
|
||
cancelInputStillRename()
|
||
return
|
||
}
|
||
if (file.value) {
|
||
const ext = (file.value.name.match(/\.[a-z0-9]+$/i) || ['.png'])[0]
|
||
file.value = new File([file.value], `${clipFileName(name)}${ext}`, { type: file.value.type || 'image/png' })
|
||
}
|
||
const id = inputStillId.value
|
||
if (id) {
|
||
try {
|
||
applyLibrary(await $fetch(`/api/library/stills/${id}`, {
|
||
method: 'PATCH',
|
||
body: { name }
|
||
}))
|
||
} catch (error: any) {
|
||
toast(error?.data?.statusMessage || 'Could not rename still')
|
||
return
|
||
}
|
||
}
|
||
cancelInputStillRename()
|
||
}
|
||
|
||
function readFile(next: File, opts?: { keepThumbnailPref?: boolean; stillId?: string }) {
|
||
cancelInputStillRename()
|
||
if (preview.value) URL.revokeObjectURL(preview.value)
|
||
file.value = next
|
||
inputStillId.value = opts?.stillId || ''
|
||
preview.value = URL.createObjectURL(next)
|
||
if (!opts?.keepThumbnailPref) applyThumbnailDefault()
|
||
const img = new Image()
|
||
img.onload = () => {
|
||
imageWidth.value = img.naturalWidth
|
||
imageHeight.value = img.naturalHeight
|
||
}
|
||
img.src = preview.value
|
||
}
|
||
|
||
function openPicker(slot: 'main' | 'editRef' | number = 'main') {
|
||
pickerKind.value = 'image'
|
||
pickerSlot.value = slot
|
||
void nextTick(() => {
|
||
pickerOpen.value = true
|
||
})
|
||
}
|
||
|
||
function openPermanencePicker(shot?: number, clipId?: string) {
|
||
pickerKind.value = 'permanence'
|
||
if (clipId) pickerSlot.value = `clip:${clipId}`
|
||
else if (typeof shot === 'number') pickerSlot.value = `perm:${shot}`
|
||
else pickerSlot.value = 'perm'
|
||
void nextTick(() => {
|
||
pickerOpen.value = true
|
||
})
|
||
}
|
||
|
||
function clipBindingKeys(clip: LibraryClip, fallback?: LibraryClip) {
|
||
return parseLockBindingKeys(clip.globalLocks || fallback?.globalLocks || '')
|
||
}
|
||
|
||
function addPermanenceRef(stillId: string, shot?: number) {
|
||
const keys = lockBindingKeys.value
|
||
if (typeof shot === 'number') {
|
||
const current = shotPermanenceRefs.value[shot] || []
|
||
if (current.some(item => item.stillId === stillId) || current.length >= 4) return
|
||
shotPermanenceRefs.value = { ...shotPermanenceRefs.value, [shot]: [...current, { stillId, label: nextPermanenceLabel([...familyPermanenceRefs.value, ...current], keys) }] }
|
||
return
|
||
}
|
||
if (familyPermanenceRefs.value.some(item => item.stillId === stillId) || familyPermanenceRefs.value.length >= 6) return
|
||
familyPermanenceRefs.value = [...familyPermanenceRefs.value, { stillId, label: nextPermanenceLabel(familyPermanenceRefs.value, keys) }]
|
||
}
|
||
|
||
function removeFamilyRef(index: number) {
|
||
familyPermanenceRefs.value = familyPermanenceRefs.value.filter((_, i) => i !== index)
|
||
}
|
||
|
||
function updateFamilyRefLabel(index: number, label: string) {
|
||
familyPermanenceRefs.value = familyPermanenceRefs.value.map((item, i) => i === index ? { ...item, label: sanitizeRefLabel(label) } : item)
|
||
}
|
||
|
||
function removeShotRef(shot: number, index: number) {
|
||
const current = (shotPermanenceRefs.value[shot] || []).filter((_, i) => i !== index)
|
||
const next = { ...shotPermanenceRefs.value }
|
||
if (current.length) next[shot] = current
|
||
else delete next[shot]
|
||
shotPermanenceRefs.value = next
|
||
}
|
||
|
||
function updateShotRefLabel(shot: number, index: number, label: string) {
|
||
const current = (shotPermanenceRefs.value[shot] || []).map((item, i) => i === index ? { ...item, label: sanitizeRefLabel(label) } : item)
|
||
shotPermanenceRefs.value = { ...shotPermanenceRefs.value, [shot]: current }
|
||
}
|
||
|
||
async function persistClipRefs(clip: LibraryClip, refs: PermanenceRef[]) {
|
||
try {
|
||
applyLibrary(await $fetch(`/api/library/clips/${clip.id}`, {
|
||
method: 'PATCH',
|
||
body: { permanenceRefs: refs }
|
||
}))
|
||
} catch (error: any) {
|
||
toast(error?.data?.statusMessage || 'Could not save permanence refs')
|
||
}
|
||
}
|
||
|
||
function removeClipRef(clip: LibraryClip, index: number) {
|
||
void persistClipRefs(clip, (clip.permanenceRefs || []).filter((_, i) => i !== index))
|
||
}
|
||
|
||
function updateClipRefLabel(clip: LibraryClip, index: number, label: string) {
|
||
void persistClipRefs(clip, (clip.permanenceRefs || []).map((item, i) => i === index ? { ...item, label: sanitizeRefLabel(label) } : item))
|
||
}
|
||
|
||
function onPickedPermanence(still: { id: string }) {
|
||
if (pickerKind.value !== 'permanence') return
|
||
const slot = pickerSlot.value
|
||
if (typeof slot !== 'string' || slot === 'main' || slot === 'editRef') return
|
||
if (slot.startsWith('clip:')) {
|
||
const clipId = slot.slice(5)
|
||
const clip = clips.value.find(item => item.id === clipId)
|
||
if (!clip) {
|
||
pickerOpen.value = false
|
||
return
|
||
}
|
||
if ((clip.permanenceRefs || []).some(item => item.stillId === still.id)) {
|
||
pickerOpen.value = false
|
||
return
|
||
}
|
||
void persistClipRefs(clip, [...(clip.permanenceRefs || []), { stillId: still.id, label: nextPermanenceLabel(clip.permanenceRefs || [], parseLockBindingKeys(clip.globalLocks || '')) }])
|
||
pickerOpen.value = false
|
||
return
|
||
}
|
||
if (slot === 'perm' || slot.startsWith('perm:')) {
|
||
const shot = slot === 'perm' ? undefined : Number(slot.slice(5))
|
||
addPermanenceRef(still.id, Number.isFinite(shot) ? shot : undefined)
|
||
}
|
||
pickerOpen.value = false
|
||
}
|
||
|
||
function setEditRef(next: File) {
|
||
if (editRefPreview.value) URL.revokeObjectURL(editRefPreview.value)
|
||
editRefFile.value = next
|
||
editRefPreview.value = URL.createObjectURL(next)
|
||
}
|
||
|
||
function clearEditRef() {
|
||
if (editRefPreview.value) URL.revokeObjectURL(editRefPreview.value)
|
||
editRefFile.value = null
|
||
editRefPreview.value = ''
|
||
}
|
||
|
||
function setIdentityRef(index: number, next: File) {
|
||
const prev = identityPreviews.value[index]
|
||
if (prev) URL.revokeObjectURL(prev)
|
||
const copies = identityRefs.value.slice()
|
||
copies[index] = next
|
||
identityRefs.value = copies
|
||
const previews = identityPreviews.value.slice()
|
||
previews[index] = URL.createObjectURL(next)
|
||
identityPreviews.value = previews
|
||
}
|
||
|
||
function clearIdentityRef(index: number) {
|
||
const prev = identityPreviews.value[index]
|
||
if (prev) URL.revokeObjectURL(prev)
|
||
const copies = identityRefs.value.slice()
|
||
copies[index] = null
|
||
identityRefs.value = copies
|
||
const previews = identityPreviews.value.slice()
|
||
previews[index] = ''
|
||
identityPreviews.value = previews
|
||
}
|
||
|
||
function onPickedImage(next: File, hidden = false, stillId?: string) {
|
||
if (pickerKind.value === 'permanence') {
|
||
if (stillId) onPickedPermanence({ id: stillId })
|
||
else pickerOpen.value = false
|
||
return
|
||
}
|
||
pickerOpen.value = false
|
||
const slot = pickerSlot.value
|
||
if (typeof slot === 'string' && slot !== 'main' && slot !== 'editRef') return
|
||
if (slot === 'editRef') {
|
||
setEditRef(next)
|
||
return
|
||
}
|
||
if (slot !== 'main') {
|
||
setIdentityRef(Number(slot), next)
|
||
return
|
||
}
|
||
if (hidden && showPrivacyToggles.value) hideInputPreview.value = true
|
||
readFile(next, stillId ? { stillId } : undefined)
|
||
}
|
||
|
||
async function persistDroppedStill(next: File) {
|
||
if (/last[\s._-]*frame/i.test(next.name || '')) return
|
||
const body = new FormData()
|
||
body.append('image', next)
|
||
body.append('folderId', folderId.value)
|
||
body.append('hideInput', String(hideInputPreview.value))
|
||
try {
|
||
const result = await $fetch<{ still?: { id: string } }>('/api/library/stills', { method: 'POST', body })
|
||
applyLibrary(result)
|
||
if (result.still?.id) inputStillId.value = result.still.id
|
||
} catch (error: any) {
|
||
toast(error?.data?.statusMessage || 'Could not save still to the library')
|
||
}
|
||
}
|
||
|
||
function onDrop(event: DragEvent) {
|
||
dragging.value = false
|
||
const next = event.dataTransfer?.files?.[0]
|
||
if (!next) return
|
||
readFile(next)
|
||
void persistDroppedStill(next)
|
||
}
|
||
|
||
function startTimer(kind: 'video' | 'edit', offset = 0) {
|
||
const elapsed = kind === 'edit' ? editElapsedMs : elapsedMs
|
||
elapsed.value = offset
|
||
stopTimer(kind)
|
||
const started = Date.now() - offset
|
||
const handle = setInterval(() => {
|
||
elapsed.value = Date.now() - started
|
||
}, 1000)
|
||
if (kind === 'edit') editTimer = handle
|
||
else videoTimer = handle
|
||
}
|
||
|
||
function persistActiveJob(kind: 'video' | 'edit', id: string, hidden: boolean, folderLocked: boolean) {
|
||
const key = kind === 'edit' ? EDIT_JOB_STORE : JOB_STORE
|
||
try {
|
||
localStorage.setItem(key, JSON.stringify({ jobId: id, hideThumbnail: hidden, folderLocked, savedAt: Date.now() }))
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
function persistActiveQueue(id: string) {
|
||
activeQueueId.value = id
|
||
try {
|
||
localStorage.setItem(QUEUE_STORE, id)
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
async function refreshLiveQueue() {
|
||
const id = activeQueueId.value
|
||
if (!id) return
|
||
const queue = await $fetch<{
|
||
id: string
|
||
status: string
|
||
pendingCount: number
|
||
completedCount: number
|
||
totalCount: number
|
||
segments: Array<{ index: number; prompt: string; duration: number; status: string; clipId?: string }>
|
||
}>(`/api/queues/${id}`).catch(() => null)
|
||
if (!queue) return
|
||
liveQueue.value = queue
|
||
queuePendingCount.value = queue.status === 'complete' ? queuePendingCount.value : queue.pendingCount
|
||
completedChainStep.value = queue.completedCount
|
||
if (queue.status === 'complete' && !videoBusy.value) {
|
||
try { localStorage.removeItem(QUEUE_STORE) } catch { /* ignore */ }
|
||
}
|
||
}
|
||
|
||
function studioJobStatusLabel(job: { status: string; cutIn?: boolean; holdForCutIn?: boolean; lastError?: string }) {
|
||
if (job.status === 'running') return 'Generating'
|
||
if (job.status === 'held') return job.holdForCutIn ? 'Paused for cut-in' : 'Paused'
|
||
if (job.status === 'waiting') return job.cutIn ? 'Next after current shot' : 'Waiting'
|
||
if (job.status === 'error') return job.lastError || 'Error'
|
||
return job.status
|
||
}
|
||
|
||
function studioJobShotLine(job: { kind?: string; shotCount: number; completedCount?: number; status: string }) {
|
||
if (job.kind === 'music') {
|
||
return String(job.payload?.engine || '').toLowerCase() === 'yue' ? 'YuE' : 'ACE-Step'
|
||
}
|
||
if (job.kind === 'edit') {
|
||
const total = job.shotCount || 1
|
||
return total <= 1 ? 'image edit' : `${total} passes`
|
||
}
|
||
const total = job.shotCount || 1
|
||
const done = job.completedCount || 0
|
||
if (total <= 1) return '1 clip'
|
||
if (job.status === 'waiting') return `${total} shots`
|
||
return `shot ${Math.min(total, done + 1)} of ${total}`
|
||
}
|
||
|
||
async function refreshStudioQueue() {
|
||
const data = await $fetch<{
|
||
jobs?: Array<{
|
||
id: string
|
||
status: string
|
||
kind?: string
|
||
name: string
|
||
prompt: string
|
||
shotCount: number
|
||
liveJobId?: string
|
||
shotQueueId?: string
|
||
stillId?: string
|
||
hideThumbnail?: boolean
|
||
folderLocked?: boolean
|
||
cutIn?: boolean
|
||
holdForCutIn?: boolean
|
||
lastError?: string
|
||
completedCount?: number
|
||
pendingCount?: number
|
||
}>
|
||
}>('/api/studio-queue').catch(() => ({ jobs: [] }))
|
||
studioJobs.value = data.jobs || []
|
||
const running = studioJobs.value.find(job => job.status === 'running' && job.liveJobId && job.kind !== 'music')
|
||
if (running?.liveJobId) videoAwaitingBeast.value = false
|
||
if (watchingLiveVideo() && running?.liveJobId && running.liveJobId !== videoJobId.value) {
|
||
return
|
||
}
|
||
if (watchingLiveEdit() && running?.liveJobId && running.liveJobId !== editJobId.value) {
|
||
return
|
||
}
|
||
if (running?.kind === 'edit') {
|
||
if (running.liveJobId && running.liveJobId !== editJobId.value && !editBusy.value && !watchingLiveVideo()) {
|
||
outputStudioJobId.value = running.id
|
||
editJobId.value = running.liveJobId
|
||
persistActiveJob('edit', running.liveJobId, running.hideThumbnail === true, running.folderLocked === true)
|
||
listen('edit', running.liveJobId, running.hideThumbnail === true, running.folderLocked === true)
|
||
editBusy.value = true
|
||
editStatusMessage.value = 'Starting queued edit…'
|
||
startTimer('edit')
|
||
} else if (running.id && running.liveJobId === editJobId.value) {
|
||
outputStudioJobId.value = running.id
|
||
}
|
||
} else if (running?.kind === 'music') {
|
||
return
|
||
} else if (running?.liveJobId && running.liveJobId !== videoJobId.value && !videoBusy.value && !watchingLiveVideo() && !extendOpen.value) {
|
||
outputStudioJobId.value = running.id
|
||
videoJobId.value = running.liveJobId
|
||
persistActiveJob('video', running.liveJobId, running.hideThumbnail === true, running.folderLocked === true)
|
||
listen('video', running.liveJobId, running.hideThumbnail === true, running.folderLocked === true)
|
||
if (running.shotQueueId) {
|
||
persistActiveQueue(running.shotQueueId)
|
||
void refreshLiveQueue()
|
||
}
|
||
videoBusy.value = true
|
||
statusMessage.value = 'Starting queued job…'
|
||
startTimer('video')
|
||
} else if (running?.id && running.liveJobId === videoJobId.value) {
|
||
outputStudioJobId.value = running.id
|
||
}
|
||
}
|
||
|
||
async function cutInStudioJob(id: string) {
|
||
try {
|
||
const result = await $fetch<{ started?: boolean; deferred?: boolean }>(`/api/studio-queue/${id}/cut-in`, { method: 'POST' })
|
||
toast(result.started
|
||
? 'Starting now.'
|
||
: (result.deferred
|
||
? 'This job will run after the current shot, as its own clip.'
|
||
: 'Still waiting — try Start now again in a moment.'))
|
||
await refreshStudioQueue()
|
||
} catch (error: any) {
|
||
toast(error?.data?.statusMessage || error?.statusMessage || 'Could not move that job next')
|
||
}
|
||
}
|
||
|
||
async function retryStudioJob(id: string) {
|
||
try {
|
||
const result = await $fetch<{ started?: boolean }>(`/api/studio-queue/${id}/retry`, { method: 'POST' })
|
||
toast(result.started ? 'Starting now.' : 'Queued to run as soon as the GPU is free.')
|
||
await refreshStudioQueue()
|
||
} catch (error: any) {
|
||
toast(error?.data?.statusMessage || error?.statusMessage || 'Could not retry that job')
|
||
}
|
||
}
|
||
|
||
async function dismissStudioJob(id: string) {
|
||
try {
|
||
await $fetch(`/api/studio-queue/${id}`, { method: 'DELETE' })
|
||
if (outputStudioJobId.value === id) outputStudioJobId.value = ''
|
||
await refreshStudioQueue()
|
||
} catch (error: any) {
|
||
toast(error?.data?.statusMessage || error?.statusMessage || 'Could not remove that job')
|
||
}
|
||
}
|
||
|
||
async function restoreStudioEditJob(payload: {
|
||
prompt?: string
|
||
name?: string
|
||
folderId?: string
|
||
aspect?: string
|
||
width?: number
|
||
height?: number
|
||
stillId?: string
|
||
stillFilename?: string
|
||
hideInput?: boolean
|
||
promptPre?: string
|
||
promptPost?: string
|
||
loraName?: string
|
||
loraStack?: LoraStackItem[]
|
||
negative?: string
|
||
imagePipeline?: 'v1' | 'v2'
|
||
v2Mode?: 'edit' | 'compose' | 'refine' | 'generate' | 'iterate'
|
||
v2Task?: 'scene' | 'identity' | 'outfit' | 'face_lock' | 'refine' | 't2i'
|
||
engine?: 'flux' | 'krea'
|
||
snofsModel?: number
|
||
snofsClip?: number
|
||
consistencyModel?: number
|
||
consistencyClip?: number
|
||
refineStrength?: number
|
||
scaleToTotalPixels?: boolean
|
||
scaleMegapixels?: number
|
||
steps?: number
|
||
cfg?: number
|
||
turbo?: boolean
|
||
referenceStillId?: string
|
||
passes?: ImageIteration[]
|
||
passMode?: ImagePassMode
|
||
seed?: number
|
||
}) {
|
||
closeExtend()
|
||
applyPromptRestore(payload.prompt || '', payload)
|
||
clipName.value = payload.name || ''
|
||
if (payload.folderId) {
|
||
folderId.value = payload.folderId
|
||
browseFolderId.value = payload.folderId
|
||
}
|
||
editPassQueue.value = (payload.passes || []).map(pass => ({ ...pass, id: crypto.randomUUID() }))
|
||
imagePassMode.value = payload.passMode || 'batch'
|
||
imageBatchScript.value = ''
|
||
seedInput.value = payload.seed == null ? '' : String(payload.seed)
|
||
const v2 = payload.imagePipeline === 'v2'
|
||
if (v2) {
|
||
studioMode.value = 'editv2'
|
||
outputFocus.value = 'edit'
|
||
const mode = payload.v2Mode === 'compose' || payload.v2Mode === 'refine' || payload.v2Mode === 'generate' || payload.v2Mode === 'iterate'
|
||
? payload.v2Mode
|
||
: 'edit'
|
||
setV2Mode(mode)
|
||
persistImageV2LoraStack(imageV2LoraStack.value, v2Engine.value)
|
||
v2Engine.value = parseImageV2Engine(payload.engine, 'flux')
|
||
if (typeof payload.negative === 'string') v2Negative.value = payload.negative
|
||
if (payload.steps != null) v2Steps.value = clampImageSteps(payload.steps, v2Engine.value === 'krea' ? IMAGE_V2_KREA_STEPS : IMAGE_V2_STEPS_DEFAULT)
|
||
if (payload.cfg != null) v2Cfg.value = clampImageCfg(payload.cfg, v2Engine.value === 'krea' ? IMAGE_V2_KREA_CFG : IMAGE_V2_CFG_DEFAULT)
|
||
if (payload.scaleMegapixels != null) v2Megapixels.value = clampImageScaleMegapixels(payload.scaleMegapixels, 1)
|
||
v2ScaleOn.value = mode !== 'generate' && payload.scaleToTotalPixels !== false
|
||
v2Turbo.value = payload.turbo === true
|
||
if ((mode === 'refine' || (v2Engine.value === 'krea' && mode !== 'generate')) && payload.refineStrength != null) {
|
||
v2Denoise.value = clampImageV2Denoise(payload.refineStrength)
|
||
}
|
||
aspect.value = parseImageAspect(payload.aspect)
|
||
if (mode === 'generate') {
|
||
const size = generateSizeForAspect(aspect.value)
|
||
v2Width.value = size.width
|
||
v2Height.value = size.height
|
||
}
|
||
if (mode === 'compose' && payload.v2Task) v2Task.value = payload.v2Task
|
||
imageV2LoraStack.value = mergeLegacyImageV2Loras(readLoraStack(payload), {
|
||
engine: v2Engine.value,
|
||
mode,
|
||
xaigen: v2ConceptLoraEnabled.value,
|
||
snofsModel: payload.snofsModel,
|
||
snofsClip: payload.snofsClip,
|
||
consistencyModel: payload.consistencyModel,
|
||
consistencyClip: payload.consistencyClip,
|
||
kreaConcept: String(runtimeConfig.public.kreaConceptLora || IMAGE_V2_KREA_CONCEPT_LORA || '').trim()
|
||
})
|
||
} else {
|
||
studioMode.value = 'edit'
|
||
outputFocus.value = 'edit'
|
||
if (payload.steps != null) editSteps.value = clampImageSteps(payload.steps)
|
||
if (payload.cfg != null) editCfg.value = clampImageCfg(payload.cfg)
|
||
if (typeof payload.negative === 'string') editNegative.value = payload.negative
|
||
editScaleToTotalPixels.value = payload.scaleToTotalPixels === true
|
||
if (payload.scaleMegapixels != null) editScaleMegapixels.value = clampImageScaleMegapixels(payload.scaleMegapixels)
|
||
imageLoraStack.value = filterLoraStackForStudio(readLoraStack(payload), ltxEnabled.value)
|
||
aspect.value = parseImageAspect(payload.aspect)
|
||
}
|
||
// Always restore Still A when the payload has one — including Generate jobs —
|
||
// so switching to Edit / Compose / Refine keeps the image in the input slot.
|
||
if (payload.stillId) {
|
||
const blob = await $fetch<Blob>(`/api/library/stills/${payload.stillId}`, { responseType: 'blob' })
|
||
if (payload.hideInput && showPrivacyToggles.value) hideInputPreview.value = true
|
||
readFile(new File([blob], stillFileName(stills.value.find(item => item.id === payload.stillId)) || payload.stillFilename || 'still.png', { type: blob.type || 'image/png' }), { stillId: payload.stillId })
|
||
}
|
||
if (v2 && payload.v2Mode === 'compose' && payload.referenceStillId) {
|
||
await loadStillAsEditRef(payload.referenceStillId).catch(() => null)
|
||
} else if (!(v2 && payload.v2Mode === 'compose')) {
|
||
clearEditRef()
|
||
}
|
||
}
|
||
|
||
async function restoreStudioJob(id: string) {
|
||
const job = await $fetch<{
|
||
payload?: Parameters<typeof restoreStudioPayload>[1]
|
||
kind?: string
|
||
imagePipeline?: string
|
||
}>(`/api/studio-queue/${id}`)
|
||
const payload = job.payload
|
||
if (!payload) throw new Error('That job has no saved inputs')
|
||
await restoreStudioPayload(job.kind || (payload.imagePipeline === 'v2' ? 'edit' : 'video'), payload)
|
||
}
|
||
|
||
async function restoreStudioPayload(kind: string, payload: {
|
||
prompt?: string
|
||
name?: string
|
||
folderId?: string
|
||
aspect?: string
|
||
width?: number
|
||
height?: number
|
||
turbo?: boolean
|
||
seed?: number
|
||
cfg?: number
|
||
fps?: number
|
||
samplerName?: string
|
||
scheduler?: string
|
||
duration?: number
|
||
sound?: boolean
|
||
workflow?: string
|
||
useIdentityRefs?: boolean
|
||
stillId?: string
|
||
stillFilename?: string
|
||
hideThumbnail?: boolean
|
||
hideInput?: boolean
|
||
referenceStillIds?: Array<string | null>
|
||
extensions?: { prompt: string; promptPre?: string; promptPost?: string; duration: number; permanenceRefs?: PermanenceRef[]; loraName?: string; loraStack?: LoraStackItem[] }[]
|
||
queueAutoRun?: boolean
|
||
refineExtensionFrame?: boolean
|
||
saveLosslessAnchor?: boolean
|
||
refinementDenoise?: number
|
||
promptPre?: string
|
||
promptPost?: string
|
||
promptMid?: string
|
||
globalLocks?: string
|
||
permanenceRefs?: PermanenceRef[]
|
||
shotPermanenceRefs?: PermanenceRef[][]
|
||
loraName?: string
|
||
loraStack?: LoraStackItem[]
|
||
shotLoras?: string[]
|
||
shotLoraStacks?: LoraStackItem[][]
|
||
negative?: string
|
||
imagePipeline?: 'v1' | 'v2'
|
||
v2Mode?: 'edit' | 'compose' | 'refine' | 'generate' | 'iterate'
|
||
v2Task?: 'scene' | 'identity' | 'outfit' | 'face_lock' | 'refine' | 't2i'
|
||
engine?: 'flux' | 'krea'
|
||
snofsModel?: number
|
||
snofsClip?: number
|
||
consistencyModel?: number
|
||
consistencyClip?: number
|
||
refineStrength?: number
|
||
scaleToTotalPixels?: boolean
|
||
scaleMegapixels?: number
|
||
steps?: number
|
||
referenceStillId?: string
|
||
passes?: ImageIteration[]
|
||
passMode?: ImagePassMode
|
||
}) {
|
||
if (kind === 'edit' || payload.imagePipeline === 'v2') {
|
||
await restoreStudioEditJob(payload)
|
||
return
|
||
}
|
||
studioMode.value = 'video'
|
||
outputFocus.value = 'video'
|
||
closeExtend()
|
||
const extensions = payload.extensions || []
|
||
const queuedAsCards = extensions.some(item =>
|
||
Object.prototype.hasOwnProperty.call(item, 'promptPre')
|
||
|| Object.prototype.hasOwnProperty.call(item, 'promptPost')
|
||
)
|
||
applyVideoWorkflow(payload.workflow)
|
||
const mid = String(payload.promptMid || '').trim() || String(payload.prompt || '').trim()
|
||
applyPromptRestore(queuedAsCards || !extensions.length
|
||
? mid
|
||
: [mid, ...extensions.map((item, index) => `shot ${index + 2}\n${String(item.prompt || '').trim()}`)].join('\n\n'), {
|
||
...payload,
|
||
prompt: mid,
|
||
promptPre: payload.promptPre,
|
||
promptPost: payload.promptPost
|
||
})
|
||
clipName.value = payload.name || ''
|
||
seedInput.value = String(payload.seed || '')
|
||
restoreAdvanced({
|
||
turbo: payload.turbo === true,
|
||
aspect: payload.aspect,
|
||
width: payload.width,
|
||
height: payload.height,
|
||
cfg: payload.cfg,
|
||
fps: payload.fps,
|
||
samplerName: payload.samplerName,
|
||
scheduler: payload.scheduler
|
||
})
|
||
duration.value = clampDuration(Number(payload.duration))
|
||
hideThumbnail.value = showPrivacyToggles.value && payload.hideThumbnail === true
|
||
if (showPrivacyToggles.value) hideInputPreview.value = payload.hideInput === true
|
||
if (payload.folderId) {
|
||
folderId.value = payload.folderId
|
||
browseFolderId.value = payload.folderId
|
||
}
|
||
if (typeof payload.sound === 'boolean') withSound.value = payload.sound
|
||
shotScriptMode.value = extensions.length > 0 && !queuedAsCards
|
||
identityRefs.value.forEach((_, index) => clearIdentityRef(index))
|
||
extensionQueue.value = extensions.map(item => queuedExtensionFrom(
|
||
item,
|
||
clampDuration(Number(item.duration)),
|
||
readLoraStack(item)
|
||
))
|
||
queueAutoRun.value = payload.queueAutoRun === true
|
||
refineExtensionFrame.value = payload.refineExtensionFrame !== false
|
||
saveLosslessAnchor.value = payload.saveLosslessAnchor === true
|
||
refinementDenoise.value = clampRefinementDenoise(payload.refinementDenoise, REFINEMENT_DENOISE_DEFAULT)
|
||
globalLocks.value = payload.globalLocks || ''
|
||
familyPermanenceRefs.value = payload.permanenceRefs || []
|
||
const shotRefs: Record<number, PermanenceRef[]> = {}
|
||
;(payload.shotPermanenceRefs || []).forEach((refs, index) => {
|
||
if (refs?.length) shotRefs[index + 1] = refs
|
||
})
|
||
shotPermanenceRefs.value = shotRefs
|
||
applyLoraSelection(
|
||
payload,
|
||
payload.shotLoraStacks || payload.shotLoras || [payload, ...extensions]
|
||
)
|
||
const stillId = payload.stillId
|
||
if (stillId) {
|
||
const blob = await $fetch<Blob>(`/api/library/stills/${stillId}`, { responseType: 'blob' })
|
||
readFile(new File([blob], stillFileName(stills.value.find(item => item.id === stillId)) || payload.stillFilename || 'still.png', { type: blob.type || 'image/png' }), { keepThumbnailPref: true, stillId })
|
||
} else {
|
||
resetImage()
|
||
}
|
||
for (const [index, refId] of (payload.referenceStillIds || []).entries()) {
|
||
if (!refId || index >= 4) continue
|
||
try {
|
||
const blob = await $fetch<Blob>(`/api/library/stills/${refId}`, { responseType: 'blob' })
|
||
setIdentityRef(index, new File([blob], `identity-ref-${index + 2}.png`, { type: blob.type || 'image/png' }))
|
||
} catch { /* skip missing identity stills */ }
|
||
}
|
||
}
|
||
|
||
async function restoreOutputJob() {
|
||
const id = outputRestoreId.value
|
||
if (!id) return
|
||
try {
|
||
await restoreStudioJob(id)
|
||
statusMessage.value = 'Job loaded into Input. Generate when you are ready.'
|
||
toast('Inputs restored. Make any changes, then generate.')
|
||
} catch (error: any) {
|
||
toast(error?.data?.statusMessage || error?.message || 'Could not restore that job')
|
||
}
|
||
}
|
||
|
||
function clearActiveJob(kind?: 'video' | 'edit') {
|
||
try {
|
||
if (!kind || kind === 'video') localStorage.removeItem(JOB_STORE)
|
||
if (!kind || kind === 'edit') localStorage.removeItem(EDIT_JOB_STORE)
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
function readStoredJob(key = JOB_STORE) {
|
||
try {
|
||
const raw = localStorage.getItem(key)
|
||
if (!raw) return null
|
||
const parsed = JSON.parse(raw) as { jobId?: string; hideThumbnail?: boolean; folderLocked?: boolean }
|
||
return parsed?.jobId ? parsed : null
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
async function resumeTrack(kind: 'video' | 'edit', id: string, hidden: boolean, locked: boolean) {
|
||
try {
|
||
const snap = await $fetch<Record<string, any>>(`/api/generate/${id}`)
|
||
if (kind === 'edit') {
|
||
editJobId.value = id
|
||
concealEditOutput.value = hidden
|
||
editSettledUi = false
|
||
} else {
|
||
videoJobId.value = id
|
||
concealOutput.value = hidden
|
||
videoSettledUi = false
|
||
}
|
||
applyJobEvent(kind, snap, hidden, locked, id)
|
||
const running = snap.status === 'queued' || snap.status === 'uploading' || snap.status === 'running'
|
||
const settled = kind === 'edit' ? editSettledUi : videoSettledUi
|
||
if (running && !settled) {
|
||
if (kind === 'edit') {
|
||
editBusy.value = true
|
||
editStatusMessage.value = snap.message || 'Reconnecting to edit ComfyUI...'
|
||
} else {
|
||
videoBusy.value = true
|
||
statusMessage.value = snap.message || 'Waiting for ComfyUI to finish this job...'
|
||
}
|
||
startTimer(kind, Number(snap.elapsedMs || 0))
|
||
persistActiveJob(kind, id, hidden, locked)
|
||
listen(kind, id, hidden, locked)
|
||
} else if (settled) {
|
||
clearActiveJob(kind)
|
||
}
|
||
} catch {
|
||
clearActiveJob(kind)
|
||
}
|
||
}
|
||
|
||
async function resumeActiveJob() {
|
||
const storedVideo = readStoredJob(JOB_STORE)
|
||
const storedEdit = readStoredJob(EDIT_JOB_STORE)
|
||
const active = await $fetch<{
|
||
jobId?: string | null
|
||
video?: { jobId?: string; hideThumbnail?: boolean; folderLocked?: boolean }
|
||
edit?: { jobId?: string; hideThumbnail?: boolean; folderLocked?: boolean }
|
||
music?: { jobId?: string }
|
||
hideThumbnail?: boolean
|
||
folderLocked?: boolean
|
||
} | null>('/api/generate/active').catch(() => null)
|
||
|
||
// Never fall back to top-level jobId — that used to steal live music jobs into Video Output.
|
||
const videoId = storedVideo?.jobId || active?.video?.jobId || ''
|
||
const editId = storedEdit?.jobId || active?.edit?.jobId || ''
|
||
|
||
if (videoId) {
|
||
await resumeTrack(
|
||
'video',
|
||
videoId,
|
||
Boolean(storedVideo?.hideThumbnail || active?.video?.hideThumbnail || active?.hideThumbnail),
|
||
Boolean(storedVideo?.folderLocked || active?.video?.folderLocked || active?.folderLocked)
|
||
)
|
||
}
|
||
if (editId) {
|
||
await resumeTrack(
|
||
'edit',
|
||
editId,
|
||
Boolean(storedEdit?.hideThumbnail || active?.edit?.hideThumbnail),
|
||
Boolean(storedEdit?.folderLocked || active?.edit?.folderLocked)
|
||
)
|
||
}
|
||
}
|
||
|
||
function openEditLightbox() {
|
||
if (!editResultUrl.value || editAwaitingReveal.value || editLockedSave.value) return
|
||
editLightboxUrl.value = editResultUrl.value
|
||
}
|
||
|
||
function closeEditLightbox() {
|
||
editLightboxUrl.value = ''
|
||
}
|
||
|
||
function typingInField(event: KeyboardEvent) {
|
||
const target = event.target as HTMLElement | null
|
||
if (!target) return false
|
||
const tag = target.tagName
|
||
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target.isContentEditable
|
||
}
|
||
|
||
function onStudioKey(event: KeyboardEvent) {
|
||
if (event.key === 'Escape') {
|
||
if (editLightboxUrl.value) closeEditLightbox()
|
||
return
|
||
}
|
||
if (typingInField(event)) return
|
||
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
|
||
if (outputFamilyStills.value.length < 2) return
|
||
event.preventDefault()
|
||
stepOutputFamily(event.key === 'ArrowLeft' ? -1 : 1)
|
||
}
|
||
|
||
function showOutputStill(still: LibraryStill) {
|
||
editResultUrl.value = `/api/library/stills/${still.id}`
|
||
lastV2StillId.value = still.id
|
||
editDownloadName.value = clipFileName(stillTitle(still))
|
||
if (editLightboxUrl.value) editLightboxUrl.value = `/api/library/stills/${still.id}`
|
||
}
|
||
|
||
function stepOutputFamily(delta: number) {
|
||
const family = outputFamilyStills.value
|
||
if (family.length < 2) return
|
||
const current = outputFamilyIndex.value
|
||
const next = family[(current + delta + family.length) % family.length]
|
||
if (next) showOutputStill(next)
|
||
}
|
||
|
||
function markConcealGesture() {
|
||
concealGestureAt = Date.now()
|
||
}
|
||
|
||
function bindConcealListeners() {
|
||
window.addEventListener('pointerdown', onConcealPointerDown, true)
|
||
window.addEventListener('pointermove', onConcealPointerMove, true)
|
||
window.addEventListener('pointerup', onConcealPointerUp, true)
|
||
window.addEventListener('pointercancel', onConcealPointerCancel, true)
|
||
window.addEventListener('touchstart', onConcealTouchStart, { capture: true, passive: true })
|
||
window.addEventListener('scroll', markConcealGesture, true)
|
||
window.addEventListener('gesturestart', markConcealGesture, true)
|
||
const viewport = window.visualViewport
|
||
if (viewport) {
|
||
viewport.addEventListener('resize', markConcealGesture)
|
||
viewport.addEventListener('scroll', markConcealGesture)
|
||
}
|
||
}
|
||
|
||
function unbindConcealListeners() {
|
||
window.removeEventListener('pointerdown', onConcealPointerDown, true)
|
||
window.removeEventListener('pointermove', onConcealPointerMove, true)
|
||
window.removeEventListener('pointerup', onConcealPointerUp, true)
|
||
window.removeEventListener('pointercancel', onConcealPointerCancel, true)
|
||
window.removeEventListener('touchstart', onConcealTouchStart, true)
|
||
window.removeEventListener('scroll', markConcealGesture, true)
|
||
window.removeEventListener('gesturestart', markConcealGesture, true)
|
||
const viewport = window.visualViewport
|
||
if (viewport) {
|
||
viewport.removeEventListener('resize', markConcealGesture)
|
||
viewport.removeEventListener('scroll', markConcealGesture)
|
||
}
|
||
}
|
||
|
||
function onConcealPointerDown(event: PointerEvent) {
|
||
concealPointers += 1
|
||
if (concealPointers > 1) {
|
||
concealPinch = true
|
||
markConcealGesture()
|
||
}
|
||
if (concealPointers === 1) {
|
||
concealStart = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, moved: false }
|
||
}
|
||
}
|
||
|
||
function onConcealPointerMove(event: PointerEvent) {
|
||
const start = concealStart
|
||
if (!start || event.pointerId !== start.pointerId) return
|
||
const dx = event.clientX - start.x
|
||
const dy = event.clientY - start.y
|
||
if ((dx * dx) + (dy * dy) > CONCEAL_MOVE_PX * CONCEAL_MOVE_PX) start.moved = true
|
||
}
|
||
|
||
function onConcealPointerUp(event: PointerEvent) {
|
||
concealPointers = Math.max(0, concealPointers - 1)
|
||
const start = concealStart
|
||
const pinched = concealPinch
|
||
if (event.pointerId === start?.pointerId) concealStart = null
|
||
if (concealPointers === 0) concealPinch = false
|
||
if (!start || event.pointerId !== start.pointerId) return
|
||
if (start.moved || pinched) {
|
||
markConcealGesture()
|
||
return
|
||
}
|
||
if (Date.now() - concealGestureAt < CONCEAL_GESTURE_MS) return
|
||
if (event.pointerType === 'mouse' && event.button !== 0) return
|
||
concealFromOutsideTap(event)
|
||
}
|
||
|
||
function onConcealPointerCancel() {
|
||
concealPointers = Math.max(0, concealPointers - 1)
|
||
concealStart = null
|
||
if (concealPointers === 0) concealPinch = false
|
||
markConcealGesture()
|
||
}
|
||
|
||
function onConcealTouchStart(event: TouchEvent) {
|
||
if (event.touches.length > 1) {
|
||
concealPinch = true
|
||
markConcealGesture()
|
||
}
|
||
}
|
||
|
||
function concealFromOutsideTap(event: Event) {
|
||
const target = event.target as Node | null
|
||
if (editLightbox.value && target && editLightbox.value.contains(target)) return
|
||
if (editLightboxUrl.value) closeEditLightbox()
|
||
const root = outputPanel.value
|
||
if (root && target && root.contains(target)) return
|
||
if (concealOutput.value && !awaitingReveal.value && !lockedSave.value && videoUrl.value) {
|
||
concealAndPause()
|
||
}
|
||
if (concealEditOutput.value && !editAwaitingReveal.value && !editLockedSave.value && editResultUrl.value) {
|
||
editAwaitingReveal.value = true
|
||
}
|
||
if (showPrivacyToggles.value) lastFrameRevealed.value = false
|
||
}
|
||
|
||
function concealAndPause() {
|
||
awaitingReveal.value = true
|
||
const el = player.value
|
||
if (el) {
|
||
el.pause()
|
||
el.currentTime = 0
|
||
}
|
||
}
|
||
|
||
function stopTimer(kind?: 'video' | 'edit') {
|
||
if (!kind || kind === 'video') {
|
||
if (videoTimer) clearInterval(videoTimer)
|
||
videoTimer = null
|
||
}
|
||
if (!kind || kind === 'edit') {
|
||
if (editTimer) clearInterval(editTimer)
|
||
editTimer = null
|
||
}
|
||
}
|
||
|
||
function clipFileName(name: string) {
|
||
return name.replace(/[<>:"/\\|?*'`]/g, '-').replace(/\s+/g, ' ').trim().slice(0, 80) || 'output'
|
||
}
|
||
|
||
function stillFileName(still?: Pick<LibraryStill, 'name' | 'filename'> | null) {
|
||
const named = (still?.name || '').trim()
|
||
if (named) return `${named}.png`
|
||
return still?.filename || 'still.png'
|
||
}
|
||
|
||
function clipGenerateDuration(item: LibraryClip) {
|
||
const parent = item.parentClipId
|
||
? clips.value.find(clip => clip.id === item.parentClipId)
|
||
: null
|
||
return clipPartDuration(item, parent)
|
||
}
|
||
|
||
function familyPartsFor(item: LibraryClip) {
|
||
const key = clipFamilyKey(item)
|
||
const group = libraryGroups.value.find(entry => entry.key === key)
|
||
return chronologicalParts(group?.parts || [item])
|
||
}
|
||
|
||
async function loadRerunImage(item: LibraryClip, parts: LibraryClip[]) {
|
||
const index = Math.max(0, parts.findIndex(part => part.id === item.id))
|
||
const isExtension = index > 0 || Boolean(item.parentClipId) || (item.chainIndex || 0) > 0
|
||
if (isExtension) {
|
||
const previous = (item.parentClipId && clips.value.find(clip => clip.id === item.parentClipId))
|
||
|| (index > 0 ? parts[index - 1] : undefined)
|
||
if (previous) {
|
||
const blob = await $fetch<Blob>(`/api/library/clips/${previous.id}/last-frame`, { responseType: 'blob' })
|
||
readFile(new File([blob], 'last_frame.png', { type: blob.type || 'image/png' }), { keepThumbnailPref: true })
|
||
return true
|
||
}
|
||
}
|
||
const still = (item.stillId && stills.value.find(entry => entry.id === item.stillId))
|
||
|| stills.value.find(entry => entry.folderId === item.folderId && entry.filename === `${item.id}.png`)
|
||
const stillId = still?.id || item.stillId
|
||
if (stillId) {
|
||
const blob = await $fetch<Blob>(`/api/library/stills/${stillId}`, { responseType: 'blob' })
|
||
if (still?.hideInput && showPrivacyToggles.value) hideInputPreview.value = true
|
||
readFile(new File([blob], stillFileName(still), { type: blob.type || 'image/png' }), { keepThumbnailPref: true, stillId })
|
||
return true
|
||
}
|
||
const blob = await $fetch<Blob>(`/api/library/clips/${item.id}/thumb`, { responseType: 'blob' })
|
||
readFile(new File([blob], 'start.jpg', { type: blob.type || 'image/jpeg' }), { keepThumbnailPref: true })
|
||
return true
|
||
}
|
||
|
||
async function rerun(item: LibraryClip, collection = false) {
|
||
studioMode.value = 'video'
|
||
outputFocus.value = 'video'
|
||
closeExtend()
|
||
const parts = familyPartsFor(item)
|
||
const target = collection ? parts[0] || item : item
|
||
const restoreAll = collection && parts.length > 1
|
||
const ordered = chronologicalParts(parts)
|
||
const wrappers = resolvePromptWrappers(target, ordered[0])
|
||
applyPromptRestore(restoreAll ? familyScript(parts) : target.prompt, {
|
||
prompt: restoreAll ? familyScript(parts) : target.prompt,
|
||
promptPre: wrappers.promptPre || target.promptPre,
|
||
promptPost: wrappers.promptPost || target.promptPost
|
||
})
|
||
clipName.value = restoreAll
|
||
? (familyBaseName(collectionTitle({ head: target, parts })) || target.name || '')
|
||
: (target.name || '')
|
||
rememberInputSeed(target.seed)
|
||
restoreAdvanced(target)
|
||
duration.value = clipGenerateDuration(target)
|
||
hideThumbnail.value = showPrivacyToggles.value && target.hideThumbnail
|
||
if (showPrivacyToggles.value) hideInputPreview.value = target.hideInput === true
|
||
folderId.value = target.folderId
|
||
browseFolderId.value = target.folderId
|
||
applyVideoWorkflow(target.workflow)
|
||
if (typeof target.sound === 'boolean') withSound.value = target.sound
|
||
shotScriptMode.value = restoreAll
|
||
identityRefs.value.forEach((_, index) => clearIdentityRef(index))
|
||
globalLocks.value = ordered.find(part => part.globalLocks)?.globalLocks || target.globalLocks || ''
|
||
familyPermanenceRefs.value = ordered[0]?.permanenceRefs || target.permanenceRefs || []
|
||
const familyIds = new Set(familyPermanenceRefs.value.map(ref => ref.stillId))
|
||
const nextShots: Record<number, PermanenceRef[]> = {}
|
||
ordered.forEach((part, index) => {
|
||
const extras = (part.permanenceRefs || []).filter(ref => !familyIds.has(ref.stillId))
|
||
if (extras.length) nextShots[index + 1] = extras
|
||
})
|
||
shotPermanenceRefs.value = nextShots
|
||
applyLoraSelection(
|
||
restoreAll ? (ordered[0] || target) : target,
|
||
restoreAll ? ordered : undefined
|
||
)
|
||
extensionQueue.value = restoreAll
|
||
? parts.slice(1).map(part => {
|
||
const shotWrappers = resolvePromptWrappers(part, ordered[0], target)
|
||
return queuedExtensionFrom(
|
||
{
|
||
...part,
|
||
promptPre: shotWrappers.promptPre || part.promptPre,
|
||
promptPost: shotWrappers.promptPost || part.promptPost
|
||
},
|
||
clipGenerateDuration(part),
|
||
loraStacksEqual(readLoraStack(part), readLoraStack(ordered[0] || target)) ? [] : readLoraStack(part)
|
||
)
|
||
})
|
||
: []
|
||
try {
|
||
const initialTextToVideo = textToVideo.value && !target.parentClipId && !(target.chainIndex)
|
||
if (!(initialTextToVideo && !target.stillId)) await loadRerunImage(target, parts)
|
||
statusMessage.value = restoreAll
|
||
? 'Collection loaded into the form. Generate when you are ready.'
|
||
: 'Clip loaded into the form. Generate when you are ready.'
|
||
toast(restoreAll
|
||
? 'Shot script and settings restored. Make any changes, then generate.'
|
||
: 'Inputs restored. Make any changes, then generate.')
|
||
} catch {
|
||
statusMessage.value = textToVideo.value
|
||
? 'Clip settings loaded. Add a still only if you want image-to-video.'
|
||
: 'Clip settings loaded. Re-drop the start still if it is missing.'
|
||
toast(textToVideo.value
|
||
? 'Settings restored. Text-to-video does not need a start still.'
|
||
: 'Settings restored, but the start still could not be loaded. Pick it again.')
|
||
}
|
||
}
|
||
|
||
async function loadDraft(draft: RetryDraft) {
|
||
activeDraftId = draft.id
|
||
applyPromptRestore(draft.prompt, draft)
|
||
clipName.value = draft.name || ''
|
||
seedInput.value = String(draft.seed)
|
||
restoreAdvanced(draft)
|
||
hideThumbnail.value = showPrivacyToggles.value && draft.hideThumbnail
|
||
folderId.value = draft.folderId
|
||
withSound.value = draft.sound !== false
|
||
if (draft.hideInput && showPrivacyToggles.value) hideInputPreview.value = true
|
||
duration.value = clampDuration(Number(draft.duration))
|
||
applyVideoWorkflow(draft.workflow)
|
||
globalLocks.value = draft.globalLocks || ''
|
||
familyPermanenceRefs.value = draft.permanenceRefs || []
|
||
const draftShots: Record<number, PermanenceRef[]> = {}
|
||
;(draft.shotPermanenceRefs || []).forEach((refs, index) => {
|
||
if (refs?.length) draftShots[index + 1] = refs
|
||
})
|
||
shotPermanenceRefs.value = draftShots
|
||
applyLoraSelection(draft, draft.shotLoraStacks || draft.shotLoras)
|
||
extensionQueue.value = (draft.extensions || []).map(item => queuedExtensionFrom(
|
||
item,
|
||
clampDuration(Number(item.duration)),
|
||
readLoraStack(item)
|
||
))
|
||
try {
|
||
const blob = await $fetch<Blob>(`/api/library/drafts/${draft.id}/still`, { responseType: 'blob' })
|
||
const filename = draft.stillFilename || 'held-still.png'
|
||
readFile(new File([blob], filename, { type: blob.type || 'image/png' }), { keepThumbnailPref: true, stillId: draft.stillId })
|
||
statusMessage.value = 'Held job restored. Generate when ComfyUI is free.'
|
||
} catch {
|
||
if (textToVideo.value) {
|
||
statusMessage.value = 'Held job restored. Generate when ComfyUI is free.'
|
||
} else {
|
||
statusMessage.value = 'Held job settings restored. Re-drop the still if the image is missing.'
|
||
toast('Settings restored. Unlock the folder or re-drop the still if the image is missing.')
|
||
}
|
||
}
|
||
}
|
||
|
||
async function dismissDraft(draft: RetryDraft) {
|
||
try {
|
||
applyLibrary(await $fetch(`/api/library/drafts/${draft.id}`, { method: 'DELETE' }))
|
||
if (activeDraftId === draft.id) activeDraftId = ''
|
||
} catch (error: any) {
|
||
toast(error?.data?.statusMessage || 'Could not dismiss held job')
|
||
}
|
||
}
|
||
|
||
async function unlockFolder(id: string) {
|
||
try {
|
||
const data = await $fetch('/api/library/unlock', { method: 'POST', body: { password: unlockPassword.value, folderId: id } })
|
||
unlockPassword.value = ''
|
||
applyLibrary(data)
|
||
if (lockedSave.value) {
|
||
const dest = folders.value.find(folder => folder.id === folderId.value)
|
||
if (dest?.unlocked) {
|
||
lockedSave.value = false
|
||
const newest = clips.value
|
||
.filter(clip => clip.folderId === folderId.value)
|
||
.sort((a, b) => b.createdAt - a.createdAt)[0]
|
||
if (newest) openClip(newest)
|
||
}
|
||
}
|
||
} catch (error: any) {
|
||
toast(error?.data?.statusMessage || error?.statusMessage || 'Unlock failed')
|
||
}
|
||
}
|
||
|
||
async function lockFolder(id: string) {
|
||
try {
|
||
applyLibrary(await $fetch('/api/library/lock', { method: 'POST', body: { folderId: id } }))
|
||
} catch {
|
||
await loadLibrary()
|
||
}
|
||
const stillVisible = clips.value.some(clip => videoUrl.value.includes(clip.id))
|
||
if (videoUrl.value && !stillVisible) {
|
||
videoUrl.value = ''
|
||
awaitingReveal.value = false
|
||
lockedSave.value = false
|
||
concealOutput.value = false
|
||
statusMessage.value = 'Folder locked'
|
||
}
|
||
}
|
||
|
||
function clipIdFromPlayer() {
|
||
const match = videoUrl.value.match(/\/api\/library\/clips\/([^/?#]+)\/video/)
|
||
return match?.[1] || currentClipId.value
|
||
}
|
||
|
||
function clipVideoUrl(clipId: string) {
|
||
return `/api/library/clips/${clipId}/video?t=${Date.now()}`
|
||
}
|
||
|
||
function closeExtend() {
|
||
lastFrameGen += 1
|
||
extendOpen.value = false
|
||
extendClipId.value = ''
|
||
extendPromptPre.value = ''
|
||
extendPrompt.value = ''
|
||
extendPromptPost.value = ''
|
||
lastFrameLoading.value = false
|
||
lastFrameRevealed.value = false
|
||
if (lastFrameObjectUrl) {
|
||
URL.revokeObjectURL(lastFrameObjectUrl)
|
||
lastFrameObjectUrl = ''
|
||
}
|
||
lastFrameUrl.value = ''
|
||
}
|
||
|
||
async function loadLastFrame(id: string) {
|
||
lastFrameLoading.value = true
|
||
const gen = ++lastFrameGen
|
||
try {
|
||
const blob = await $fetch<Blob>(`/api/library/clips/${id}/last-frame`, {
|
||
responseType: 'blob',
|
||
signal: AbortSignal.timeout(20_000)
|
||
})
|
||
if (gen !== lastFrameGen) return
|
||
if (lastFrameObjectUrl) URL.revokeObjectURL(lastFrameObjectUrl)
|
||
lastFrameObjectUrl = URL.createObjectURL(blob)
|
||
lastFrameUrl.value = lastFrameObjectUrl
|
||
} catch (error: any) {
|
||
if (gen !== lastFrameGen) return
|
||
toast(error?.data?.statusMessage || 'Could not extract the last frame')
|
||
} finally {
|
||
if (gen === lastFrameGen) lastFrameLoading.value = false
|
||
}
|
||
}
|
||
|
||
function revealExtendLastFrame() {
|
||
lastFrameRevealed.value = true
|
||
const id = extendClipId.value || currentClipId.value || clipIdFromPlayer()
|
||
if (id && !lastFrameUrl.value && !lastFrameLoading.value) void loadLastFrame(id)
|
||
}
|
||
|
||
async function openExtend(item?: LibraryClip) {
|
||
if (item) openClip(item)
|
||
const id = item?.id || clipIdFromPlayer()
|
||
if (!id) {
|
||
toast('Load a generated clip before extending it.')
|
||
return
|
||
}
|
||
currentClipId.value = id
|
||
extendClipId.value = id
|
||
const clip = clips.value.find(entry => entry.id === id) || item || null
|
||
if (clip) ensureFamilyExpanded(clip)
|
||
// Prefer clip wrappers, then family head, then the main form — never blank pre/post
|
||
// just because this part did not re-store them.
|
||
const parts = promptPartsForClip(clip, prompt.value)
|
||
extendPromptPre.value = parts.pre
|
||
extendPrompt.value = parts.prompt
|
||
extendPromptPost.value = parts.post
|
||
extendDuration.value = clip
|
||
? clipGenerateDuration(clip)
|
||
: clampDuration(duration.value)
|
||
extendLoraStack.value = readLoraStack(clip)
|
||
lastFrameRevealed.value = false
|
||
extendOpen.value = true
|
||
lastFrameUrl.value = ''
|
||
lastFrameLoading.value = false
|
||
player.value?.pause()
|
||
if (!hideExtendLastFrame.value) await loadLastFrame(id)
|
||
}
|
||
|
||
async function generateExtension() {
|
||
const id = extendClipId.value || clipIdFromPlayer()
|
||
if (!composePromptParts(extendPromptPre.value, extendPrompt.value, extendPromptPost.value)) return
|
||
if (!id) {
|
||
toast('Load a generated clip before extending it.')
|
||
return
|
||
}
|
||
const hideOut = hideThumbnail.value
|
||
const keepLiveOutput = watchingLiveVideo()
|
||
try {
|
||
await ensureGenerationNotifyPermission()
|
||
const started = await $fetch<{
|
||
jobId: string
|
||
studioJobId?: string
|
||
queued?: boolean
|
||
steps: number
|
||
hideThumbnail?: boolean
|
||
folderLocked?: boolean
|
||
width?: number
|
||
height?: number
|
||
name?: string
|
||
}>('/api/extend', {
|
||
method: 'POST',
|
||
body: {
|
||
clipId: id,
|
||
prompt: extendPrompt.value.trim(),
|
||
promptPre: extendPromptPre.value,
|
||
promptPost: extendPromptPost.value,
|
||
duration: extendDuration.value,
|
||
loraStack: extendLoraStack.value,
|
||
refineExtensionFrame: refineExtensionFrame.value,
|
||
saveLosslessAnchor: saveLosslessAnchor.value,
|
||
refinementDenoise: clampRefinementDenoise(refinementDenoise.value)
|
||
}
|
||
})
|
||
if (queuedOffLiveOutput(started, 'video')) {
|
||
toast(started.name
|
||
? `Queued “${started.name}”. Stay here to queue another branch.`
|
||
: 'Queued behind the current job. This panel stays open.')
|
||
if (!watchingLiveVideo()) {
|
||
outputStudioJobId.value = started.studioJobId || ''
|
||
outputFocus.value = 'video'
|
||
videoAwaitingBeast.value = true
|
||
statusMessage.value = 'Queued. Waiting for Beast to be free.'
|
||
}
|
||
await refreshStudioQueue()
|
||
return
|
||
}
|
||
statusBusy.value = false
|
||
videoBusy.value = true
|
||
stopListen('video')
|
||
videoSettledUi = false
|
||
progress.value = 1
|
||
if (!extendOpen.value && !watchingLibrary.value) {
|
||
awaitingReveal.value = false
|
||
concealOutput.value = started.hideThumbnail === true || hideOut
|
||
}
|
||
lockedSave.value = false
|
||
chainStep.value = 1
|
||
chainTotal.value = 1
|
||
chainLabel.value = ''
|
||
overallProgress.value = 0
|
||
completedChainStep.value = 0
|
||
activeChainPlan.value = []
|
||
statusMessage.value = 'Extracting last frame & stitching extension...'
|
||
startTimer('video')
|
||
if (started.width && started.height) playerSize.value = { width: started.width, height: started.height }
|
||
videoJobId.value = started.jobId
|
||
outputStudioJobId.value = started.studioJobId || ''
|
||
maxStep.value = started.steps
|
||
persistActiveJob('video', started.jobId, started.hideThumbnail === true || concealOutput.value, started.folderLocked === true)
|
||
listen('video', started.jobId, started.hideThumbnail === true || concealOutput.value, started.folderLocked === true)
|
||
toast(started.name
|
||
? `Started “${started.name}”. This panel stays open so you can queue another branch.`
|
||
: 'Extension started. This panel stays open.')
|
||
void pollComfyHealth()
|
||
void refreshStudioQueue()
|
||
} catch (error: any) {
|
||
const message = error?.data?.statusMessage || error?.statusMessage || error?.message || 'Failed to start extension'
|
||
if (!keepLiveOutput) {
|
||
videoBusy.value = false
|
||
statusBusy.value = false
|
||
stopTimer('video')
|
||
clearActiveJob('video')
|
||
statusMessage.value = message
|
||
}
|
||
toast(message)
|
||
}
|
||
}
|
||
|
||
function openClip(item: LibraryClip) {
|
||
// Preview only — never yank Video/Image workspace tabs.
|
||
outputFocus.value = 'video'
|
||
watchingLibrary.value = true
|
||
ensureFamilyExpanded(item)
|
||
currentClipId.value = item.id
|
||
lockedSave.value = false
|
||
concealOutput.value = item.hideThumbnail
|
||
videoUrl.value = clipVideoUrl(item.id)
|
||
awaitingReveal.value = item.hideThumbnail
|
||
playerSize.value = { width: item.width || 1344, height: item.height || 768 }
|
||
browseFolderId.value = item.folderId
|
||
videoDownloadName.value = clipFileName(item.name || item.prompt.split(/[.!?\n]/)[0] || 'output')
|
||
if (!videoBusy.value) {
|
||
statusMessage.value = item.hideThumbnail ? 'Hidden clip ready' : 'Loaded from library'
|
||
progress.value = 100
|
||
}
|
||
}
|
||
|
||
function openStill(still: LibraryStill) {
|
||
// Preview only — never yank Video/Image workspace tabs.
|
||
outputFocus.value = 'edit'
|
||
browseFolderId.value = still.folderId
|
||
editResultUrl.value = `/api/library/stills/${still.id}`
|
||
concealEditOutput.value = still.hideInput === true
|
||
editAwaitingReveal.value = still.hideInput === true
|
||
editLockedSave.value = false
|
||
editDownloadName.value = clipFileName(stillTitle(still))
|
||
if (!editBusy.value) {
|
||
editStatusMessage.value = still.hideInput ? 'Hidden still ready' : 'Loaded from library'
|
||
editProgress.value = 100
|
||
}
|
||
}
|
||
|
||
function applyStillSettings(still: LibraryStill) {
|
||
const dest = inferStillPipeline(still)
|
||
const settings = still.settings
|
||
folderId.value = still.folderId
|
||
browseFolderId.value = still.folderId
|
||
rememberInputSeed(settings?.seed)
|
||
applyPromptRestore(still.prompt || '', still)
|
||
// Video start frames stay on Video. Image stills open Image with full settings.
|
||
if (!dest) {
|
||
studioMode.value = 'video'
|
||
outputFocus.value = 'video'
|
||
return
|
||
}
|
||
if (dest === 'imagev2') {
|
||
studioMode.value = 'editv2'
|
||
outputFocus.value = 'edit'
|
||
const mode = settings?.mode === 'compose' || settings?.mode === 'refine' || settings?.mode === 'generate' || settings?.mode === 'iterate'
|
||
? settings.mode
|
||
: 'edit'
|
||
setV2Mode(mode)
|
||
persistImageV2LoraStack(imageV2LoraStack.value, v2Engine.value)
|
||
v2Engine.value = parseImageV2Engine(settings?.engine, 'flux')
|
||
if (!settings) return
|
||
if (typeof settings.negative === 'string') v2Negative.value = settings.negative
|
||
if (settings.steps != null) v2Steps.value = clampImageSteps(settings.steps, IMAGE_V2_STEPS_DEFAULT)
|
||
if (settings.cfg != null) v2Cfg.value = clampImageCfg(settings.cfg, IMAGE_V2_CFG_DEFAULT)
|
||
if (settings.megapixels != null) v2Megapixels.value = clampImageScaleMegapixels(settings.megapixels, 1)
|
||
v2ScaleOn.value = mode !== 'generate' && settings.scaleToTotalPixels !== false
|
||
v2Turbo.value = settings.turbo === true
|
||
if ((mode === 'refine' || (v2Engine.value === 'krea' && mode !== 'generate')) && settings.strength != null) v2Denoise.value = clampImageV2Denoise(settings.strength)
|
||
aspect.value = parseImageAspect(settings.aspect)
|
||
if (mode === 'generate') {
|
||
const size = generateSizeForAspect(aspect.value)
|
||
v2Width.value = size.width
|
||
v2Height.value = size.height
|
||
}
|
||
if (mode === 'compose' && settings.task) v2Task.value = settings.task
|
||
imageV2LoraStack.value = mergeLegacyImageV2Loras(
|
||
settings.loraStack?.length || settings.loraName ? readLoraStack(settings) : imageV2LoraStack.value,
|
||
{
|
||
engine: v2Engine.value,
|
||
mode,
|
||
xaigen: v2ConceptLoraEnabled.value,
|
||
snofsModel: settings.snofsModel,
|
||
snofsClip: settings.snofsClip,
|
||
consistencyModel: settings.consistencyModel,
|
||
consistencyClip: settings.consistencyClip,
|
||
kreaConcept: String(runtimeConfig.public.kreaConceptLora || IMAGE_V2_KREA_CONCEPT_LORA || '').trim()
|
||
}
|
||
)
|
||
return
|
||
}
|
||
studioMode.value = 'edit'
|
||
outputFocus.value = 'edit'
|
||
if (!settings) return
|
||
if (settings.steps != null) editSteps.value = clampImageSteps(settings.steps)
|
||
if (settings.cfg != null) editCfg.value = clampImageCfg(settings.cfg)
|
||
if (typeof settings.negative === 'string') editNegative.value = settings.negative
|
||
editScaleToTotalPixels.value = settings.scaleToTotalPixels === true
|
||
if (settings.scaleMegapixels != null) editScaleMegapixels.value = clampImageScaleMegapixels(settings.scaleMegapixels)
|
||
if (settings.loraStack?.length || settings.loraName) {
|
||
imageLoraStack.value = filterLoraStackForStudio(readLoraStack(settings), ltxEnabled.value)
|
||
}
|
||
aspect.value = parseImageAspect(settings.aspect)
|
||
}
|
||
|
||
async function loadStillAsEditRef(stillId: string) {
|
||
const blob = await $fetch<Blob>(`/api/library/stills/${stillId}`, { responseType: 'blob' })
|
||
const still = stills.value.find(item => item.id === stillId)
|
||
setEditRef(new File([blob], stillFileName(still) || 'still-b.png', { type: blob.type || 'image/png' }))
|
||
}
|
||
|
||
function stillUseAsInputMessage(still: LibraryStill) {
|
||
const dest = inferStillPipeline(still)
|
||
const mode = still.settings?.mode
|
||
if (dest === 'imagev2' && mode === 'generate') return 'Generate settings and still loaded. Switch to Edit anytime — the image is already in Still A.'
|
||
if (dest === 'imagev2' && mode === 'iterate') return 'Iterate settings and still loaded.'
|
||
if (dest === 'imagev2' && mode === 'compose') {
|
||
return still.settings?.referenceStillId
|
||
? 'Compose settings loaded with stills A and B.'
|
||
: 'Compose settings loaded. Re-add still B, then generate.'
|
||
}
|
||
if (dest === 'imagev2' && mode === 'refine') return 'Refine settings and canvas still loaded.'
|
||
if (dest === 'imagev2') return 'Image settings and still loaded.'
|
||
if (dest === 'image' && still.settings) return 'Image edit settings and still loaded.'
|
||
return 'Loaded as input with prompt and still.'
|
||
}
|
||
|
||
async function useStillAsInput(still: LibraryStill) {
|
||
try {
|
||
applyStillSettings(still)
|
||
const dest = inferStillPipeline(still)
|
||
// Always put the still pixels in the input slot — including Generate outputs —
|
||
// so Edit / Compose / Refine / Video all have an image after Use as input.
|
||
const blob = await $fetch<Blob>(`/api/library/stills/${still.id}`, { responseType: 'blob' })
|
||
if (still.hideInput && showPrivacyToggles.value) hideInputPreview.value = true
|
||
readFile(new File([blob], stillFileName(still), { type: blob.type || 'image/png' }), { stillId: still.id })
|
||
if (dest === 'imagev2' && still.settings?.mode === 'compose' && still.settings.referenceStillId) {
|
||
try {
|
||
await loadStillAsEditRef(still.settings.referenceStillId)
|
||
} catch {
|
||
toast('Compose settings loaded, but still B is missing. Re-add it.')
|
||
}
|
||
} else if (dest !== 'imagev2' || still.settings?.mode !== 'compose') {
|
||
clearEditRef()
|
||
}
|
||
scrollToStudioInput()
|
||
toast(stillUseAsInputMessage(still))
|
||
} catch (error: any) {
|
||
toast(error?.data?.statusMessage || 'Could not load that still')
|
||
}
|
||
}
|
||
|
||
async function sendOutputStillToBackup() {
|
||
const stillId = lastV2StillId.value || stillIdFromLibraryUrl(editResultUrl.value)
|
||
if (!stillId) {
|
||
toast('Save this still first, then send it to Backup.')
|
||
return
|
||
}
|
||
await sendStillToBackup({ id: stillId })
|
||
}
|
||
|
||
async function sendStillToBackup(still: Pick<LibraryStill, 'id'>) {
|
||
if (!backupStudioEnabled.value || backupBusyId.value) return
|
||
backupBusyId.value = still.id
|
||
try {
|
||
await $fetch(`/api/library/stills/${still.id}/backup`, { method: 'POST' })
|
||
toast('Sent to Backup')
|
||
} catch (error: any) {
|
||
toast(error?.data?.statusMessage || 'Could not send that still to Backup')
|
||
} finally {
|
||
backupBusyId.value = ''
|
||
}
|
||
}
|
||
|
||
async function saveStillAsInput(still: Pick<LibraryStill, 'id'>) {
|
||
if (keepingInputBusy.value) return
|
||
keepingInputBusy.value = true
|
||
try {
|
||
applyLibrary(await $fetch(`/api/library/stills/${still.id}/keep-input`, { method: 'POST' }))
|
||
toast('Saved to Input. Pick it later from Choose an image → Input.')
|
||
} catch (error: any) {
|
||
toast(error?.data?.statusMessage || 'Could not save that still as input')
|
||
} finally {
|
||
keepingInputBusy.value = false
|
||
}
|
||
}
|
||
|
||
async function saveEditAsInput() {
|
||
const stillId = stillIdFromLibraryUrl(editResultUrl.value)
|
||
if (stillId) {
|
||
await saveStillAsInput({ id: stillId })
|
||
return
|
||
}
|
||
if (!editResultUrl.value) return
|
||
if (keepingInputBusy.value) return
|
||
keepingInputBusy.value = true
|
||
try {
|
||
const blob = await $fetch<Blob>(editResultUrl.value, { responseType: 'blob' })
|
||
if (!blob?.size) throw new Error('empty still')
|
||
const body = new FormData()
|
||
body.append('image', new File([blob], `${editDownloadName.value || 'edited'}.png`, { type: blob.type || 'image/png' }))
|
||
body.append('folderId', folderId.value)
|
||
body.append('hideInput', String(hideInputPreview.value))
|
||
applyLibrary(await $fetch('/api/library/stills', { method: 'POST', body }))
|
||
toast('Saved to Input. Pick it later from Choose an image → Input.')
|
||
} catch (error: any) {
|
||
toast(error?.data?.statusMessage || 'Could not save that still as input')
|
||
} finally {
|
||
keepingInputBusy.value = false
|
||
}
|
||
}
|
||
|
||
async function useClipAsInput(item: LibraryClip, collection = false) {
|
||
await rerun(item, collection)
|
||
scrollToStudioInput()
|
||
}
|
||
|
||
function libraryPromptSnippet(text?: string) {
|
||
const value = String(text || '').replace(/\s+/g, ' ').trim()
|
||
if (!value) return ''
|
||
return value.length > 120 ? `${value.slice(0, 117)}…` : value
|
||
}
|
||
|
||
function clipDeleteName(item: LibraryClip) {
|
||
return item.name || libraryPromptSnippet(item.prompt) || 'Untitled clip'
|
||
}
|
||
|
||
function stillDeleteName(still: LibraryStill) {
|
||
return still.name || libraryPromptSnippet(still.prompt) || stillTitle(still)
|
||
}
|
||
|
||
function askRemoveClip(item: LibraryClip, family = false) {
|
||
const parts = family ? familyPartsFor(item) : [item]
|
||
const collection = Boolean(family)
|
||
pendingLibraryDelete.value = {
|
||
title: collection ? 'Delete this collection?' : 'Delete this clip?',
|
||
name: collection
|
||
? (collectionTitle({ head: item, parts }) || clipDeleteName(item))
|
||
: clipDeleteName(item),
|
||
detail: collection
|
||
? `${parts.length} ${parts.length === 1 ? 'part' : 'parts'}`
|
||
: (item.name ? libraryPromptSnippet(item.prompt) : ''),
|
||
consequence: collection
|
||
? 'This removes the whole collection from the library.'
|
||
: 'This removes only this clip from the library.',
|
||
mode: 'clip',
|
||
clip: item,
|
||
family
|
||
}
|
||
}
|
||
|
||
function askRemoveStill(still: LibraryStill, family = false) {
|
||
const parts = family ? stillFamilyPartsFor(still) : [still]
|
||
const collection = Boolean(family)
|
||
pendingLibraryDelete.value = {
|
||
title: collection ? 'Delete this collection?' : 'Delete this still?',
|
||
name: collection
|
||
? stillCollectionTitle({ head: still, parts })
|
||
: stillDeleteName(still),
|
||
detail: collection
|
||
? `${parts.length} ${parts.length === 1 ? 'part' : 'parts'}`
|
||
: (still.name ? libraryPromptSnippet(still.prompt) : ''),
|
||
consequence: collection
|
||
? 'This removes the whole collection from the library.'
|
||
: 'This removes only this still from the library.',
|
||
mode: 'still',
|
||
still,
|
||
family
|
||
}
|
||
}
|
||
|
||
function askRemoveSelected() {
|
||
const items = selectedLibraryEntries()
|
||
if (!items.length) return
|
||
if (items.length === 1) {
|
||
const entry = items[0]
|
||
if (entry.kind === 'clip') askRemoveClip(entry.group.head, true)
|
||
else askRemoveStill(entry.stillGroup!.head, true)
|
||
const pending = pendingLibraryDelete.value
|
||
if (pending) pendingLibraryDelete.value = { ...pending, mode: 'selected' }
|
||
return
|
||
}
|
||
const partCount = items.reduce((count, entry) => (
|
||
count + (entry.kind === 'clip' ? entry.group.parts.length : entry.stillGroup!.parts.length)
|
||
), 0)
|
||
const names = items.slice(0, 3).map(entry => (
|
||
entry.kind === 'clip' ? collectionTitle(entry.group) : stillCollectionTitle(entry.stillGroup!)
|
||
)).filter(Boolean)
|
||
pendingLibraryDelete.value = {
|
||
title: 'Delete these items?',
|
||
name: names.join(', ') + (items.length > 3 ? ` and ${items.length - 3} more` : ''),
|
||
detail: `${items.length} collections · ${partCount} ${partCount === 1 ? 'part' : 'parts'}`,
|
||
consequence: 'This removes the selected collections from the library.',
|
||
mode: 'selected'
|
||
}
|
||
}
|
||
|
||
async function confirmLibraryDelete() {
|
||
const pending = pendingLibraryDelete.value
|
||
if (!pending || libraryDeleteBusy.value) return
|
||
pendingLibraryDelete.value = null
|
||
libraryDeleteBusy.value = true
|
||
try {
|
||
if (pending.mode === 'selected') await removeSelected()
|
||
else if (pending.mode === 'clip' && pending.clip) await removeClip(pending.clip, Boolean(pending.family))
|
||
else if (pending.mode === 'still' && pending.still) await removeStill(pending.still, Boolean(pending.family))
|
||
} finally {
|
||
libraryDeleteBusy.value = false
|
||
}
|
||
}
|
||
|
||
async function removeStill(still: LibraryStill, family = false) {
|
||
const watched = family ? stillFamilyPartsFor(still) : [still]
|
||
try {
|
||
await $fetch(`/api/library/stills/${still.id}`, {
|
||
method: 'DELETE',
|
||
query: family ? { family: '1' } : undefined
|
||
})
|
||
const ids = new Set(watched.map(item => item.id))
|
||
if ([...ids].some(id => editResultUrl.value.includes(id))) {
|
||
editResultUrl.value = ''
|
||
editAwaitingReveal.value = false
|
||
concealEditOutput.value = false
|
||
editLockedSave.value = false
|
||
}
|
||
await loadLibrary()
|
||
} catch (error: any) {
|
||
toast(error?.data?.statusMessage || 'Could not delete image')
|
||
}
|
||
}
|
||
|
||
async function onMoveStill(still: LibraryStill, event: Event, family = false) {
|
||
const select = event.target as HTMLSelectElement
|
||
const destId = select.value
|
||
select.value = ''
|
||
if (!destId || destId === still.folderId) return
|
||
try {
|
||
applyLibrary(await $fetch(`/api/library/stills/${still.id}`, {
|
||
method: 'PATCH',
|
||
body: { folderId: destId, family: family || undefined }
|
||
}))
|
||
const dest = folders.value.find(folder => folder.id === destId)
|
||
toast(`Moved ${family ? 'edit chain' : 'still'} to ${dest?.name || 'folder'}${dest?.unlocked ? '' : ' (locked)'}`)
|
||
} catch (error: any) {
|
||
toast(error?.data?.statusMessage || 'Could not move image')
|
||
}
|
||
}
|
||
|
||
function startRenameStill(still: LibraryStill, family = false) {
|
||
renamingId.value = still.id
|
||
renamingFamily.value = family
|
||
const source = family
|
||
? (familyBaseName(still.name || '') || still.name || '')
|
||
: (still.name || stillTitle(still))
|
||
renameDraft.value = source.trim() || 'edited'
|
||
nextTick(() => {
|
||
renameInput.value?.focus()
|
||
renameInput.value?.select()
|
||
})
|
||
}
|
||
|
||
async function saveRenameStill(still: LibraryStill, family = false) {
|
||
if (renamingId.value !== still.id || renamingFamily.value !== family) return
|
||
const name = renameDraft.value.trim().slice(0, 80)
|
||
if (!name) {
|
||
toast('A name is required')
|
||
return
|
||
}
|
||
const current = family ? familyBaseName(still.name || '') : still.name
|
||
if (name === current) {
|
||
cancelRename()
|
||
return
|
||
}
|
||
try {
|
||
applyLibrary(await $fetch(`/api/library/stills/${still.id}`, {
|
||
method: 'PATCH',
|
||
body: { name, family: family || undefined }
|
||
}))
|
||
cancelRename()
|
||
if (editResultUrl.value.includes(still.id)) {
|
||
editDownloadName.value = clipFileName(name)
|
||
}
|
||
} catch (error: any) {
|
||
toast(error?.data?.statusMessage || 'Could not rename image')
|
||
}
|
||
}
|
||
|
||
function stillFamilyPartsFor(still: LibraryStill) {
|
||
const group = stillGroups.value.find(entry => entry.parts.some(part => part.id === still.id))
|
||
return chronologicalStills(group?.parts || [still])
|
||
}
|
||
|
||
function clearPreview() {
|
||
closeExtend()
|
||
videoUrl.value = ''
|
||
awaitingReveal.value = false
|
||
concealOutput.value = false
|
||
lockedSave.value = false
|
||
currentClipId.value = ''
|
||
watchingLibrary.value = false
|
||
editResultUrl.value = ''
|
||
editAwaitingReveal.value = false
|
||
concealEditOutput.value = false
|
||
editLockedSave.value = false
|
||
if (!videoBusy.value) {
|
||
statusMessage.value = idleInputStatus('video')
|
||
progress.value = 0
|
||
}
|
||
if (!editBusy.value) {
|
||
editStatusMessage.value = idleInputStatus('edit')
|
||
editProgress.value = 0
|
||
}
|
||
}
|
||
|
||
function selectedLibraryEntries() {
|
||
const chosen = new Set(selectedKeys.value)
|
||
return libraryEntries.value.filter(entry => chosen.has(entry.key))
|
||
}
|
||
|
||
function clearSelection() {
|
||
selectedKeys.value = []
|
||
lastSelectedKey.value = ''
|
||
}
|
||
|
||
function selectAllLibrary() {
|
||
selectedKeys.value = visibleLibraryEntries.value.map(entry => entry.key)
|
||
lastSelectedKey.value = selectedKeys.value.at(-1) || ''
|
||
}
|
||
|
||
function onSelectClick(key: string, event: MouseEvent) {
|
||
const keys = libraryEntries.value.map(entry => entry.key)
|
||
if (event.shiftKey && lastSelectedKey.value) {
|
||
const start = keys.indexOf(lastSelectedKey.value)
|
||
const end = keys.indexOf(key)
|
||
if (start >= 0 && end >= 0) {
|
||
const [from, to] = start < end ? [start, end] : [end, start]
|
||
const range = keys.slice(from, to + 1)
|
||
selectedKeys.value = [...new Set([...selectedKeys.value, ...range])]
|
||
lastSelectedKey.value = key
|
||
return
|
||
}
|
||
}
|
||
if (selectedKeys.value.includes(key)) {
|
||
selectedKeys.value = selectedKeys.value.filter(item => item !== key)
|
||
} else {
|
||
selectedKeys.value = [...selectedKeys.value, key]
|
||
}
|
||
lastSelectedKey.value = key
|
||
}
|
||
|
||
async function onMoveSelected(event: Event) {
|
||
const select = event.target as HTMLSelectElement
|
||
const destId = select.value
|
||
select.value = ''
|
||
if (!destId) return
|
||
const items = selectedLibraryEntries()
|
||
if (!items.length) return
|
||
const dest = folders.value.find(folder => folder.id === destId)
|
||
const results = await Promise.allSettled(items.map(entry => {
|
||
if (entry.kind === 'clip') {
|
||
return $fetch(`/api/library/clips/${entry.group.head.id}`, {
|
||
method: 'PATCH',
|
||
body: { folderId: destId, family: true }
|
||
})
|
||
}
|
||
return $fetch(`/api/library/stills/${entry.stillGroup!.head.id}`, {
|
||
method: 'PATCH',
|
||
body: { folderId: destId, family: true }
|
||
})
|
||
}))
|
||
await loadLibrary()
|
||
const failed = results.filter(result => result.status === 'rejected').length
|
||
const moved = items.length - failed
|
||
clearSelection()
|
||
if (failed) toast(`${moved} moved to ${dest?.name || 'folder'}. ${failed} could not be moved.`)
|
||
else toast(`Moved ${moved} to ${dest?.name || 'folder'}${dest?.unlocked ? '' : ' (locked)'}`)
|
||
}
|
||
|
||
async function removeSelected() {
|
||
const items = selectedLibraryEntries()
|
||
if (!items.length) return
|
||
const clipIds = new Set<string>()
|
||
const stillIds = new Set<string>()
|
||
const results = await Promise.allSettled(items.map(entry => {
|
||
if (entry.kind === 'clip') {
|
||
for (const part of entry.group.parts) clipIds.add(part.id)
|
||
return $fetch(`/api/library/clips/${entry.group.head.id}`, {
|
||
method: 'DELETE',
|
||
query: { family: '1' }
|
||
})
|
||
}
|
||
for (const part of entry.stillGroup!.parts) stillIds.add(part.id)
|
||
return $fetch(`/api/library/stills/${entry.stillGroup!.head.id}`, {
|
||
method: 'DELETE',
|
||
query: { family: '1' }
|
||
})
|
||
}))
|
||
if ([...clipIds].some(id => videoUrl.value.includes(id) || currentClipId.value === id)) {
|
||
videoUrl.value = ''
|
||
awaitingReveal.value = false
|
||
concealOutput.value = false
|
||
lockedSave.value = false
|
||
currentClipId.value = ''
|
||
closeExtend()
|
||
}
|
||
if ([...stillIds].some(id => editResultUrl.value.includes(id))) {
|
||
editResultUrl.value = ''
|
||
editAwaitingReveal.value = false
|
||
concealEditOutput.value = false
|
||
editLockedSave.value = false
|
||
}
|
||
await loadLibrary()
|
||
const failed = results.filter(result => result.status === 'rejected').length
|
||
clearSelection()
|
||
if (failed) toast(`${items.length - failed} removed. ${failed} could not be deleted.`)
|
||
}
|
||
|
||
async function removeClip(item: LibraryClip, family = false) {
|
||
const watched = family ? familyPartsFor(item) : [item]
|
||
try {
|
||
await $fetch(`/api/library/clips/${item.id}`, {
|
||
method: 'DELETE',
|
||
query: family ? { family: '1' } : undefined
|
||
})
|
||
const ids = new Set(watched.map(part => part.id))
|
||
if ([...ids].some(id => videoUrl.value.includes(id) || currentClipId.value === id)) {
|
||
videoUrl.value = ''
|
||
awaitingReveal.value = false
|
||
concealOutput.value = false
|
||
lockedSave.value = false
|
||
currentClipId.value = ''
|
||
closeExtend()
|
||
}
|
||
await loadLibrary()
|
||
} catch (error: any) {
|
||
toast(error?.data?.statusMessage || 'Could not delete clip')
|
||
}
|
||
}
|
||
|
||
async function onMoveClip(item: LibraryClip, event: Event, family = false) {
|
||
const select = event.target as HTMLSelectElement
|
||
const destId = select.value
|
||
select.value = ''
|
||
if (!destId || destId === item.folderId) return
|
||
try {
|
||
applyLibrary(await $fetch(`/api/library/clips/${item.id}`, {
|
||
method: 'PATCH',
|
||
body: { folderId: destId, family: family || undefined }
|
||
}))
|
||
const dest = folders.value.find(folder => folder.id === destId)
|
||
toast(`Moved ${family ? 'collection' : 'clip'} to ${dest?.name || 'folder'}${dest?.unlocked ? '' : ' (locked)'}`)
|
||
} catch (error: any) {
|
||
toast(error?.data?.statusMessage || 'Could not move clip')
|
||
}
|
||
}
|
||
|
||
function startRename(item: LibraryClip, family = false) {
|
||
renamingId.value = item.id
|
||
renamingFamily.value = family
|
||
const source = family
|
||
? (familyBaseName(item.name || '') || item.name || '')
|
||
: (item.name || '')
|
||
renameDraft.value = source.trim() || clipFileName(item.prompt.split(/[.!?\n]/)[0] || 'output')
|
||
nextTick(() => {
|
||
renameInput.value?.focus()
|
||
renameInput.value?.select()
|
||
})
|
||
}
|
||
|
||
function cancelRename() {
|
||
renamingId.value = ''
|
||
renamingFamily.value = false
|
||
renameDraft.value = ''
|
||
}
|
||
|
||
async function saveRename(item: LibraryClip, family = false) {
|
||
if (renamingId.value !== item.id || renamingFamily.value !== family) return
|
||
const name = renameDraft.value.trim().slice(0, 80)
|
||
if (!name) {
|
||
toast('A name is required')
|
||
nextTick(() => renameInput.value?.focus())
|
||
return
|
||
}
|
||
const current = family ? familyBaseName(item.name || '') : item.name
|
||
if (name === current) {
|
||
cancelRename()
|
||
return
|
||
}
|
||
try {
|
||
applyLibrary(await $fetch(`/api/library/clips/${item.id}`, {
|
||
method: 'PATCH',
|
||
body: { name, family: family || undefined }
|
||
}))
|
||
cancelRename()
|
||
if (videoUrl.value.includes(item.id)) {
|
||
videoDownloadName.value = clipFileName(name)
|
||
}
|
||
statusMessage.value = family ? `Renamed collection to ${familyBaseName(name) || name}` : `Renamed to ${name}`
|
||
} catch (error: any) {
|
||
toast(error?.data?.statusMessage || 'Could not rename clip')
|
||
}
|
||
}
|
||
|
||
function onPlayed() {
|
||
awaitingReveal.value = false
|
||
}
|
||
|
||
function onVideoMeta() {
|
||
const el = player.value
|
||
if (!el) {
|
||
videoSeekable.value = true
|
||
return
|
||
}
|
||
const duration = el.duration
|
||
videoSeekable.value = Number.isFinite(duration) && duration > 0
|
||
}
|
||
|
||
function revealAndPlay() {
|
||
awaitingReveal.value = false
|
||
nextTick(() => {
|
||
player.value?.play().catch(() => null)
|
||
})
|
||
}
|
||
|
||
function queueExtension() {
|
||
const prior = extensionQueue.value[extensionQueue.value.length - 1]
|
||
extensionQueue.value.push({
|
||
id: crypto.randomUUID(),
|
||
prompt: prompt.value.trim(),
|
||
promptPre: promptPre.value,
|
||
promptPost: promptPost.value,
|
||
duration: clampDuration(prior?.duration ?? duration.value),
|
||
loraStack: []
|
||
})
|
||
}
|
||
|
||
function removeQueuedExtension(id: string) {
|
||
extensionQueue.value = extensionQueue.value.filter(item => item.id !== id)
|
||
}
|
||
|
||
function applyIterationSweep(rows: ImageIteration[]) {
|
||
if (rows.length < 2) { toast('Use at least two variations for Iterate.'); return }
|
||
v2Steps.value = rows[0].steps!
|
||
v2Cfg.value = rows[0].cfg!
|
||
v2Turbo.value = false
|
||
imagePassMode.value = 'batch'
|
||
if (!seedInput.value || seedInput.value === 'random') seedInput.value = String(Math.floor(Math.random() * 2_147_483_647))
|
||
editPassQueue.value = rows.slice(1).map(row => ({ ...row, id: crypto.randomUUID() }))
|
||
imageBatchScript.value = ''
|
||
toast(rows.length + ' variations ready. The first uses the shared settings; each following variation can be edited.')
|
||
}
|
||
|
||
function queueEditPass() {
|
||
editPassQueue.value.push({
|
||
id: crypto.randomUUID(),
|
||
prompt: ''
|
||
})
|
||
}
|
||
|
||
function removeEditPass(id: string) {
|
||
editPassQueue.value = editPassQueue.value.filter(item => item.id !== id)
|
||
}
|
||
|
||
function clearEditPasses() {
|
||
editPassQueue.value = []
|
||
imageBatchScript.value = ''
|
||
}
|
||
|
||
function reconstructImageBatchScript() {
|
||
if (imageBatchScript.value.trim()) return imageBatchScript.value
|
||
const first = prompt.value.trim()
|
||
const rest = editPassQueue.value.map(item => item.prompt.trim()).filter(Boolean)
|
||
if (!first && !rest.length) return ''
|
||
if (!rest.length) return first
|
||
return [`shot 1\n${first}`, ...rest.map((item, index) => `shot ${index + 2}\n${item}`)].join('\n\n')
|
||
}
|
||
|
||
function openImageBatchModal() {
|
||
imageBatchDraft.value = reconstructImageBatchScript()
|
||
requestAnimationFrame(() => {
|
||
imageBatchOpen.value = true
|
||
})
|
||
}
|
||
|
||
function applyImageBatch(text?: string) {
|
||
const src = String(text ?? imageBatchDraft.value)
|
||
const shots = parseImageBatchScript(src)
|
||
if (!shots.length) {
|
||
toast('Paste at least one prompt. Put shot 1, shot 2 on their own lines.')
|
||
return
|
||
}
|
||
imagePassMode.value = 'batch'
|
||
imageBatchDraft.value = src
|
||
imageBatchScript.value = src
|
||
prompt.value = shots[0].prompt
|
||
editPassQueue.value = shots.slice(1).map(item => ({ id: crypto.randomUUID(), prompt: item.prompt }))
|
||
imageBatchOpen.value = false
|
||
toast(shots.length === 1 ? 'Loaded 1 prompt' : `Loaded ${shots.length} prompts on the start still`)
|
||
}
|
||
|
||
function absorbPastedImageBatch() {
|
||
if (imagePassMode.value !== 'batch') return
|
||
if (!(studioMode.value === 'edit' || v2PassChaining.value)) return
|
||
if (editPassQueue.value.length) return
|
||
const shots = parseImageBatchScript(prompt.value)
|
||
if (shots.length < 2) return
|
||
imageBatchScript.value = prompt.value
|
||
prompt.value = shots[0].prompt
|
||
editPassQueue.value = shots.slice(1).map(item => ({ id: crypto.randomUUID(), prompt: item.prompt }))
|
||
}
|
||
|
||
function imagePassUnit() {
|
||
if (v2Mode.value === 'iterate') return 'Iteration'
|
||
return imagePassMode.value === 'batch' ? 'Batch' : 'Pass'
|
||
}
|
||
|
||
function imagePassPlan(firstPrompt: string, extra: { prompt: string }[], fallback: string) {
|
||
const unit = imagePassUnit()
|
||
if (!extra.length) return [{ label: fallback, prompt: firstPrompt }]
|
||
return [
|
||
{ label: `${unit} 1`, prompt: firstPrompt },
|
||
...extra.map((item, index) => ({ label: `${unit} ${index + 2}`, prompt: item.prompt }))
|
||
]
|
||
}
|
||
|
||
async function editImage() {
|
||
if (!file.value || (!prompt.value.trim() && !imageFillWithoutPrompt.value)) {
|
||
toast(editBlockReason.value || 'Load a still first.')
|
||
return
|
||
}
|
||
if (editPassQueue.value.some(item => !item.prompt.trim())) {
|
||
toast('Each extra pass needs a prompt.')
|
||
return
|
||
}
|
||
if (!folderId.value) {
|
||
toast('Choose a library folder before editing.')
|
||
return
|
||
}
|
||
absorbPastedImageBatch()
|
||
const extraPasses = editPassQueue.value.map(item => ({ prompt: item.prompt.trim() }))
|
||
const hideOut = hideThumbnail.value
|
||
try {
|
||
await ensureGenerationNotifyPermission()
|
||
const body = new FormData()
|
||
body.append('image', file.value)
|
||
if (editRefFile.value) body.append('image2', editRefFile.value)
|
||
body.append('prompt', composePromptParts(promptPre.value, prompt.value.trim(), promptPost.value) || prompt.value.trim())
|
||
body.append('promptMid', prompt.value.trim())
|
||
body.append('promptPre', promptPre.value.trim())
|
||
body.append('promptPost', promptPost.value.trim())
|
||
body.append('aspect', aspect.value)
|
||
body.append('negative', editNegative.value)
|
||
body.append('steps', String(clampImageSteps(editSteps.value)))
|
||
body.append('cfg', String(clampImageCfg(editCfg.value)))
|
||
body.append('scaleToTotalPixels', String(editScaleToTotalPixels.value))
|
||
body.append('scaleMegapixels', String(clampImageScaleMegapixels(editScaleMegapixels.value)))
|
||
body.append('seed', seedInput.value || 'random')
|
||
body.append('folderId', folderId.value)
|
||
body.append('hideInput', String(showPrivacyToggles.value && hideInputPreview.value))
|
||
body.append('hideThumbnail', String(showPrivacyToggles.value && hideThumbnail.value))
|
||
body.append('name', clipName.value.trim())
|
||
if (inputStillId.value) body.append('stillId', inputStillId.value)
|
||
if (extraPasses.length) body.append('passes', JSON.stringify(extraPasses))
|
||
body.append('passMode', imagePassMode.value)
|
||
body.append('loraStack', JSON.stringify(imageLoraStack.value))
|
||
const downloadName = clipFileName(clipName.value.trim() || autoOutputName.value)
|
||
const started = await $fetch<{
|
||
jobId: string
|
||
studioJobId?: string
|
||
queued?: boolean
|
||
steps: number
|
||
hideThumbnail?: boolean
|
||
folderLocked?: boolean
|
||
}>('/api/edit', {
|
||
method: 'POST',
|
||
body
|
||
})
|
||
if (started.queued) {
|
||
const n = 1 + extraPasses.length
|
||
toast(n > 1
|
||
? (imagePassMode.value === 'batch'
|
||
? `Queued ${n} edits from the start still behind the current job.`
|
||
: `Queued ${n} edit passes behind the current job.`)
|
||
: 'Queued behind the current job.')
|
||
await refreshStudioQueue()
|
||
return
|
||
}
|
||
editStatusBusy.value = false
|
||
editBusy.value = true
|
||
stopListen('edit')
|
||
editSettledUi = false
|
||
editProgress.value = 2
|
||
editResultUrl.value = ''
|
||
editAwaitingReveal.value = false
|
||
concealEditOutput.value = hideOut
|
||
editLockedSave.value = false
|
||
editStatusMessage.value = 'Checking Beast ComfyUI...'
|
||
editMaxStep.value = started.steps
|
||
editChainStep.value = 1
|
||
editChainTotal.value = 1 + extraPasses.length
|
||
editChainLabel.value = extraPasses.length ? `${imagePassUnit()} 1` : ''
|
||
editOverallProgress.value = 0
|
||
editCompletedChainStep.value = 0
|
||
editActiveChainPlan.value = imagePassPlan(prompt.value.trim(), extraPasses, 'Pass 1')
|
||
startTimer('edit')
|
||
editDownloadName.value = downloadName
|
||
editJobId.value = started.jobId
|
||
outputStudioJobId.value = started.studioJobId || ''
|
||
persistActiveJob('edit', started.jobId, started.hideThumbnail === true || hideOut, started.folderLocked === true)
|
||
listen('edit', started.jobId, started.hideThumbnail === true || hideOut, started.folderLocked === true)
|
||
void pollComfyHealth()
|
||
void refreshStudioQueue()
|
||
} catch (error: any) {
|
||
editSettledUi = true
|
||
const message = error?.data?.statusMessage || error?.statusMessage || error?.message || 'Image edit failed'
|
||
editStatusMessage.value = message
|
||
toast(message)
|
||
editBusy.value = false
|
||
editStatusBusy.value = false
|
||
stopTimer('edit')
|
||
void pollComfyHealth()
|
||
}
|
||
}
|
||
|
||
function setV2Mode(mode: ImageV2Mode) {
|
||
if (v2Mode.value === imageModeForInput(mode, Boolean(file.value), editPassQueue.value.length > 0)) return
|
||
if (editPassQueue.value.length && v2PassChaining.value) {
|
||
toast('Remove extra passes to change graphs. Extra passes stay in the current mode.')
|
||
return
|
||
}
|
||
v2Mode.value = mode
|
||
if (mode === 'edit') v2Task.value = 'scene'
|
||
if (mode === 'refine') {
|
||
v2Task.value = 'refine'
|
||
void ensureRefineCanvas()
|
||
}
|
||
if (mode === 'generate') {
|
||
v2Task.value = 't2i'
|
||
}
|
||
if (mode === 'iterate') {
|
||
v2Task.value = 'scene'
|
||
imagePassMode.value = 'batch'
|
||
}
|
||
if (v2Engine.value === 'krea' && mode === 'edit') v2Denoise.value = IMAGE_V2_KREA_EDIT_DENOISE
|
||
if (v2Engine.value === 'krea' && (mode === 'compose' || mode === 'iterate')) v2Denoise.value = IMAGE_V2_KREA_COMPOSE_DENOISE
|
||
}
|
||
|
||
function setV2Engine(engine: ImageV2Engine) {
|
||
if (engine === v2Engine.value) return
|
||
const previous = v2Engine.value
|
||
const previousStack = imageV2LoraStack.value
|
||
persistImageV2LoraStack(previousStack, previous)
|
||
v2Engine.value = engine
|
||
imageV2LoraStack.value = readImageV2LoraStack(engine)
|
||
seedImageV2LorasIfEmpty()
|
||
if (engine === 'krea') {
|
||
v2Steps.value = IMAGE_V2_KREA_STEPS
|
||
v2Cfg.value = IMAGE_V2_KREA_CFG
|
||
v2Turbo.value = false
|
||
if (previousStack.some(item => imageLoraEngineOf(item.name) === 'flux')) {
|
||
toast('Flux LoRAs stay on the Flux engine. Switch back to restore that chain.')
|
||
}
|
||
if (v2Mode.value === 'edit') v2Denoise.value = IMAGE_V2_KREA_EDIT_DENOISE
|
||
if (v2Mode.value === 'compose') v2Denoise.value = IMAGE_V2_KREA_COMPOSE_DENOISE
|
||
return
|
||
}
|
||
if (previous === 'krea') {
|
||
v2Steps.value = IMAGE_V2_STEPS_DEFAULT
|
||
v2Cfg.value = IMAGE_V2_CFG_DEFAULT
|
||
}
|
||
}
|
||
|
||
function setImageAspect(id: string) {
|
||
aspect.value = normalizeAspect(id)
|
||
const size = generateSizeForAspect(aspect.value)
|
||
v2Width.value = size.width
|
||
v2Height.value = size.height
|
||
}
|
||
|
||
function applyRefinePreset(kind: 'face' | 'hand' | 'heavy') {
|
||
if (kind === 'face') {
|
||
v2Denoise.value = IMAGE_V2_REFINE_FACE.strength
|
||
if (v2Engine.value === 'flux') {
|
||
if (v2ConceptLoraEnabled.value && imageV2SnofsOn.value) {
|
||
imageV2LoraStack.value = upsertImageV2Lora(imageV2LoraStack.value, IMAGE_V2_SNOFS_LORA, IMAGE_V2_REFINE_FACE.snofsModel, IMAGE_V2_REFINE_FACE.snofsClip)
|
||
}
|
||
imageV2LoraStack.value = upsertImageV2Lora(imageV2LoraStack.value, IMAGE_V2_CONSISTENCY_LORA, IMAGE_V2_REFINE_FACE.consistencyModel, IMAGE_V2_REFINE_FACE.consistencyClip)
|
||
}
|
||
if (!prompt.value.trim()) prompt.value = IMAGE_V2_REFINE_FACE_PROMPT
|
||
return
|
||
}
|
||
if (kind === 'hand') {
|
||
v2Denoise.value = IMAGE_V2_REFINE_HAND.strength
|
||
if (v2Engine.value === 'flux') {
|
||
if (v2ConceptLoraEnabled.value && imageV2SnofsOn.value) {
|
||
imageV2LoraStack.value = upsertImageV2Lora(imageV2LoraStack.value, IMAGE_V2_SNOFS_LORA, IMAGE_V2_REFINE_HAND.snofsModel, IMAGE_V2_REFINE_HAND.snofsClip)
|
||
}
|
||
imageV2LoraStack.value = upsertImageV2Lora(imageV2LoraStack.value, IMAGE_V2_CONSISTENCY_LORA, IMAGE_V2_REFINE_HAND.consistencyModel, IMAGE_V2_REFINE_HAND.consistencyClip)
|
||
}
|
||
return
|
||
}
|
||
v2Denoise.value = IMAGE_V2_REFINE_HEAVY.strength
|
||
}
|
||
|
||
async function ensureRefineCanvas() {
|
||
if (file.value) return
|
||
const stillId = lastV2StillId.value || stillIdFromLibraryUrl(editResultUrl.value)
|
||
if (!stillId) return
|
||
try {
|
||
const blob = await $fetch<Blob>(`/api/library/stills/${stillId}`, { responseType: 'blob' })
|
||
if (!blob?.size) return
|
||
readFile(new File([blob], 'refine-canvas.png', { type: blob.type || 'image/png' }), { stillId })
|
||
} catch {
|
||
// leave Still A empty if the last output is gone
|
||
}
|
||
}
|
||
|
||
async function sendToEdit() {
|
||
if (!editResultUrl.value) return
|
||
clearEditPasses()
|
||
studioMode.value = 'editv2'
|
||
outputFocus.value = 'edit'
|
||
try {
|
||
await useEditAsInput()
|
||
setV2Mode('edit')
|
||
toast('Output is Still A. Write an edit prompt.')
|
||
} catch {
|
||
toast('Could not send that still to Edit')
|
||
}
|
||
}
|
||
|
||
async function sendToRefine() {
|
||
if (!editResultUrl.value) return
|
||
clearEditPasses()
|
||
studioMode.value = 'editv2'
|
||
outputFocus.value = 'edit'
|
||
try {
|
||
await useEditAsInput()
|
||
setV2Mode('refine')
|
||
toast('Output is Still A. Paint a mask, then refine.')
|
||
} catch {
|
||
toast('Could not send that still to Refine')
|
||
}
|
||
}
|
||
|
||
async function editImageV2() {
|
||
if (editV2Blocked.value) {
|
||
toast(editV2BlockReason.value || 'Load still A first.')
|
||
return
|
||
}
|
||
if (v2Mode.value === 'compose' && !editRefFile.value) {
|
||
toast('Compose requires still B. This will not fall back to one-image generation.')
|
||
return
|
||
}
|
||
if (v2Mode.value === 'refine' && !refineMaskDirty.value) {
|
||
toast('Paint a mask on Still A first. Refine will not fall back to Edit.')
|
||
return
|
||
}
|
||
if (v2Mode.value !== 'iterate' && v2PassChaining.value && editPassQueue.value.some(item => !item.prompt.trim())) {
|
||
toast('Each extra pass needs a prompt.')
|
||
return
|
||
}
|
||
absorbPastedImageBatch()
|
||
let extraPasses: ImageIteration[]
|
||
try { extraPasses = v2PassChaining.value ? normalizeImageIterations(editPassQueue.value) : [] }
|
||
catch (error) { toast(error instanceof Error ? error.message : String(error)); return }
|
||
const hideOut = hideThumbnail.value
|
||
try {
|
||
await ensureGenerationNotifyPermission()
|
||
const body = new FormData()
|
||
body.append('mode', v2Mode.value)
|
||
const iterateGenerate = v2Mode.value === 'iterate' && !file.value
|
||
body.append('task', v2Mode.value === 'generate' || iterateGenerate
|
||
? 't2i'
|
||
: v2Mode.value === 'refine'
|
||
? 'refine'
|
||
: (v2Mode.value === 'compose' || (v2Mode.value === 'iterate' && editRefFile.value))
|
||
? v2Task.value
|
||
: 'scene')
|
||
if (v2Mode.value !== 'generate' && file.value) body.append('image_a', file.value)
|
||
if ((v2Mode.value === 'compose' || v2Mode.value === 'iterate') && editRefFile.value) body.append('image_b', editRefFile.value)
|
||
if (v2Mode.value === 'refine' || (v2Engine.value === 'krea' && v2Mode.value !== 'generate' && !iterateGenerate)) {
|
||
if (v2Mode.value === 'refine') {
|
||
const maskBlob = await refinePainter.value?.exportPng()
|
||
if (!maskBlob) {
|
||
toast('Paint a mask on Still A first. Refine will not fall back to Edit.')
|
||
return
|
||
}
|
||
body.append('mask', new File([maskBlob], 'refine-mask.png', { type: 'image/png' }))
|
||
}
|
||
body.append('strength', String(clampImageV2Denoise(v2Denoise.value)))
|
||
}
|
||
if (v2Mode.value === 'generate' || iterateGenerate) {
|
||
const size = generateSizeForAspect(aspect.value)
|
||
body.append('width', String(size.width))
|
||
body.append('height', String(size.height))
|
||
}
|
||
body.append('aspect', aspect.value)
|
||
body.append('engine', v2Engine.value)
|
||
body.append('prompt', composePromptParts(promptPre.value, prompt.value.trim(), promptPost.value) || prompt.value.trim())
|
||
body.append('promptMid', prompt.value.trim())
|
||
body.append('promptPre', promptPre.value.trim())
|
||
body.append('promptPost', promptPost.value.trim())
|
||
body.append('negative', v2Negative.value)
|
||
body.append('snofs_model', String(imageV2EngineLoras().find(item => isImageV2ConceptLora(item.name))?.strengthModel ?? 0))
|
||
body.append('snofs_clip', String(imageV2EngineLoras().find(item => isImageV2ConceptLora(item.name))?.strengthClip ?? 0))
|
||
body.append('consistency_model', String(imageV2EngineLoras().find(item => isImageV2ConsistencyLora(item.name))?.strengthModel ?? 0))
|
||
body.append('consistency_clip', String(imageV2EngineLoras().find(item => isImageV2ConsistencyLora(item.name))?.strengthClip ?? 0))
|
||
body.append('steps', String(clampImageSteps(v2Steps.value, v2Engine.value === 'krea' ? IMAGE_V2_KREA_STEPS : IMAGE_V2_STEPS_DEFAULT)))
|
||
body.append('cfg', String(clampImageCfg(v2Cfg.value, v2Engine.value === 'krea' ? IMAGE_V2_KREA_CFG : IMAGE_V2_CFG_DEFAULT)))
|
||
if (v2Mode.value !== 'generate' && !iterateGenerate) {
|
||
body.append('scaleToTotalPixels', String(v2ScaleOn.value))
|
||
if (v2ScaleOn.value) body.append('megapixels', String(clampImageScaleMegapixels(v2Megapixels.value, 1)))
|
||
}
|
||
body.append('turbo', String(v2Turbo.value === true))
|
||
body.append('seed', seedInput.value || 'random')
|
||
body.append('folderId', folderId.value)
|
||
body.append('hideInput', String(showPrivacyToggles.value && hideInputPreview.value))
|
||
body.append('hideThumbnail', String(showPrivacyToggles.value && hideThumbnail.value))
|
||
body.append('name', clipName.value.trim())
|
||
if (inputStillId.value && file.value) body.append('stillId', inputStillId.value)
|
||
if (extraPasses.length) body.append('passes', JSON.stringify(extraPasses))
|
||
body.append('passMode', iterateGenerate ? 'batch' : imagePassMode.value)
|
||
const v2Stack = filterLoraStackForImageEngine(
|
||
filterLoraStackForStudio(imageV2LoraStack.value, ltxEnabled.value),
|
||
v2Engine.value
|
||
)
|
||
const wrongEngine = imageV2LoraStack.value.find((item) => {
|
||
const tagged = imageLoraEngineOf(item.name)
|
||
return tagged != null && tagged !== v2Engine.value
|
||
})
|
||
if (wrongEngine) {
|
||
toast(imageLoraEngineMismatchMessage(wrongEngine.name, v2Engine.value))
|
||
return
|
||
}
|
||
body.append('loraStack', JSON.stringify(v2Stack))
|
||
const downloadName = clipFileName(clipName.value.trim() || autoOutputName.value)
|
||
const started = await $fetch<{
|
||
jobId: string
|
||
studioJobId?: string
|
||
queued?: boolean
|
||
steps: number
|
||
hideThumbnail?: boolean
|
||
folderLocked?: boolean
|
||
}>('/api/v2/generate', {
|
||
method: 'POST',
|
||
body
|
||
})
|
||
if (started.queued) {
|
||
const n = 1 + extraPasses.length
|
||
toast(n > 1
|
||
? (v2Mode.value === 'iterate'
|
||
? `Queued ${n} iterations behind the current job.`
|
||
: imagePassMode.value === 'batch'
|
||
? `Queued ${n} Image v2 stills from the start image behind the current job.`
|
||
: `Queued ${n} Image v2 passes behind the current job.`)
|
||
: (v2Engine.value === 'krea' ? 'Queued Krea generate behind the current job.' : 'Queued Image v2 behind the current job.'))
|
||
await refreshStudioQueue()
|
||
return
|
||
}
|
||
editStatusBusy.value = false
|
||
editBusy.value = true
|
||
stopListen('edit')
|
||
editSettledUi = false
|
||
editProgress.value = 2
|
||
editResultUrl.value = ''
|
||
editAwaitingReveal.value = false
|
||
concealEditOutput.value = hideOut
|
||
editLockedSave.value = false
|
||
editStatusMessage.value = 'Checking Beast ComfyUI...'
|
||
editMaxStep.value = started.steps
|
||
editChainStep.value = 1
|
||
editChainTotal.value = 1 + extraPasses.length
|
||
editChainLabel.value = extraPasses.length ? `${imagePassUnit()} 1` : ''
|
||
editOverallProgress.value = 0
|
||
editCompletedChainStep.value = 0
|
||
const passNoun = v2Mode.value === 'iterate'
|
||
? 'Iteration'
|
||
: v2Mode.value === 'compose'
|
||
? 'Compose'
|
||
: v2Mode.value === 'refine'
|
||
? 'Refine'
|
||
: v2Mode.value === 'generate'
|
||
? 'Generate'
|
||
: 'Edit'
|
||
editActiveChainPlan.value = imagePassPlan(prompt.value.trim(), extraPasses, passNoun)
|
||
startTimer('edit')
|
||
editDownloadName.value = downloadName
|
||
editJobId.value = started.jobId
|
||
outputStudioJobId.value = started.studioJobId || ''
|
||
persistActiveJob('edit', started.jobId, started.hideThumbnail === true || hideOut, started.folderLocked === true)
|
||
v2JobPending.value = true
|
||
listen('edit', started.jobId, started.hideThumbnail === true || hideOut, started.folderLocked === true)
|
||
void pollComfyHealth()
|
||
void refreshStudioQueue()
|
||
} catch (error: any) {
|
||
editSettledUi = true
|
||
const message = error?.data?.statusMessage || error?.statusMessage || error?.message || 'Image v2 failed'
|
||
editStatusMessage.value = message
|
||
toast(message)
|
||
editBusy.value = false
|
||
editStatusBusy.value = false
|
||
stopTimer('edit')
|
||
void pollComfyHealth()
|
||
}
|
||
}
|
||
|
||
async function useEditAsInput() {
|
||
if (!editResultUrl.value) return
|
||
const stillId = lastV2StillId.value || stillIdFromLibraryUrl(editResultUrl.value)
|
||
const libraryStill = stillId ? stills.value.find(item => item.id === stillId) : undefined
|
||
if (libraryStill) {
|
||
await useStillAsInput(libraryStill)
|
||
return
|
||
}
|
||
try {
|
||
const blob = await $fetch<Blob>(editResultUrl.value, { responseType: 'blob' })
|
||
if (!blob || !blob.size) throw new Error('empty still')
|
||
readFile(new File([blob], `${editDownloadName.value || 'edited'}.png`, { type: blob.type || 'image/png' }), {
|
||
stillId: stillId || undefined
|
||
})
|
||
toast('Edited still is now the input')
|
||
} catch {
|
||
toast('Could not load that still as input')
|
||
}
|
||
}
|
||
|
||
function applyEngineDefaults(engine: VideoEngineId) {
|
||
if (engine === 'ltx') {
|
||
samplerName.value = 'euler'
|
||
if (!cfgTouched.value) cfg.value = 3.5
|
||
return
|
||
}
|
||
if (samplerName.value === 'euler') samplerName.value = 'res_multistep'
|
||
if (!cfgTouched.value) cfg.value = turbo.value ? CFG_TURBO : CFG_QUALITY
|
||
}
|
||
|
||
function selectVideoEngine(engine: VideoEngineId) {
|
||
if (engine === 'ltx' && !ltxEnabled.value) return
|
||
if (videoEngine.value === engine) return
|
||
cfgTouched.value = false
|
||
videoEngine.value = engine
|
||
applyEngineDefaults(engine)
|
||
}
|
||
|
||
function applyVideoWorkflow(raw?: string) {
|
||
const parsed = coerceVideoWorkflow(raw, ltxEnabled.value)
|
||
videoEngine.value = videoEngineOf(parsed)
|
||
minimaxGraph.value = minimaxGraphOf(parsed) === 'v1' ? 'v2' : minimaxGraphOf(parsed)
|
||
}
|
||
|
||
async function ensureVideoInputStill() {
|
||
if (inputStillId.value && file.value) return
|
||
let stillId = inputStillId.value
|
||
if (!stillId) {
|
||
const selected = selectedLibraryEntries().filter(entry => entry.kind === 'still')
|
||
if (selected.length === 1) stillId = selected[0].stillGroup?.head.id || ''
|
||
}
|
||
if (!stillId) return
|
||
inputStillId.value = stillId
|
||
if (file.value) return
|
||
const blob = await $fetch<Blob>(`/api/library/stills/${stillId}`, { responseType: 'blob' })
|
||
if (!blob?.size) throw new Error('Could not load that still')
|
||
const still = stills.value.find(item => item.id === stillId)
|
||
readFile(new File([blob], stillFileName(still), { type: blob.type || 'image/png' }), {
|
||
keepThumbnailPref: true,
|
||
stillId
|
||
})
|
||
}
|
||
|
||
async function generate() {
|
||
if (!ltxEnabled.value && isLtxWorkflow(videoWorkflow.value)) {
|
||
videoEngine.value = 'minimax'
|
||
applyEngineDefaults('minimax')
|
||
toast('LTX Pink Cherry is not available on this instance')
|
||
return
|
||
}
|
||
console.info('[aigen generate click]', {
|
||
hasFile: Boolean(file.value),
|
||
fileName: file.value?.name || '',
|
||
fileSize: file.value?.size || 0,
|
||
fileType: file.value?.type || '',
|
||
inputStillId: inputStillId.value,
|
||
hasPreview: Boolean(preview.value),
|
||
hasInputStill: hasInputStill.value,
|
||
mode: studioMode.value,
|
||
workflow: videoWorkflow.value,
|
||
block: videoBlockReason.value || '',
|
||
promptChars: prompt.value.trim().length
|
||
})
|
||
if (videoBlockReason.value) {
|
||
toast(videoBlockReason.value)
|
||
return
|
||
}
|
||
if (!folderId.value) {
|
||
toast('Choose a library folder before generating.')
|
||
return
|
||
}
|
||
if (!textToVideo.value) {
|
||
try {
|
||
await ensureVideoInputStill()
|
||
} catch {
|
||
toast('Could not load the selected still for image-to-video.')
|
||
return
|
||
}
|
||
if (!file.value && !inputStillId.value) {
|
||
toast('Load a still first. Image-to-video needs an input still.')
|
||
return
|
||
}
|
||
}
|
||
const shots = shotScriptMode.value ? parsedShots.value : null
|
||
const initialPrompt = (shots?.[0]?.prompt || prompt.value).trim()
|
||
const queued = shots
|
||
? shots.slice(1).map(shot => ({ prompt: shot.prompt.trim(), duration: duration.value, ...persistLoraFields(shotLoraStacks.value[shot.n] || []) }))
|
||
: extensionQueue.value.map(item => ({
|
||
prompt: item.prompt.trim(),
|
||
promptPre: item.promptPre,
|
||
promptPost: item.promptPost,
|
||
duration: item.duration,
|
||
...persistLoraFields(item.loraStack || [])
|
||
}))
|
||
const hideOut = hideThumbnail.value
|
||
const folderLockedPref = false
|
||
const keepLiveOutput = watchingLiveVideo()
|
||
try {
|
||
await ensureGenerationNotifyPermission()
|
||
const body = new FormData()
|
||
if (!textToVideo.value) {
|
||
if (file.value) body.append('image', file.value)
|
||
if (inputStillId.value) body.append('stillId', inputStillId.value)
|
||
}
|
||
body.append('prompt', composePromptParts(promptPre.value, initialPrompt, promptPost.value) || initialPrompt)
|
||
body.append('promptMid', initialPrompt)
|
||
body.append('promptPre', promptPre.value.trim())
|
||
body.append('promptPost', promptPost.value.trim())
|
||
body.append('aspect', aspect.value)
|
||
body.append('width', String(outputSize.value.width))
|
||
body.append('height', String(outputSize.value.height))
|
||
body.append('turbo', String(turbo.value))
|
||
body.append('steps', String(defaultVideoSteps(turbo.value, videoWorkflow.value)))
|
||
body.append('duration', String(duration.value))
|
||
body.append('cfg', String(cfg.value))
|
||
body.append('fps', String(fps.value))
|
||
body.append('sampler_name', samplerName.value)
|
||
body.append('scheduler', scheduler.value)
|
||
body.append('seed', seedInput.value || 'random')
|
||
body.append('folderId', folderId.value)
|
||
body.append('hideThumbnail', String(showPrivacyToggles.value && hideThumbnail.value))
|
||
body.append('hideInput', String(showPrivacyToggles.value && hideInputPreview.value))
|
||
body.append('sound', String(withSound.value))
|
||
body.append('name', clipName.value.trim())
|
||
body.append('workflow', videoWorkflow.value)
|
||
body.append('useIdentityRefs', String(videoWorkflow.value === 'v2' && useIdentityRefs.value && !extraPersonLock.value))
|
||
if (videoWorkflow.value === 'v2' && useIdentityRefs.value && !extraPersonLock.value) {
|
||
identityRefs.value.forEach((ref, index) => {
|
||
if (ref) body.append(`refImage${index}`, ref)
|
||
})
|
||
}
|
||
if (globalLocks.value.trim()) body.append('globalLocks', globalLocks.value)
|
||
body.append('permanenceRefs', JSON.stringify(familyPermanenceRefs.value))
|
||
const shotRefList = shots
|
||
? shots.map(shot => shotPermanenceRefs.value[shot.n] || [])
|
||
: [shotPermanenceRefs.value[1] || [], ...queued.map((_, index) => shotPermanenceRefs.value[index + 2] || [])]
|
||
body.append('shotPermanenceRefs', JSON.stringify(shotRefList))
|
||
const shotLoraStacksList = shots
|
||
? shots.map(shot => shotLoraStacks.value[shot.n] || [])
|
||
: [shotLoraStacks.value[1] || [], ...queued.map(item => item.loraStack || [])]
|
||
body.append('loraStack', JSON.stringify(videoLoraStack.value))
|
||
body.append('shotLoraStacks', JSON.stringify(shotLoraStacksList))
|
||
if (activeDraftId) body.append('draftId', activeDraftId)
|
||
if (queued.length) {
|
||
body.append('extensions', JSON.stringify(queued.map((item, index) => ({
|
||
...item,
|
||
permanenceRefs: shotRefList[index + 1] || [],
|
||
...persistLoraFields(shotLoraStacksList[index + 1] || [])
|
||
}))))
|
||
body.append('queueAutoRun', String(queueAutoRun.value))
|
||
}
|
||
body.append('refineExtensionFrame', String(refineExtensionFrame.value))
|
||
body.append('saveLosslessAnchor', String(saveLosslessAnchor.value))
|
||
body.append('refinementDenoise', String(clampRefinementDenoise(refinementDenoise.value)))
|
||
const downloadName = clipFileName(clipName.value.trim() || autoOutputName.value)
|
||
const chainPlan = plannedChain.value.map(part => ({ ...part }))
|
||
console.info('[aigen generate payload]', {
|
||
keys: [...body.keys()],
|
||
hasImage: body.has('image'),
|
||
stillId: inputStillId.value,
|
||
promptChars: initialPrompt.length,
|
||
workflow: videoWorkflow.value
|
||
})
|
||
const started = await $fetch<{
|
||
jobId: string
|
||
studioJobId?: string
|
||
queued?: boolean
|
||
seed: number
|
||
width: number
|
||
height: number
|
||
steps: number
|
||
turbo: boolean
|
||
hideThumbnail?: boolean
|
||
folderLocked?: boolean
|
||
queueId?: string
|
||
chainTotal?: number
|
||
}>('/api/generate', {
|
||
method: 'POST',
|
||
body
|
||
})
|
||
const queuedShotCount = 1 + queued.length
|
||
const queuedBehindToast = queuedShotCount > 1
|
||
? `Queued ${queuedShotCount} shots behind the current job.`
|
||
: 'Queued behind the current job.'
|
||
if (queuedOffLiveOutput(started, 'video')) {
|
||
if (watchingLiveVideo()) {
|
||
toast(queuedShotCount > 1
|
||
? `Queued ${queuedShotCount} shots. Output stays on the clip that is generating.`
|
||
: 'Queued behind the current job. Output stays on the clip that is generating.')
|
||
await refreshStudioQueue()
|
||
return
|
||
}
|
||
outputStudioJobId.value = started.studioJobId || ''
|
||
outputFocus.value = 'video'
|
||
videoAwaitingBeast.value = true
|
||
statusMessage.value = 'Queued. Waiting for Beast to be free.'
|
||
toast(queuedBehindToast)
|
||
await refreshStudioQueue()
|
||
return
|
||
}
|
||
videoAwaitingBeast.value = false
|
||
clearVideoForm()
|
||
statusBusy.value = false
|
||
videoBusy.value = true
|
||
stopListen('video')
|
||
videoSettledUi = false
|
||
progress.value = 1
|
||
videoUrl.value = ''
|
||
currentClipId.value = ''
|
||
closeExtend()
|
||
awaitingReveal.value = false
|
||
concealOutput.value = started.hideThumbnail === true || hideOut
|
||
lockedSave.value = false
|
||
watchingLibrary.value = false
|
||
playerSize.value = { width: started.width, height: started.height }
|
||
statusMessage.value = 'Checking ComfyUI...'
|
||
chainStep.value = 1
|
||
chainTotal.value = started.chainTotal || (1 + queued.length)
|
||
chainLabel.value = queued.length ? 'Initial' : ''
|
||
overallProgress.value = 0
|
||
completedChainStep.value = 0
|
||
activeChainPlan.value = chainPlan
|
||
startTimer('video')
|
||
videoDownloadName.value = downloadName
|
||
videoJobId.value = started.jobId
|
||
outputStudioJobId.value = started.studioJobId || ''
|
||
maxStep.value = started.steps
|
||
persistActiveJob('video', started.jobId, started.hideThumbnail === true || hideOut, started.folderLocked === true || folderLockedPref)
|
||
if (started.queueId) {
|
||
persistActiveQueue(started.queueId)
|
||
void refreshLiveQueue()
|
||
}
|
||
listen('video', started.jobId, started.hideThumbnail === true || hideOut, started.folderLocked === true)
|
||
void pollComfyHealth()
|
||
void refreshStudioQueue()
|
||
} catch (error: any) {
|
||
const message = error?.data?.statusMessage || error?.statusMessage || error?.message || 'Failed to start generation'
|
||
if (!keepLiveOutput) {
|
||
videoBusy.value = false
|
||
statusBusy.value = false
|
||
stopTimer('video')
|
||
clearActiveJob('video')
|
||
statusMessage.value = message
|
||
}
|
||
toast(message)
|
||
}
|
||
}
|
||
|
||
function stopListen(kind?: 'video' | 'edit') {
|
||
if (!kind || kind === 'video') {
|
||
videoSource?.close()
|
||
videoSource = null
|
||
if (videoPollTimer) {
|
||
clearInterval(videoPollTimer)
|
||
videoPollTimer = null
|
||
}
|
||
}
|
||
if (!kind || kind === 'edit') {
|
||
editSource?.close()
|
||
editSource = null
|
||
if (editPollTimer) {
|
||
clearInterval(editPollTimer)
|
||
editPollTimer = null
|
||
}
|
||
}
|
||
}
|
||
|
||
function applyEditEvent(payload: Record<string, any>, hidden: boolean, folderLocked: boolean) {
|
||
if (payload.message) editStatusMessage.value = payload.message
|
||
editStatusBusy.value = payload.type === 'busy' || payload.busy === true
|
||
if (typeof payload.progress === 'number') editProgress.value = payload.progress
|
||
if (typeof payload.step === 'number') editStep.value = payload.step
|
||
if (typeof payload.maxStep === 'number') editMaxStep.value = payload.maxStep
|
||
if (typeof payload.chainStep === 'number') editChainStep.value = payload.chainStep
|
||
if (typeof payload.chainTotal === 'number') editChainTotal.value = payload.chainTotal
|
||
if (payload.chainLabel) editChainLabel.value = String(payload.chainLabel)
|
||
if (typeof payload.overallProgress === 'number') editOverallProgress.value = payload.overallProgress
|
||
|
||
const failed = payload.type === 'error' || payload.status === 'error' || payload.status === 'cancelled'
|
||
const complete = payload.type === 'complete' || payload.status === 'complete'
|
||
if (editSettledUi) return
|
||
|
||
const dest = folders.value.find(folder => folder.id === folderId.value)
|
||
const locked = dest
|
||
? Boolean(dest.protected && !dest.unlocked)
|
||
: (folderLocked || payload.folderLocked === true)
|
||
|
||
if (payload.stillId && !locked) {
|
||
editResultUrl.value = `/api/library/stills/${payload.stillId}`
|
||
if (studioMode.value === 'editv2' || v2JobPending.value) lastV2StillId.value = payload.stillId
|
||
editLockedSave.value = false
|
||
if (payload.hideThumbnail || hidden) {
|
||
concealEditOutput.value = true
|
||
if (payload.type !== 'checkpoint') editAwaitingReveal.value = true
|
||
}
|
||
}
|
||
|
||
if (payload.type === 'checkpoint' && payload.stillId) {
|
||
editCompletedChainStep.value = Math.max(editCompletedChainStep.value, Number(payload.chainStep || editChainStep.value) || 0)
|
||
scheduleLibraryRefresh()
|
||
return
|
||
}
|
||
|
||
if (complete && payload.stillId) {
|
||
const total = Math.max(1, Number(payload.chainTotal || 0) || 0, Number(editChainTotal.value || 0) || 0)
|
||
const step = Math.max(1, Number(payload.chainStep || 0) || 0, Number(editChainStep.value || 0) || 0)
|
||
if (total > 1 && step < total) {
|
||
editCompletedChainStep.value = Math.max(editCompletedChainStep.value, step)
|
||
scheduleLibraryRefresh()
|
||
if (locked) {
|
||
editResultUrl.value = ''
|
||
editLockedSave.value = true
|
||
}
|
||
editStatusMessage.value = `Pass ${step} of ${total} saved, but the remaining passes did not start.`
|
||
editProgress.value = Math.min(Number(payload.progress || editProgress.value || 99), 99)
|
||
toast(`Pass ${step} of ${total} saved. Remaining passes did not run.`)
|
||
editBusy.value = false
|
||
editStatusBusy.value = false
|
||
stopTimer('edit')
|
||
stopListen('edit')
|
||
clearActiveJob('edit')
|
||
void pollComfyHealth()
|
||
editSettledUi = true
|
||
return
|
||
}
|
||
editSettledUi = true
|
||
if (locked) {
|
||
editResultUrl.value = ''
|
||
editLockedSave.value = true
|
||
browseFolderId.value = folderId.value
|
||
editStatusMessage.value = 'Saved to the locked folder. Unlock it to view.'
|
||
} else {
|
||
editResultUrl.value = `/api/library/stills/${payload.stillId}`
|
||
if (studioMode.value === 'editv2' || v2JobPending.value) lastV2StillId.value = payload.stillId
|
||
concealEditOutput.value = Boolean(payload.hideThumbnail || hidden)
|
||
editAwaitingReveal.value = concealEditOutput.value
|
||
editLockedSave.value = false
|
||
editStatusMessage.value = total > 1 ? 'Edit chain finished' : 'Edit finished'
|
||
editCompletedChainStep.value = total
|
||
}
|
||
editBusy.value = false
|
||
editStatusBusy.value = false
|
||
v2JobPending.value = false
|
||
stopTimer('edit')
|
||
stopListen('edit')
|
||
clearActiveJob('edit')
|
||
scheduleLibraryRefresh()
|
||
void pollComfyHealth()
|
||
void refreshStudioQueue()
|
||
void notifyGenerationComplete('image')
|
||
return
|
||
}
|
||
|
||
if (failed) {
|
||
editSettledUi = true
|
||
editBusy.value = false
|
||
v2JobPending.value = false
|
||
editStatusBusy.value = false
|
||
stopTimer('edit')
|
||
stopListen('edit')
|
||
clearActiveJob('edit')
|
||
scheduleLibraryRefresh()
|
||
void pollComfyHealth()
|
||
toast(payload.error || payload.message || 'Image edit failed')
|
||
}
|
||
}
|
||
|
||
function keepPlayerOnCurrentView(incomingClipId?: string) {
|
||
if (extendOpen.value) return true
|
||
if (watchingLibrary.value && currentClipId.value && incomingClipId && incomingClipId !== currentClipId.value) return true
|
||
return false
|
||
}
|
||
|
||
function applyVideoEvent(payload: Record<string, any>, hidden: boolean, folderLocked: boolean) {
|
||
if (payload.queueId) persistActiveQueue(String(payload.queueId))
|
||
if (payload.message) statusMessage.value = payload.message
|
||
statusBusy.value = payload.type === 'busy' || payload.busy === true
|
||
if (typeof payload.progress === 'number') progress.value = payload.progress
|
||
if (typeof payload.step === 'number') step.value = payload.step
|
||
if (typeof payload.maxStep === 'number') maxStep.value = payload.maxStep
|
||
if (typeof payload.chainStep === 'number') chainStep.value = payload.chainStep
|
||
if (typeof payload.chainTotal === 'number') chainTotal.value = payload.chainTotal
|
||
if (payload.chainLabel) chainLabel.value = String(payload.chainLabel)
|
||
if (typeof payload.overallProgress === 'number') overallProgress.value = payload.overallProgress
|
||
|
||
const failed = payload.type === 'error' || payload.status === 'error' || payload.status === 'cancelled'
|
||
const complete = payload.type === 'complete' || payload.status === 'complete'
|
||
const deferred = payload.type === 'deferred' || payload.status === 'deferred'
|
||
if (videoSettledUi) return
|
||
|
||
if (payload.type === 'checkpoint' && payload.clipId) {
|
||
completedChainStep.value = Math.max(completedChainStep.value, Number(payload.chainStep || chainStep.value) || 0)
|
||
scheduleLibraryRefresh()
|
||
void refreshLiveQueue()
|
||
const locked = folderLocked || payload.folderLocked === true
|
||
if (!keepPlayerOnCurrentView(payload.clipId) && !locked) {
|
||
currentClipId.value = payload.clipId
|
||
videoUrl.value = clipVideoUrl(payload.clipId)
|
||
concealOutput.value = hidden || payload.hideThumbnail === true
|
||
}
|
||
return
|
||
}
|
||
|
||
if (deferred) {
|
||
videoSettledUi = true
|
||
videoBusy.value = false
|
||
statusBusy.value = false
|
||
stopTimer('video')
|
||
stopListen('video')
|
||
clearActiveJob('video')
|
||
scheduleLibraryRefresh()
|
||
void pollComfyHealth()
|
||
void refreshStudioQueue()
|
||
toast(payload.message || 'Comfy was still busy. This job was saved under Held jobs.')
|
||
return
|
||
}
|
||
|
||
if (complete && (payload.clipId || payload.filename)) {
|
||
const total = Math.max(1, Number(payload.chainTotal || 0) || 0, Number(chainTotal.value || 0) || 0)
|
||
const step = Math.max(1, Number(payload.chainStep || 0) || 0, Number(chainStep.value || 0) || 0)
|
||
const remaining = Number(payload.queuedRemaining || 0)
|
||
if (remaining > 0 || (total > 1 && step < total)) {
|
||
completedChainStep.value = Math.max(completedChainStep.value, step)
|
||
scheduleLibraryRefresh()
|
||
const dest = folders.value.find(folder => folder.id === folderId.value)
|
||
const locked = dest
|
||
? Boolean(dest.protected && !dest.unlocked)
|
||
: (folderLocked || payload.folderLocked === true)
|
||
if (!keepPlayerOnCurrentView(payload.clipId) && !locked && payload.clipId) {
|
||
currentClipId.value = payload.clipId
|
||
videoUrl.value = clipVideoUrl(payload.clipId)
|
||
concealOutput.value = hidden || payload.hideThumbnail === true
|
||
}
|
||
const left = remaining || Math.max(0, total - step)
|
||
statusMessage.value = `Shot ${step} of ${total} saved. ${left} waiting on Queue.`
|
||
progress.value = Number(payload.progress || progress.value || 100)
|
||
toast(`Shot ${step} of ${total} saved. ${left} shot${left === 1 ? '' : 's'} waiting on Queue — edit prompts or process next.`)
|
||
queuePendingCount.value = left
|
||
void refreshLiveQueue()
|
||
videoBusy.value = false
|
||
statusBusy.value = false
|
||
stopTimer('video')
|
||
stopListen('video')
|
||
clearActiveJob('video')
|
||
activeDraftId = ''
|
||
void pollComfyHealth()
|
||
void refreshStudioQueue()
|
||
videoSettledUi = true
|
||
return
|
||
}
|
||
videoSettledUi = true
|
||
const dest = folders.value.find(folder => folder.id === folderId.value)
|
||
const locked = dest
|
||
? Boolean(dest.protected && !dest.unlocked)
|
||
: (folderLocked || payload.folderLocked === true)
|
||
if (keepPlayerOnCurrentView(payload.clipId)) {
|
||
videoBusy.value = false
|
||
statusBusy.value = false
|
||
stopTimer('video')
|
||
stopListen('video')
|
||
clearActiveJob('video')
|
||
activeDraftId = ''
|
||
scheduleLibraryRefresh()
|
||
void pollComfyHealth()
|
||
void refreshStudioQueue()
|
||
statusMessage.value = currentClip.value?.hideThumbnail ? 'Hidden clip ready' : 'Loaded from library'
|
||
toast(locked
|
||
? 'Saved to the locked folder. Unlock it to view.'
|
||
: 'New clip saved to the library')
|
||
void notifyGenerationComplete('video')
|
||
return
|
||
}
|
||
if (locked) {
|
||
videoUrl.value = ''
|
||
awaitingReveal.value = false
|
||
concealOutput.value = false
|
||
lockedSave.value = true
|
||
browseFolderId.value = folderId.value
|
||
statusMessage.value = 'Saved to the locked folder. Unlock it to view.'
|
||
currentClipId.value = payload.clipId || currentClipId.value
|
||
} else if (payload.clipId) {
|
||
currentClipId.value = payload.clipId
|
||
videoUrl.value = clipVideoUrl(payload.clipId)
|
||
awaitingReveal.value = Boolean(payload.hideThumbnail || hidden)
|
||
concealOutput.value = awaitingReveal.value
|
||
lockedSave.value = false
|
||
} else {
|
||
videoUrl.value = `/api/media?filename=${encodeURIComponent(payload.filename)}&subfolder=${encodeURIComponent(payload.subfolder || 'video')}&type=${encodeURIComponent(payload.mediaType || 'output')}&t=${Date.now()}`
|
||
awaitingReveal.value = Boolean(payload.hideThumbnail || hidden)
|
||
concealOutput.value = awaitingReveal.value
|
||
lockedSave.value = false
|
||
}
|
||
videoBusy.value = false
|
||
statusBusy.value = false
|
||
stopTimer('video')
|
||
stopListen('video')
|
||
clearActiveJob('video')
|
||
activeDraftId = ''
|
||
scheduleLibraryRefresh()
|
||
void pollComfyHealth()
|
||
void refreshStudioQueue()
|
||
void notifyGenerationComplete('video')
|
||
return
|
||
}
|
||
|
||
if (failed) {
|
||
videoSettledUi = true
|
||
videoBusy.value = false
|
||
statusBusy.value = false
|
||
stopTimer('video')
|
||
stopListen('video')
|
||
clearActiveJob('video')
|
||
scheduleLibraryRefresh()
|
||
void pollComfyHealth()
|
||
void refreshStudioQueue()
|
||
toast(payload.error || payload.message || 'Generation failed')
|
||
}
|
||
}
|
||
|
||
function applyJobEvent(kind: 'video' | 'edit', payload: Record<string, any>, hidden: boolean, folderLocked: boolean, expectedJobId: string) {
|
||
const currentId = kind === 'edit' ? editJobId.value : videoJobId.value
|
||
if (expectedJobId && currentId && expectedJobId !== currentId) return
|
||
if (payload.jobId && expectedJobId && payload.jobId !== expectedJobId) return
|
||
if (kind === 'edit') applyEditEvent(payload, hidden, folderLocked)
|
||
else applyVideoEvent(payload, hidden, folderLocked)
|
||
}
|
||
|
||
function listen(kind: 'video' | 'edit', id: string, hidden: boolean, folderLocked = false) {
|
||
stopListen(kind)
|
||
const isEdit = kind === 'edit'
|
||
const gen = isEdit ? ++editListenGen : ++videoListenGen
|
||
const source = new EventSource(`/api/generate/${id}/stream`)
|
||
if (isEdit) editSource = source
|
||
else videoSource = source
|
||
source.onmessage = (event) => {
|
||
if (gen !== (isEdit ? editListenGen : videoListenGen)) return
|
||
applyJobEvent(kind, JSON.parse(event.data), hidden, folderLocked, id)
|
||
}
|
||
source.onerror = () => {
|
||
if (gen !== (isEdit ? editListenGen : videoListenGen)) return
|
||
source.close()
|
||
setTimeout(() => {
|
||
if (gen !== (isEdit ? editListenGen : videoListenGen)) return
|
||
if (isEdit ? editSettledUi : videoSettledUi) return
|
||
listen(kind, id, hidden, folderLocked)
|
||
}, 2000)
|
||
}
|
||
let misses = 0
|
||
const tick = async () => {
|
||
const currentGen = isEdit ? editListenGen : videoListenGen
|
||
const settled = isEdit ? editSettledUi : videoSettledUi
|
||
if (gen !== currentGen || settled) {
|
||
if (gen === currentGen && settled) stopListen(kind)
|
||
return
|
||
}
|
||
try {
|
||
const snap = await $fetch<Record<string, any>>(`/api/generate/${id}`)
|
||
misses = 0
|
||
if (gen !== (isEdit ? editListenGen : videoListenGen)) return
|
||
applyJobEvent(kind, snap, hidden, folderLocked, id)
|
||
} catch {
|
||
if (gen !== (isEdit ? editListenGen : videoListenGen)) return
|
||
try {
|
||
const recovered = await $fetch<Record<string, any>>('/api/generate/recover', {
|
||
method: 'POST',
|
||
body: { jobId: id }
|
||
})
|
||
misses = 0
|
||
if (gen !== (isEdit ? editListenGen : videoListenGen)) return
|
||
applyJobEvent(kind, recovered, hidden, folderLocked, id)
|
||
} catch {
|
||
misses += 1
|
||
if (misses >= 20) {
|
||
applyJobEvent(kind, {
|
||
type: 'error',
|
||
status: 'error',
|
||
error: 'Lost the running job after refresh. Check the library, or generate again.',
|
||
message: 'Lost the running job after refresh. Check the library, or generate again.'
|
||
}, hidden, folderLocked, id)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
void tick()
|
||
const poll = setInterval(() => {
|
||
if (typeof document !== 'undefined' && document.hidden) return
|
||
void tick()
|
||
}, 4000)
|
||
if (isEdit) editPollTimer = poll
|
||
else videoPollTimer = poll
|
||
}
|
||
|
||
async function cancelJob() {
|
||
const kind = outputProgressKind.value === 'edit' && editBusy.value
|
||
? 'edit'
|
||
: videoBusy.value
|
||
? 'video'
|
||
: studioMode.value === 'edit' ? 'edit' : 'video'
|
||
const id = kind === 'edit' ? editJobId.value : videoJobId.value
|
||
const studioId = outputStudioJobId.value
|
||
try {
|
||
await $fetch('/api/interrupt', { method: 'POST', body: { jobId: id, kind } })
|
||
if (studioId) {
|
||
await $fetch(`/api/studio-queue/${studioId}`, { method: 'DELETE' }).catch(() => null)
|
||
}
|
||
if (kind === 'edit') editStatusMessage.value = 'Edit cancelled. GPU is idle — edit again.'
|
||
else statusMessage.value = 'Job cancelled. GPU is idle — generate again.'
|
||
} catch (error: any) {
|
||
toast(error?.statusMessage || error?.data?.statusMessage || 'Failed to interrupt the job')
|
||
if (kind === 'edit') editStatusMessage.value = 'Cancelled locally. Edit again when the GPU is free.'
|
||
else statusMessage.value = 'Cancelled locally. Generate again when the GPU is free.'
|
||
}
|
||
if (kind === 'edit') {
|
||
editSettledUi = true
|
||
editBusy.value = false
|
||
editStatusBusy.value = false
|
||
stopTimer('edit')
|
||
stopListen('edit')
|
||
clearActiveJob('edit')
|
||
} else {
|
||
videoSettledUi = true
|
||
videoBusy.value = false
|
||
statusBusy.value = false
|
||
stopTimer('video')
|
||
stopListen('video')
|
||
clearActiveJob('video')
|
||
outputStudioJobId.value = ''
|
||
activeQueueId.value = ''
|
||
try { localStorage.removeItem(QUEUE_STORE) } catch { /* ignore */ }
|
||
}
|
||
}
|
||
</script>
|