Files
aigen/pages/index.vue
T

8149 lines
346 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="min-h-dvh min-w-0">
<AppHeader
:subtitle="advanced ? (ltxEnabled ? 'MiniMax H3 · LTX-2.3 · Flux.2 Klein' : 'MiniMax H3 · Flux.2 Klein') : 'Create images, video, and music'"
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
:advanced="advanced"
: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="flex flex-wrap items-center justify-between gap-3">
<div v-if="!advanced" class="flex flex-wrap gap-2" role="group" aria-label="Generation task">
<button v-for="task in ['From text', 'From image', 'Edit', 'Extend']" :key="task" type="button" class="rounded-full border px-4 py-2 text-sm" :class="simpleTask === task ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/15 text-zinc-300'" :aria-pressed="simpleTask === task" @click="chooseSimpleTask(task)">{{ task }}</button>
</div>
<label class="ml-auto flex items-center gap-2 text-sm text-zinc-300"><input v-model="advanced" type="checkbox" class="accent-amber-400">Advanced mode</label>
</div>
<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' && (advanced || simpleTask === 'Edit')" 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="fluxTextReference || 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">{{ fluxTextReference ? 'Reference photo · optional' : 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">
{{ fluxTextReference ? 'Choose a reference photo from your library or upload one' : studioMode === 'editv2' ? stillBHint : 'Apply this still onto image 1' }}
</span>
</button>
<p class="mt-2 text-xs text-zinc-500">
{{ fluxTextReference ? 'Guides a new image from your text. This is not a starting image; your selected output size stays in effect. Describe which details to use from the photo.' : 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="advanced && 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
:simple="!advanced"
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="!advanced" class="space-y-4">
<AspectRatioPick v-if="imageStudio" :model-value="aspect" heading="Image size" :options="imageAspects" @update:model-value="setImageAspect" />
<template v-else><DurationField v-model="duration" label="Duration" /><AspectRatioPick v-model="aspect" heading="Video size" :options="videoAspects" /></template>
</div>
<GenerationSettings :advanced="advanced">
<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>
<label class="block space-y-2 text-sm">
<span>Model</span>
<select v-if="imageStudio" :value="v2Engine" class="w-full rounded-xl border border-white/15 bg-zinc-900 p-3" @change="setV2Engine(($event.target as HTMLSelectElement).value as ImageV2Engine)"><option value="flux">Flux Klein 9B</option><option value="krea">Krea 2 Turbo</option></select>
<select v-else :value="videoEngine" class="w-full rounded-xl border border-white/15 bg-zinc-900 p-3" @change="selectVideoEngine(($event.target as HTMLSelectElement).value as VideoEngineId)"><option value="minimax">MiniMax H3</option><option v-if="ltxEnabled" value="ltx">LTX 2.3</option></select>
</label>
<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>
<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 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 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>
</GenerationSettings>
<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 class="sticky bottom-3 z-10 flex flex-wrap gap-3 rounded-2xl bg-zinc-950/95 p-2 shadow-lg">
<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())"
>
{{ !advanced ? 'Queue' : 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">
<VideoUpscaleControl v-if="videoUrl && !lockedSave && (currentClipId || clipIdFromPlayer())" :clip-id="currentClipId || clipIdFromPlayer()" @complete="loadLibrary()" />
<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 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">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"
title="Replace only the input image; keep the current prompt and settings"
@click="useEditAsInput"
>
Use as input still
</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 disabled:opacity-50"
:disabled="usingReferenceBusy"
title="Replace only the reference photo; keep the input image, prompt, and settings"
@click="useOutputAsFluxReference"
>
{{ usingReferenceBusy ? 'Loading reference…' : 'Use as reference' }}
</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 disabled:opacity-50"
title="Replace the input image, prompt, and generation settings with this image's saved setup"
:disabled="usingInputSettingsBusy"
@click="useOutputWithSettings"
>
{{ usingInputSettingsBusy ? 'Loading settings…' : 'Use as input' }}
</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()
const { advanced } = useStudioLayout()
const editingTask = ref(false)
const simpleTask = computed(() => extendOpen.value ? 'Extend' : editingTask.value || (imageStudio.value && ['compose', 'refine'].includes(v2Mode.value)) ? 'Edit' : file.value ? 'From image' : 'From text')
function chooseSimpleTask(task: string) {
if (task === 'Extend') {
selectStudioKind('video')
void openExtend()
return
}
closeExtend()
editingTask.value = task === 'Edit'
if (task === 'Edit') selectStudioKind('image')
if (task === 'From text') {
resetImage()
if (imageStudio.value) v2Mode.value = editPassQueue.value.length ? 'iterate' : 'generate'
} else {
if (imageStudio.value && !editPassQueue.value.length) v2Mode.value = 'edit'
if (!file.value) openPicker('main')
}
}
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 fluxTextReference = computed(() => studioMode.value === 'editv2' && v2Engine.value === 'flux' && (v2Mode.value === 'generate' || (v2Mode.value === 'iterate' && !file.value)))
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(true)
const refineExtensionFrame = ref(true)
const saveLosslessAnchor = ref(true)
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 fluxTextReference.value ? 'Choose a reference photo' : 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 && ['compose', 'generate', 'iterate'].includes(payload.v2Mode || '') && 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 !== false
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
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 = settings.width && settings.width > 0 ? settings.width : size.width
v2Height.value = settings.height && settings.height > 0 ? settings.height : size.height
}
if (mode === 'compose' && settings.task) v2Task.value = settings.task
imageV2LoraStack.value = mergeLegacyImageV2Loras(
readLoraStack(settings),
{
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)
editNegative.value = settings.negative || ''
editScaleToTotalPixels.value = settings.scaleToTotalPixels === true
if (settings.scaleMegapixels != null) editScaleMegapixels.value = clampImageScaleMegapixels(settings.scaleMegapixels)
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' && ['compose', 'generate', 'iterate'].includes(still.settings?.mode || '') && 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 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 ((fluxTextReference.value || 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()
}
}
const usingReferenceBusy = ref(false)
async function useOutputAsFluxReference() {
if (!editResultUrl.value || usingReferenceBusy.value) return
usingReferenceBusy.value = true
try {
const blob = await $fetch<Blob>(editResultUrl.value, { responseType: 'blob' })
if (!blob?.size) throw new Error('Empty image')
const reference = new File([blob], `${editDownloadName.value || 'reference'}.png`, { type: blob.type || 'image/png' })
setEditRef(reference)
scrollToStudioInput()
toast('Reference photo updated. Your input image, prompt, and settings were kept.')
} catch {
toast('Could not load that image as a reference')
} finally {
usingReferenceBusy.value = false
}
}
const usingInputSettingsBusy = ref(false)
async function useOutputWithSettings() {
if (!editResultUrl.value || usingInputSettingsBusy.value) return
usingInputSettingsBusy.value = true
const stillId = stillIdFromLibraryUrl(editResultUrl.value) || lastV2StillId.value
try {
if (!stillId) throw new Error('This image has no saved settings yet.')
if (!stills.value.some(item => item.id === stillId)) await loadLibrary()
const still = stills.value.find(item => item.id === stillId)
if (!still) throw new Error('Saved settings for this image are unavailable.')
await useStillAsInput(still)
} catch (error: any) {
toast(error?.message || 'Could not load this image and its settings')
} finally {
usingInputSettingsBusy.value = false
}
}
async function useEditAsInput() {
if (!editResultUrl.value) return
const stillId = stillIdFromLibraryUrl(editResultUrl.value) || lastV2StillId.value
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, keepThumbnailPref: true
})
scrollToStudioInput()
toast('Input image updated. Your prompt and settings were kept.')
} 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>