Files
aigen/pages/index.vue
T

6459 lines
274 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">
<header class="sticky top-0 z-20 border-b border-white/5 bg-zinc-950/70 backdrop-blur-xl">
<div class="mx-auto flex max-w-7xl items-center justify-between px-4 py-4 sm:px-6">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-amber-400 text-zinc-950 font-display font-extrabold">A</div>
<div>
<h1 class="font-display text-xl font-bold leading-none">{{ instanceName }}</h1>
<p class="hidden text-[11px] uppercase tracking-[0.22em] text-zinc-500 sm:block">{{ ltxEnabled ? 'MiniMax H3 · LTX-2.3 · Flux.2 Klein' : 'MiniMax H3 · Flux.2 Klein' }}</p>
</div>
</div>
<div class="flex items-center gap-3 text-sm">
<div class="flex items-center gap-2">
<span
class="hidden items-center gap-2 rounded-full border px-3 py-1 sm:inline-flex"
:class="rigBadge.className"
:title="rigBadge.title"
>
<span class="h-2 w-2 rounded-full" :class="rigBadge.dot" />
{{ rigBadge.label }}
</span>
<button
v-if="showWakeGpu"
type="button"
class="rounded-full border px-3 py-1 hover:border-amber-300/50 hover:text-white disabled:cursor-wait disabled:opacity-80"
:class="gpuWaking ? 'border-amber-300/40 text-amber-100' : 'border-amber-400/40 text-amber-200'"
:disabled="gpuWaking"
:title="gpuWakeError || (gpuWaking ? 'Starting Comfy Desktop on the GPU' : 'Poke / Wake GPU — start Comfy before generating')"
@click="pokeGpu"
>
{{ gpuWaking ? 'Starting GPU…' : 'Wake GPU' }}
</button>
</div>
<span class="hidden md:block text-zinc-400">{{ userName }}</span>
<button type="button" class="rounded-full border border-white/10 px-3 py-1 text-zinc-300 hover:border-amber-300/50 hover:text-white" @click="settingsOpen = true">
Settings
</button>
<NuxtLink
to="/director"
class="rounded-full border border-white/10 px-3 py-1 text-zinc-300 hover:border-amber-300/50 hover:text-white"
>
Director
</NuxtLink>
<NuxtLink
to="/queue"
class="rounded-full border px-3 py-1 text-zinc-300 hover:border-amber-300/50 hover:text-white"
:class="queueBadgeCount ? 'border-amber-300/40 text-amber-100' : 'border-white/10'"
>
Queue<span v-if="queueBadgeCount" class="ml-1.5 text-amber-200">{{ queueBadgeCount }}</span>
</NuxtLink>
<a v-if="authEnabled" href="/api/auth/logout" class="text-zinc-400 hover:text-white">Sign out</a>
</div>
</div>
</header>
<main class="mx-auto grid min-w-0 max-w-7xl gap-6 px-4 py-6 sm:px-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<section id="studio-input" class="glass min-w-0 max-w-full rounded-3xl p-5 sm:p-6 space-y-5">
<div>
<div class="mb-3 flex flex-wrap gap-2">
<button
type="button"
class="rounded-full border px-3 py-1 text-xs font-medium"
:class="studioMode === 'video' ? 'border-amber-300/70 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400 hover:text-white'"
@click="studioMode = 'video'; outputFocus = 'video'"
>
Video
</button>
<button
type="button"
class="rounded-full border px-3 py-1 text-xs font-medium"
:class="studioMode === 'edit' ? 'border-amber-300/70 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400 hover:text-white'"
@click="studioMode = 'edit'; outputFocus = 'edit'"
>
Image edit
</button>
<button
type="button"
class="rounded-full border px-3 py-1 text-xs font-medium"
:class="studioMode === 'editv2' ? 'border-amber-300/70 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400 hover:text-white'"
@click="studioMode = 'editv2'; outputFocus = 'edit'"
>
Image v2
</button>
</div>
<h2 class="font-display text-2xl font-bold">Input</h2>
<p class="text-sm text-zinc-400">{{ studioMode === 'editv2'
? (v2Mode === 'generate'
? 'Generate: no reference image. Prompt only.'
: 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.'
: 'Drop a still, describe the motion, then send the job. Generate stays available — a long shot list takes one queue slot, and you can line up more while it runs.' }}</p>
</div>
<div
v-if="!(studioMode === 'editv2' && v2Mode === 'generate')"
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-if="studioMode === 'editv2'" class="flex flex-wrap gap-2">
<button
type="button"
class="rounded-full border px-3 py-1 text-xs font-medium"
:class="v2Mode === 'edit' ? 'border-amber-300/70 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400 hover:text-white'"
@click="setV2Mode('edit')"
>
Edit
</button>
<button
type="button"
class="rounded-full border px-3 py-1 text-xs font-medium"
:class="v2Mode === 'compose' ? 'border-amber-300/70 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400 hover:text-white'"
@click="setV2Mode('compose')"
>
Compose
</button>
<button
type="button"
class="rounded-full border px-3 py-1 text-xs font-medium"
:class="v2Mode === 'refine' ? 'border-amber-300/70 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400 hover:text-white'"
@click="setV2Mode('refine')"
>
Refine
</button>
<button
type="button"
class="rounded-full border px-3 py-1 text-xs font-medium"
:class="v2Mode === 'generate' ? 'border-amber-300/70 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400 hover:text-white'"
@click="setV2Mode('generate')"
>
Generate
</button>
<label v-if="v2Mode === 'compose'" class="ml-auto text-xs">
<span class="mr-2 text-zinc-500">Task</span>
<select
v-model="v2Task"
class="rounded-lg border border-white/10 bg-zinc-950 px-2 py-1 text-xs text-zinc-300 outline-none ring-amber-300/40 focus:ring-2"
>
<option value="scene">scene</option>
<option value="identity">identity</option>
<option value="outfit">outfit</option>
<option value="face_lock">face_lock</option>
</select>
</label>
</div>
<div v-if="studioMode === 'edit' || (studioMode === 'editv2' && v2Mode === 'compose')" class="rounded-2xl border border-dashed border-white/15 bg-zinc-950/50 p-4">
<div class="mb-2 flex items-center justify-between gap-2">
<p class="text-sm font-medium text-zinc-300">{{ studioMode === 'editv2' ? 'Still B · required' : 'Image 2 · optional' }}</p>
<button
v-if="editRefFile"
type="button"
class="text-xs text-zinc-400 hover:text-white"
@click="clearEditRef"
>
Remove
</button>
</div>
<button
type="button"
class="relative block w-full overflow-hidden rounded-xl border border-white/10 bg-zinc-950/60 text-left"
@click="openPicker('editRef')"
>
<span
v-if="editRefPreview && hideInputPreview"
class="relative flex min-h-28 items-center justify-center px-3 py-6 text-center text-sm text-zinc-300"
>
<span class="sensitive-glow absolute inset-1 rounded-lg" />
<span class="relative">Image 2 loaded · preview hidden</span>
</span>
<img
v-else-if="editRefPreview"
:src="editRefPreview"
alt="Reference still"
class="max-h-40 w-full object-contain bg-black/40"
>
<span v-else class="flex min-h-28 items-center justify-center px-3 py-6 text-center text-sm text-zinc-500">
{{ studioMode === 'editv2' ? stillBHint : 'Apply this still onto image 1' }}
</span>
</button>
<p class="mt-2 text-xs text-zinc-500">
{{ studioMode === 'editv2'
? (editRefFile ? stillBHint : 'Compose will reject this job until still B is loaded.')
: (editRefFile
? 'Two-image branch on. Image 2 is applied onto image 1.'
: 'One-image branch on. Add a second still to transfer details from image 2.') }}
</p>
</div>
<div>
<div class="mb-2 flex flex-wrap items-center justify-between gap-2">
<label class="text-sm font-medium text-zinc-300">{{ studioMode === 'editv2' && v2Mode === 'generate' ? 'Generate prompt' : studioMode === 'editv2' && v2Mode === '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 promptPresets" :key="item.id" :value="item.id">{{ item.name }}</option>
</select>
<button
type="button"
class="rounded-full border px-3 py-1 text-xs font-medium disabled:cursor-not-allowed disabled:opacity-50"
:class="recommendBusy ? 'border-amber-300/40 text-amber-100' : 'border-amber-300/40 text-amber-100 hover:bg-amber-400/10'"
:disabled="recommendDisabled"
:title="recommendHint"
@click="recommendPrompt"
>
{{ recommendBusy ? 'Recommending…' : 'Recommend' }}
</button>
<label v-if="studioMode === 'video'" class="flex cursor-pointer items-center gap-2 text-xs text-zinc-400">
<span class="relative inline-flex h-5 w-9 shrink-0 items-center">
<input v-model="shotScriptMode" type="checkbox" class="peer sr-only" role="switch">
<span class="absolute inset-0 rounded-full bg-zinc-700 transition peer-checked:bg-amber-400" />
<span class="absolute left-0.5 top-0.5 h-4 w-4 rounded-full bg-white transition peer-checked:translate-x-4" />
</span>
Shot script
</label>
</div>
</div>
<DirectorScriptBar
v-if="studioMode === 'video'"
class="mb-2"
:scripts="directorScripts"
v-model:selected-id="directorScriptId"
v-model:name="directorScriptName"
:disabled="videoBusy"
@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. Every shot uses the duration slider. 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">
<textarea
v-model="prompt"
:rows="shotScriptMode || identityPrompting ? 12 : 6"
class="hidden max-h-[min(28rem,70vh)] w-full overflow-y-auto 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:block"
:placeholder="promptPlaceholder"
/>
<button
type="button"
class="w-full rounded-2xl border border-white/10 bg-zinc-950/70 px-4 py-3 text-left md:hidden"
@click="promptDrawerOpen = true"
>
<p v-if="prompt.trim()" class="line-clamp-4 whitespace-pre-wrap text-sm text-zinc-200 [overflow-wrap:anywhere]">{{ prompt }}</p>
<p v-else class="text-sm text-zinc-600">{{ promptPlaceholder }}</p>
<span class="mt-2 inline-block text-xs text-amber-200">{{ prompt.trim() ? 'View / edit prompt' : 'Write prompt' }}</span>
</button>
</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>
<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'" class="mt-3 space-y-3">
<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">Pass {{ index + 2 }}</p>
<button
type="button"
class="text-xs text-red-300 hover:text-red-200"
:disabled="editBusy"
@click="removeEditPass(item.id)"
>
Remove
</button>
</div>
<textarea
v-model="item.prompt"
rows="3"
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
placeholder="Next edit prompt, applied to the previous pass output…"
/>
</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"
:disabled="editBusy"
@click="queueEditPass"
>
+ Pass
</button>
</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>
<div v-if="studioMode === 'video'" class="mt-3 space-y-3">
<GlobalLocksEditor v-model="globalLocks" />
<div class="rounded-xl border border-white/10 bg-zinc-950/40 px-3 py-2.5">
<PermanenceRefStrip
:refs="familyPermanenceRefs"
:name-options="lockBindingKeys"
label="Permanence refs · all shots"
hint="Name + still"
cta="Add permanence refs"
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 will not see these photos. Continuity is the last frame of the previous clip. The stills only name the lock lines.
</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>
<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"
label="LoRA"
empty-label="Default"
add-label="Add LoRA"
compact
:clip-strength="false"
@update:model-value="setShotLoraStack(shot.n, $event)"
/>
<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"
:disabled="videoBusy"
@click="removeQueuedExtension(item.id)"
>
Remove
</button>
</div>
<textarea
v-model="item.prompt"
rows="3"
class="max-h-32 w-full overflow-y-auto rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 [overflow-wrap:anywhere] focus:ring-2 md:max-h-none"
placeholder="Motion prompt for this extension…"
/>
<DurationField v-model="item.duration" class="mt-3" label="Duration" />
<LoraSelect
class="mt-3"
:model-value="item.loraStack || []"
:names="videoLoraOptions"
label="LoRA"
empty-label="Default"
add-label="Add LoRA"
compact
:clip-strength="false"
@update:model-value="item.loraStack = $event"
/>
<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"
:disabled="videoBusy"
@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="Shown in the library and used for the download filename"
maxlength="80"
>
</label>
<form v-if="currentFolder?.protected && !currentFolder.unlocked" class="space-y-2" @submit.prevent="unlockFolder(folderId)">
<p class="text-sm text-zinc-400">This folder is locked. You can still generate into it; unlock to view clips.</p>
<div class="flex gap-2">
<input v-model="unlockPassword" type="password" class="min-w-0 flex-1 rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm" placeholder="Folder password">
<button type="submit" class="rounded-xl bg-amber-400 px-3 py-2 text-sm font-semibold text-zinc-950">Unlock</button>
</div>
</form>
<label v-if="showPrivacyToggles" class="flex cursor-pointer items-center justify-between gap-3 rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3 text-sm">
<span class="font-medium text-zinc-200">Hide input still</span>
<span class="relative inline-flex h-6 w-11 shrink-0 items-center">
<input v-model="hideInputPreview" type="checkbox" class="peer sr-only" role="switch">
<span class="absolute inset-0 rounded-full bg-zinc-700 transition peer-checked:bg-amber-400" />
<span class="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition peer-checked:translate-x-5" />
</span>
</label>
<label v-if="showPrivacyToggles" class="flex cursor-pointer items-center justify-between gap-3 rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3 text-sm">
<span class="font-medium text-zinc-200">Hide output preview</span>
<span class="relative inline-flex h-6 w-11 shrink-0 items-center">
<input v-model="hideThumbnail" type="checkbox" class="peer sr-only" role="switch">
<span class="absolute inset-0 rounded-full bg-zinc-700 transition peer-checked:bg-amber-400" />
<span class="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition peer-checked:translate-x-5" />
</span>
</label>
<label v-if="studioMode === 'video' && !ltxVideo" class="flex cursor-pointer items-center justify-between gap-3 rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3 text-sm">
<span>
<span class="block font-medium text-zinc-200">Generate sound</span>
<span class="mt-0.5 block text-xs text-zinc-500">Off skips the audio VAE so Comfy encodes a silent clip.</span>
</span>
<span class="relative inline-flex h-6 w-11 shrink-0 items-center">
<input v-model="withSound" type="checkbox" class="peer sr-only" role="switch">
<span class="absolute inset-0 rounded-full bg-zinc-700 transition peer-checked:bg-amber-400" />
<span class="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition peer-checked:translate-x-5" />
</span>
</label>
<label v-if="studioMode === 'video'" class="flex cursor-pointer items-center justify-between gap-3 rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3 text-sm">
<span>
<span class="block font-medium text-zinc-200">Autoplay</span>
<span class="mt-0.5 block text-xs text-zinc-500">Off leaves finished clips paused until you press play.</span>
</span>
<span class="relative inline-flex h-6 w-11 shrink-0 items-center">
<input v-model="autoplayEnabled" type="checkbox" class="peer sr-only" role="switch">
<span class="absolute inset-0 rounded-full bg-zinc-700 transition peer-checked:bg-amber-400" />
<span class="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition peer-checked:translate-x-5" />
</span>
</label>
<p v-if="showPrivacyToggles" class="text-xs text-zinc-500">Hide input still and hide output preview are on by default. Click the player or still to reveal; click anywhere else on the page to hide it again.</p>
</div>
</div>
<div v-if="studioMode === 'video'" class="space-y-3">
<div v-if="ltxEnabled">
<p class="mb-2 text-xs uppercase tracking-wider text-zinc-500">Video model</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="videoEngine === 'minimax' ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 hover:border-white/20'"
@click="selectVideoEngine('minimax')"
>
<span class="block font-semibold">MiniMax H3</span>
<span class="text-xs text-zinc-400">Native stereo audio. Identity refs on v2.</span>
</button>
<button
type="button"
class="rounded-xl border px-3 py-2 text-left text-sm"
:class="videoEngine === 'ltx' ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 hover:border-white/20'"
@click="selectVideoEngine('ltx')"
>
<span class="block font-semibold">LTX-2.3 PinkCherry</span>
<span class="text-xs text-zinc-400">PinkCherry + distilled LoRA. I2V and T2V.</span>
</button>
</div>
</div>
<div>
<p class="mb-2 text-xs uppercase tracking-wider text-zinc-500">Start from</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="videoStart === 'still' ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 hover:border-white/20'"
@click="videoStart = 'still'"
>
<span class="block font-semibold">A still</span>
<span class="text-xs text-zinc-400">Image to video. Extensions use the last frame.</span>
</button>
<button
type="button"
class="rounded-xl border px-3 py-2 text-left text-sm"
:class="videoStart === 'text' ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 hover:border-white/20'"
@click="videoStart = 'text'"
>
<span class="block font-semibold">Text only</span>
<span class="text-xs text-zinc-400">No start still. Shot 2+ still continues from last frame.</span>
</button>
</div>
<p class="mt-2 text-xs text-zinc-500">{{ videoModelHint }}</p>
</div>
<div v-if="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>
<div v-if="videoEngine === 'minimax' && videoStart === 'still' && minimaxGraph === 'v2'" class="rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3 space-y-3">
<div>
<p class="text-sm font-medium text-zinc-200">Character stills</p>
<p class="mt-0.5 text-xs text-zinc-500">Optional. Extra views of the same person — 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>
<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>
</div>
<div v-if="studioMode === 'video'" class="max-w-md space-y-2">
<LoraSelect v-model="videoLoraStack" :names="videoLoraOptions" label="LoRA" empty-label="None" compact :clip-strength="false" />
<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.</p>
<p class="mb-2 text-xs uppercase tracking-wider text-zinc-500">Image engine</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="v2Engine === 'flux' ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 hover:border-white/20'"
@click="setV2Engine('flux')"
>
<span class="block font-semibold">Flux</span>
<span class="text-xs text-zinc-400">Klein 9B. Edit, compose, refine, and generate.</span>
</button>
<button
type="button"
class="rounded-xl border px-3 py-2 text-left text-sm"
:class="v2Engine === 'krea' ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 hover:border-white/20'"
@click="setV2Engine('krea')"
>
<span class="block font-semibold">Krea</span>
<span class="text-xs text-zinc-400">Krea 2 Turbo. Photograph engine for stills.</span>
</button>
</div>
<p class="mt-2 text-xs text-zinc-500">Flux is Klein 9B. Krea is Krea 2 Turbo. Video stays MiniMax or LTX.</p>
<div class="max-w-md">
<LoraSelect
v-model="imageV2LoraStack"
:names="imageV2LoraOptions"
label="LoRA"
empty-label="None"
compact
/>
<p v-if="v2Engine === 'krea'" class="mt-1 text-[11px] text-zinc-500">Flux LoRAs stay off on Krea.</p>
</div>
<div v-if="v2Mode === 'generate'" class="space-y-3 rounded-2xl border border-white/10 bg-zinc-950/40 p-3">
<div>
<p class="text-xs text-zinc-400">Size · near 1 MP</p>
<div class="mt-2 flex flex-wrap gap-2">
<button
v-for="(pair, aspect) in IMAGE_V2_GENERATE_ASPECTS"
:key="aspect"
type="button"
class="rounded-full border px-2.5 py-1 text-xs"
:class="v2Width === pair[0] && v2Height === pair[1] ? 'border-amber-300/70 text-amber-100' : 'border-white/10 text-zinc-400'"
@click="setGenerateSize(aspect)"
>
{{ aspect }} · {{ pair[0] }}×{{ pair[1] }}
</button>
</div>
</div>
</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">
<label class="block text-sm">
<span class="mb-1 block text-zinc-300">Strength (denoise)</span>
<div class="flex items-center gap-3">
<input
v-model.number="v2Denoise"
type="range"
:min="IMAGE_V2_DENOISE_MIN"
:max="IMAGE_V2_DENOISE_MAX"
:step="IMAGE_V2_DENOISE_STEP"
class="w-full"
>
<span class="w-12 tabular-nums text-sm text-zinc-200">{{ v2Denoise }}</span>
</div>
</label>
<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="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="v2Steps" type="number" :min="IMAGE_STEPS_MIN" :max="IMAGE_STEPS_MAX" step="1" :disabled="v2Turbo" 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 disabled:opacity-50">
</label>
<label class="block text-sm">
<span class="mb-1 block text-zinc-400">CFG</span>
<input v-model.number="v2Cfg" type="number" :min="IMAGE_CFG_MIN" :max="IMAGE_CFG_MAX" :step="IMAGE_CFG_STEP" :disabled="v2Turbo" 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 disabled:opacity-50">
</label>
<label class="block text-sm">
<span class="mb-1 block text-zinc-400">Seed</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="v2Mode !== 'generate'" class="block text-sm">
<span class="mb-1 block text-zinc-400">Scale to MP</span>
<input v-model.number="v2Megapixels" type="number" :min="IMAGE_SCALE_MP_MIN" :max="IMAGE_SCALE_MP_MAX" :step="IMAGE_SCALE_MP_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">
</label>
</div>
<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">Flux.2 Klein 9B. Shares the desktop GPU with video.</p>
<div class="max-w-md space-y-2">
<LoraSelect v-model="imageLoraStack" :names="imageLoraOptions" label="LoRA" empty-label="None" compact />
<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 block text-zinc-400">Seed</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>
<details v-if="studioMode === 'video'" class="rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3">
<summary class="cursor-pointer text-sm font-medium">Generation settings</summary>
<div class="mt-4 space-y-4">
<div>
<p class="mb-2 text-xs uppercase tracking-wider text-zinc-500">Aspect ratio / resolution</p>
<div class="grid grid-cols-2 gap-2 lg:grid-cols-4">
<button
v-for="option in aspects"
:key="option.id"
type="button"
class="rounded-xl border px-3 py-2 text-left text-sm transition"
:class="aspect === option.id ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 hover:border-white/20'"
@click="aspect = option.id"
>
<span class="block font-semibold">{{ option.label }}</span>
<span class="text-xs text-zinc-400">{{ option.id === 'auto' ? autoHint : option.hint }}</span>
</button>
</div>
<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>
<div class="text-sm">
<span class="mb-1 block text-zinc-400">CFG / Guidance scale</span>
<div class="flex items-center gap-3">
<input
class="aigen-slider min-w-0 flex-1"
type="range"
:min="CFG_MIN"
:max="CFG_MAX"
:step="CFG_STEP"
:value="cfg"
:style="{ background: `linear-gradient(to right, #fbbf24 ${cfgPct}%, #27272a ${cfgPct}%)` }"
aria-label="CFG guidance scale"
@input="onCfgSlider"
>
<input
v-model.number="cfg"
type="number"
:min="CFG_MIN"
:max="CFG_MAX"
:step="CFG_STEP"
class="w-24 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="onCfgNumber"
>
</div>
<p class="mt-1 text-[11px] text-zinc-500">{{ ltxVideo ? 'Default 3.5 for PinkCherry' : (turbo ? 'Default 1.5 with Turbo LoRA' : 'Default 4.0 with High-Fidelity') }}. Type 0–20 (0.1 steps); the box is the source of truth.</p>
</div>
<div class="grid grid-cols-2 gap-3">
<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>
<DurationField
v-model="duration"
label="Duration"
:hint="shotScriptMode
? 'This length applies to the initial shot and every extension in the script.'
: 'Type any length, use the slider, or drag up/down in the box.'"
/>
</div>
<label class="text-sm">
<span class="mb-1 block text-zinc-400">Seed</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>
</div>
</details>
<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="flex flex-wrap gap-3">
<button
type="button"
class="flex-1 rounded-2xl bg-amber-400 px-5 py-3 font-semibold text-zinc-950 transition hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="studioMode === 'editv2' ? editV2Blocked : (studioMode === 'edit' ? editBlocked : videoGenerateDisabled)"
@click="studioMode === 'editv2' ? editImageV2() : (studioMode === 'edit' ? editImage() : generate())"
>
{{ studioMode === 'editv2' ? editV2SubmitLabel : (studioMode === 'edit' ? editSubmitLabel : generateLabel) }}
</button>
<button
v-if="showWakeGpu"
type="button"
class="rounded-2xl border px-4 py-3 text-sm font-semibold disabled:cursor-wait disabled:opacity-80"
:class="gpuWaking ? 'border-amber-300/40 text-amber-100' : 'border-amber-400/40 text-amber-200 hover:border-amber-300 hover:text-amber-50'"
:disabled="gpuWaking"
@click="pokeGpu"
>
{{ gpuWaking ? 'Starting GPU…' : 'Wake GPU' }}
</button>
<button
v-if="videoBusy || editBusy"
type="button"
class="rounded-2xl border border-red-400/40 px-4 py-3 text-red-300"
@click="cancelJob"
>
Cancel / Interrupt
</button>
</div>
<p v-if="studioMode === 'video' && videoBusy" class="text-xs text-zinc-400">The current job keeps running in Output. This submit waits in the job queue.</p>
<p v-else-if="(studioMode === 'edit' || studioMode === 'editv2') && (videoBusy || editBusy)" class="text-xs text-zinc-400">The current job keeps running in Output. This submit waits in the job queue.</p>
<p v-if="studioMode === 'video' && videoBlockReason" class="text-xs text-zinc-400">{{ videoBlockReason }}</p>
<p v-if="studioMode === 'edit' && editBlockReason" class="text-xs text-zinc-400">{{ editBlockReason }}</p>
<p v-if="studioMode === 'editv2' && editV2BlockReason" class="text-xs text-zinc-400">{{ editV2BlockReason }}</p>
<p v-if="gpuSleepHint" class="text-xs text-amber-200/80">{{ gpuSleepHint }}</p>
<div v-if="studioJobs.length" class="rounded-2xl border border-white/10 bg-zinc-950/40 p-4">
<div class="flex items-start justify-between gap-3">
<div>
<h3 class="text-sm font-semibold text-zinc-100">Job queue</h3>
<p class="mt-1 text-xs text-zinc-500">Each submit is one job. 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'"
>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-zinc-100">{{ job.name }}</p>
<p class="mt-0.5 text-xs text-zinc-500">
<span v-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>
</div>
<button
v-if="job.status === 'waiting'"
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 === 'waiting' || job.status === 'held'"
type="button"
class="shrink-0 text-xs text-zinc-400 hover:text-red-200"
@click="dismissStudioJob(job.id)"
>
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="min-w-0 max-w-full space-y-5">
<div ref="outputPanel" class="glass min-w-0 max-w-full rounded-3xl p-5 sm:p-6">
<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>
</div>
<div v-show="outputPreviewKind === 'video'">
<div
class="relative mx-auto mt-5 w-full max-w-full overflow-hidden rounded-2xl bg-black/50"
:class="{ 'sensitive-glow': awaitingReveal || lockedSave }"
:style="{ aspectRatio: `${playerSize.width} / ${playerSize.height}`, maxHeight: 'min(70dvh, 32rem)' }"
>
<video
v-if="videoUrl && !lockedSave"
ref="player"
:src="videoUrl"
class="h-full w-full max-h-[min(70dvh,32rem)] bg-black object-contain"
:class="{ 'opacity-0': awaitingReveal }"
:controls="!awaitingReveal"
:autoplay="autoplayEnabled && !awaitingReveal"
loop
playsinline
preload="metadata"
@play="onPlayed"
@loadedmetadata="onVideoMeta"
@durationchange="onVideoMeta"
/>
<button
v-if="awaitingReveal && !lockedSave"
type="button"
class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-amber-100"
@click="revealAndPlay"
>
<span class="font-display text-lg font-bold">Ready</span>
<span class="text-sm text-amber-100/80">Click to play · preview hidden</span>
</button>
<div
v-else-if="lockedSave"
class="absolute inset-0 flex flex-col items-center justify-center gap-2 px-6 text-center text-amber-100"
>
<span class="font-display text-lg font-bold">Saved to a locked folder</span>
<span class="text-sm text-amber-100/80">Unlock that folder in the library to view this clip</span>
</div>
<div
v-else-if="!videoUrl"
class="flex h-full min-h-[12rem] items-center justify-center text-sm text-zinc-500"
>
Generated MP4 appears here
</div>
</div>
<p v-if="videoUrl && !lockedSave && !videoSeekable" class="mt-3 text-xs text-amber-200/80">
Encoding… this file cannot be scrubbed until Comfy finishes writing it.
</p>
<p v-else-if="playingLastSavedWhileBusy" class="mt-3 text-xs text-zinc-500">
Playing the last saved part. The shot still encoding cannot be scrubbed until it saves.
</p>
<div v-if="(videoUrl && !lockedSave) || canUseGeneratedStill" class="mt-4 flex flex-wrap gap-3">
<a
v-if="videoUrl && !lockedSave && !awaitingReveal"
:href="videoUrl"
:download="`${videoDownloadName}.mp4`"
class="inline-flex rounded-2xl border border-white/15 px-4 py-2 text-sm hover:border-amber-300/50"
>
Download MP4
</a>
<button
v-if="canExtend"
type="button"
class="inline-flex rounded-2xl border border-amber-300/40 px-4 py-2 text-sm text-amber-100 hover:border-amber-300/70"
@click="openExtend()"
>
Extend Video
</button>
<button
v-if="canUseGeneratedStill"
type="button"
class="inline-flex rounded-2xl border border-amber-300/40 px-4 py-2 text-sm text-amber-100 hover:border-amber-300/70"
@click="useEditAsInput"
>
Use the image I just generated as input
</button>
</div>
<div v-if="extendOpen" 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 class="mt-1 text-xs text-zinc-500">Continue from the last frame with a new motion prompt, then stitch the segments together.{{ extendSaveName ? ` Saves as “${extendSaveName}”.` : '' }}</p>
</div>
<button type="button" class="text-xs text-zinc-400 hover:text-white" @click="closeExtend">Close</button>
</div>
<div class="overflow-hidden rounded-xl bg-black/40">
<img v-if="lastFrameUrl" :src="lastFrameUrl" alt="Last frame" class="max-h-48 w-full object-contain">
<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>
<label class="block text-sm">
<span class="mb-1 block text-zinc-400">Extension prompt</span>
<textarea
v-model="extendPrompt"
rows="4"
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
placeholder="Continue the motion from this frame…"
/>
</label>
<DurationField
v-model="extendDuration"
label="Extension duration"
hint="Type any length, use the slider, or drag up/down in the box."
/>
<LoraSelect v-model="extendLoraStack" :names="videoLoraOptions" label="LoRA" empty-label="Same as clip / none" compact :clip-strength="false" />
<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="!extendPrompt.trim() || lastFrameLoading || videoBusy"
@click="generateExtension"
>
Generate Extension
</button>
</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"
>
<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>
</div>
<div v-if="editResultUrl && !editLockedSave && !editAwaitingReveal" class="mt-4 flex flex-wrap gap-3">
<a
:href="editResultUrl"
:download="`${editDownloadName || 'aigen-edit'}.png`"
class="inline-flex rounded-2xl border border-white/15 px-4 py-2 text-sm hover:border-amber-300/50"
>
Download still
</a>
<button
type="button"
class="inline-flex rounded-2xl border border-amber-300/40 px-4 py-2 text-sm text-amber-100 hover:border-amber-300/70"
@click="useEditAsInput"
>
Use as input still
</button>
<button
type="button"
class="inline-flex rounded-2xl border border-amber-300/40 px-4 py-2 text-sm text-amber-100 hover:border-amber-300/70"
@click="sendToEdit"
>
Send to Edit
</button>
<button
type="button"
class="inline-flex rounded-2xl border border-amber-300/40 px-4 py-2 text-sm text-amber-100 hover:border-amber-300/70"
@click="sendToRefine"
>
Send to Refine
</button>
</div>
</div>
</div>
<div class="glass rounded-3xl p-5 sm:p-6">
<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">
<button
v-if="libraryEntries.length"
type="button"
class="text-xs text-zinc-400 hover:text-white"
@click="selectedKeys.length === libraryEntries.length ? clearSelection() : selectAllLibrary()"
>
{{ selectedKeys.length && selectedKeys.length === libraryEntries.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="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 space-y-3">
<li
v-for="entry in libraryEntries"
:key="entry.key"
class="rounded-2xl border p-3"
:class="selectedKeys.includes(entry.key) ? 'border-amber-300/30 bg-amber-400/5' : 'border-white/5 bg-zinc-950/40'"
>
<template v-if="entry.kind === 'clip'">
<div class="flex gap-3">
<label class="flex shrink-0 items-start pt-1" @click.stop>
<input
type="checkbox"
class="mt-0.5 h-4 w-4 rounded border-white/20 bg-zinc-950 accent-amber-400"
:checked="selectedKeys.includes(entry.key)"
@click.prevent="onSelectClick(entry.key, $event)"
>
</label>
<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-if="entry.group.head.hasThumbnail" :src="`/api/library/clips/${entry.group.head.id}/thumb`" alt="" loading="lazy" decoding="async" class="h-full w-full object-cover">
<span v-else class="block h-full w-full bg-zinc-800" />
</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="familyScript(entry.group.parts)"
:text="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="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)">
{{ expandedFamilies[entry.group.key] ? 'Collapse' : `Expand ${entry.group.parts.length} parts` }}
</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">
<li
v-for="(item, partIndex) in chronologicalParts(entry.group.parts)"
:key="item.id"
class="flex flex-wrap items-start gap-3 rounded-xl border border-white/5 bg-black/20 p-2"
>
<ClipFrameThumbs
:hidden="item.hideThumbnail"
:revealed="!!stitchReveal[item.id]"
:still-id="partIndex === 0 ? item.stillId : undefined"
:from-clip-id="previousClipIdFor(item, entry.group.parts, partIndex)"
:clip-id="item.id"
:show-first-frame="partIndex > 0 || !item.stillId || Boolean(item.parentClipId)"
@update:revealed="stitchReveal[item.id] = $event"
/>
<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="saveRename(item)"
@keydown.escape.prevent="cancelRename"
@blur="saveRename(item)"
>
<p v-else class="min-w-0 flex-1 truncate text-sm font-medium">{{ item.name || item.prompt }}</p>
</div>
<p class="truncate text-xs text-zinc-500">{{ clipMetaLine(item) }}</p>
<PermanenceRefStrip
v-if="!item.hideThumbnail"
class="mt-2"
:refs="item.permanenceRefs || []"
:name-options="clipBindingKeys(item, entry.group.head)"
cta="Add still"
:hidden="item.hideInput || hideInputPreview"
@add-ref="openPermanencePicker(undefined, item.id)"
@remove="(refIndex) => removeClipRef(item, refIndex)"
@update-label="(refIndex, label) => updateClipRefLabel(item, refIndex, label)"
/>
<div class="mt-1.5 flex flex-wrap items-center gap-3">
<button
v-if="item.hideThumbnail"
type="button"
class="text-xs text-amber-300"
@click="stitchReveal[item.id] = !stitchReveal[item.id]"
>
{{ stitchReveal[item.id] ? 'Hide frames' : 'Click to reveal' }}
</button>
<button type="button" class="text-xs text-amber-300" @click="openClip(item)">Open</button>
<button type="button" class="text-xs text-amber-300" @click="useClipAsInput(item)">Use as input</button>
<button type="button" class="text-xs text-amber-300" @click="openExtend(item)">Extend</button>
<button type="button" class="text-xs text-amber-300" @click="startRename(item)">Rename</button>
<button type="button" class="text-xs text-zinc-400" @click="toggleDetails(item.id)">
{{ expandedDetails[item.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(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="askRemoveClip(item)">Delete</button>
</div>
<LibrarySettingsDetails
v-if="expandedDetails[item.id]"
:rows="clipSettingsRows(item)"
empty="No generator settings were saved with this clip."
/>
</div>
</li>
</ul>
</template>
<template v-else-if="entry.kind === 'still' && entry.stillGroup">
<div class="flex gap-3">
<label class="flex shrink-0 items-start pt-1" @click.stop>
<input
type="checkbox"
class="mt-0.5 h-4 w-4 rounded border-white/20 bg-zinc-950 accent-amber-400"
:checked="selectedKeys.includes(entry.key)"
@click.prevent="onSelectClick(entry.key, $event)"
>
</label>
<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="stillFamilyScript(entry.stillGroup.parts)"
:text="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} 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 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 gap-3 rounded-xl border border-white/5 bg-black/20 p-2"
>
<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>
</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 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>
</template>
</div>
</section>
</main>
<SettingsModal
v-if="settingsOpen"
:folders="folders"
:auth-mode="authMode"
:username="userName"
@close="settingsOpen = false"
@updated="applyLibrary"
@error="toast"
@renamed="userName = $event"
/>
<PromptDrawer
:open="promptDrawerOpen"
v-model:text="prompt"
:title="studioMode === 'edit' || studioMode === 'editv2' ? 'Edit prompt' : (shotScriptMode ? 'Shot script' : 'Prompt')"
:placeholder="promptPlaceholder"
@update:open="promptDrawerOpen = $event"
/>
<ImagePickerModal
:open="pickerOpen"
:folders="folders"
:stills="stills"
:folder-id="folderId"
:hide-input-preview="hideInputPreview"
: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"
>
<img
:src="editLightboxUrl"
alt="Edited still"
class="max-h-[90vh] max-w-[min(96vw,80rem)] object-contain"
@click.stop
>
</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>
<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 { parseEditPasses, looksLikeShotScript } from '~/utils/parseRecommend'
import { 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,
isKleinImageLora,
kleinLoraOnKreaMessage,
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_MAX,
IMAGE_SCALE_MP_MIN,
IMAGE_SCALE_MP_STEP,
IMAGE_STEPS_MAX,
IMAGE_STEPS_MIN,
VIDEO_CFG_MAX,
VIDEO_CFG_MIN,
VIDEO_CFG_STEP,
type GenerationPreset
} from '~/utils/generationPresets'
import {
clipSettingsRows,
inferStillPipeline,
stillModeLabel,
stillSettingsRows,
type StillGenerationSettings
} from '~/utils/libraryGeneration'
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_ASPECTS,
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_LORA,
IMAGE_V2_STEPS_DEFAULT,
clampImageV2Denoise,
clampImageV2Size,
defaultImageV2LoraStack,
isImageV2ConceptLora,
isImageV2ConsistencyLora,
mergeLegacyImageV2Loras,
parseImageV2Engine,
upsertImageV2Lora,
type ImageV2Engine,
type ImageV2GenerateAspect,
type ImageV2Mode,
type ImageV2PresetSettings,
type ImageV2Task
} from '~/utils/imageV2'
import {
coerceVideoWorkflow,
composeVideoWorkflow,
isLtxWorkflow,
isTextToVideo,
isXaigenStudio,
ltxWorkflowEnabled,
minimaxGraphOf,
parseVideoWorkflow,
videoEngineOf,
videoStartOf,
type MiniMaxGraphId,
type VideoEngineId,
type VideoStartId
} from '~/utils/videoModels'
const runtimeConfig = useRuntimeConfig()
const route = useRoute()
const router = useRouter()
type AspectId = 'auto' | '16:9' | '9:16' | '1:1'
interface LibraryFolder {
id: string
name: string
createdAt: number
thumbnailDefault: boolean
protected: boolean
unlocked: boolean
clipCount?: number
imageCount?: number
}
interface LibraryStill {
id: string
folderId: string
filename: string
width: number
height: number
createdAt: number
hideInput?: boolean
role?: 'input' | 'output'
name?: string
prompt?: string
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[]
}
interface RetryDraft {
id: string
createdAt: number
folderId: string
stillId?: string
stillFilename?: string
name?: string
prompt: 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; 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
duration: number
loraName?: string
loraStack?: LoraStackItem[]
}
interface QueuedEditPass {
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 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 preview = ref('')
const dragging = ref(false)
const prompt = ref('')
const promptDrawerOpen = ref(false)
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 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 shotScriptBeforeRecommend = ref(false)
const extensionQueue = ref<QueuedExtension[]>([])
const editPassQueue = ref<QueuedEditPass[]>([])
const clipName = ref('')
const videoDownloadName = ref('aigen')
const editDownloadName = ref('aigen-edit')
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(5)
const seedInput = ref('random')
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 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 v2Mode = ref<ImageV2Mode>('edit')
const v2Engine = ref<ImageV2Engine>('flux')
const v2Task = ref<ImageV2Task>('scene')
const v2Negative = ref('')
const v2Steps = ref(IMAGE_V2_STEPS_DEFAULT)
const v2Cfg = ref(IMAGE_V2_CFG_DEFAULT)
const v2Megapixels = ref(1)
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 extendPrompt = ref('')
const extendDuration = ref(5)
const lastFrameUrl = ref('')
const lastFrameLoading = 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 userName = ref('')
const authEnabled = ref(false)
const authMode = 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 seedImageV2LorasIfEmpty() {
if (imageV2LoraStack.value.length) return
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 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 settingsOpen = ref(false)
const queueAutoRun = ref(false)
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 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 = ref<VideoStartId>('still')
const minimaxGraph = ref<MiniMaxGraphId>('v1')
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(videoLoras.value, [
...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(imageLoras.value, imageLoraStack.value.map(item => item.name)))
const imageV2LoraOptions = computed(() => mergeLoraOptions(
filterLorasForImageEngine(imageLoras.value, v2Engine.value),
filterLorasForImageEngine(imageV2LoraStack.value.map(item => item.name), v2Engine.value)
))
const identityRefs = ref<(File | null)[]>([null, null, null, null])
const identityPreviews = ref(['', '', '', ''])
const globalLocks = ref('')
const familyPermanenceRefs = ref<PermanenceRef[]>([])
const shotPermanenceRefs = ref<Record<number, PermanenceRef[]>>({})
const LOCKS_STORE = 'aigen-global-locks'
const REFS_STORE = 'aigen-permanence-refs'
const pickerHeading = computed(() => {
if (pickerKind.value === 'permanence') return 'Choose a still to name'
if (pickerSlot.value === 'main') return studioMode.value === 'edit' || studioMode.value === 'editv2' ? (studioMode.value === 'editv2' ? 'Choose still A' : 'Choose image 1') : 'Choose an image'
if (pickerSlot.value === 'editRef') return studioMode.value === 'editv2' ? 'Choose still B' : 'Choose image 2'
return `Choose Picture ${Number(pickerSlot.value) + 2}`
})
const permanencePicker = computed(() => pickerKind.value === 'permanence')
const lockBindingKeys = computed(() => parseLockBindingKeys(globalLocks.value))
const familyRefLabelLine = computed(() => {
const labels = permanenceLabels(familyPermanenceRefs.value)
if (!labels.length) return ''
return `In the prompt: Permanence: ${labels.join(', ')} stay the same.`
})
const extraPersonLock = computed(() => shouldForceLastFrameI2V(
mergePermanenceRefs(familyPermanenceRefs.value, Object.values(shotPermanenceRefs.value).flat()),
globalLocks.value
))
const useIdentityRefs = computed(() => !extraPersonLock.value && identityRefs.value.some(Boolean))
const permanenceComfyNote = computed(() => {
if (extraPersonLock.value) {
return 'A second named person keeps last-frame I2V on. MiniMax never sees the man/bench photos; those names only go in the inherit line.'
}
if (studioMode.value === 'video' && videoWorkflow.value === 'v2' && useIdentityRefs.value) {
return 'Character stills are extra views of the same person. Picture 1 is the last frame after shot 1. Permanence stills only name the lock text.'
}
return 'Last-frame I2V does not send these stills to MiniMax as extra pictures. Continuity is the previous clip’s last frame; the names only go in the lock line.'
})
let videoTimer: ReturnType<typeof setInterval> | null = null
let editTimer: ReturnType<typeof setInterval> | null = null
let videoSource: EventSource | null = null
let editSource: EventSource | null = null
let videoPollTimer: ReturnType<typeof setInterval> | null = null
let editPollTimer: ReturnType<typeof setInterval> | null = null
let healthTimer: ReturnType<typeof setInterval> | null = null
let videoSettledUi = false
let editSettledUi = false
let videoListenGen = 0
let editListenGen = 0
let activeDraftId = ''
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 (outputQueuedJob.value || videoAwaitingBeast.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 rigBusy = computed(() => (
comfyBusy.value || imageComfyBusy.value || videoBusy.value || editBusy.value
))
const showWakeGpu = computed(() => gpuWaking.value || (!comfyOk.value && !imageComfyOk.value))
const rigBadge = computed(() => {
if (gpuWaking.value) {
return {
label: 'Starting…',
className: 'border-amber-400/40 text-amber-200',
dot: 'bg-amber-400 animate-pulse',
title: 'Starting Comfy Desktop on the GPU'
}
}
if (!comfyOk.value && !imageComfyOk.value) {
return {
label: 'Offline',
className: 'border-red-400/30 text-red-300',
dot: 'bg-red-400',
title: comfyAsleep.value
? 'Desktop GPU is asleep. Click Wake GPU or generate to start Comfy.'
: 'Desktop GPU is unreachable'
}
}
if (rigBusy.value) {
return {
label: 'Live',
className: 'border-amber-400/40 text-amber-200',
dot: 'bg-amber-400 animate-pulse',
title: 'Desktop GPU is rendering'
}
}
return {
label: 'Ready',
className: 'border-emerald-400/30 text-emerald-300',
dot: 'bg-emerald-400',
title: 'Desktop GPU is free'
}
})
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 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 canExtend = computed(() => Boolean(
videoUrl.value
&& !lockedSave.value
&& (currentClipId.value || clipIdFromPlayer())
))
const extendSaveName = computed(() => nextClipPartName(currentClip.value?.name || currentClip.value?.prompt || ''))
const queueReady = computed(() => {
if (shotScriptMode.value) return parsedShots.value.length > 0 && parsedShots.value.every(shot => shot.prompt.trim())
return extensionQueue.value.every(item => item.prompt.trim())
})
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 (!prompt.value.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 click Wake GPU to pre-warm.'
return 'GPU is offline. Generate will try to start Comfy. Or click Wake GPU.'
})
const editBlocked = computed(() => {
if (!file.value || !prompt.value.trim() || !folderId.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()) return 'Write an edit prompt first.'
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 (!prompt.value.trim() || !folderId.value) return true
if (v2Mode.value !== 'generate' && !file.value) return true
if (v2Mode.value === 'compose' && !editRefFile.value) return true
if (v2Mode.value === 'refine' && !refineMaskDirty.value) return true
return false
})
const editV2BlockReason = computed(() => {
if (v2Mode.value !== 'generate' && !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()) return 'Write a prompt first.'
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 editV2SubmitLabel = computed(() => {
const occupied = videoBusy.value || editBusy.value || studioJobs.value.some(job => job.status === 'running')
const label = v2Mode.value === 'generate' ? 'Generate image' : v2Mode.value === 'refine' ? 'Refine image' : v2Mode.value === 'compose' ? 'Compose image' : 'Edit image'
return occupied ? `Queue ${label.toLowerCase()}` : label
})
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'
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
return n ? `Queue ${n + 1} passes` : 'Queue edit'
})
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: text,
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: prompt.value, duration: duration.value },
...extensionQueue.value.map((item, index) => ({
label: `Extension ${index + 1}`,
prompt: item.prompt,
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) => b.head.createdAt - a.head.createdAt)
})
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 sorted = [...parts].sort(sortClipParts)
return { key, head: sorted[0], parts: sorted }
})
.sort((a, b) => b.head.createdAt - a.head.createdAt)
})
const libraryEntries = computed(() => {
const videos = libraryFilter.value === 'images'
? []
: libraryGroups.value.map(group => ({
kind: 'clip' as const,
key: group.key,
createdAt: group.head.createdAt,
group
}))
const images = libraryFilter.value === 'videos'
? []
: stillGroups.value.map(group => ({
kind: 'still' as const,
key: group.key,
createdAt: group.head.createdAt,
stillGroup: group
}))
return [...videos, ...images].sort((a, b) => b.createdAt - a.createdAt)
})
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 familyBaseName(name: string) {
return String(name || '').trim().replace(/\s+p\d+$/i, '')
}
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 sortClipParts(a: LibraryClip, b: LibraryClip) {
const durationDelta = (Number(b.duration) || 0) - (Number(a.duration) || 0)
if (durationDelta) return durationDelta
const chainDelta = (b.chainIndex || 0) - (a.chainIndex || 0)
if (chainDelta) return chainDelta
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[], index: number) {
if (item.parentClipId) return item.parentClipId
if (index <= 0) return undefined
const ordered = chronologicalParts(parts)
const current = ordered[index]
const currentChain = current?.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 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 pass = typeof still.chainIndex === 'number' ? `pass ${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[] } | unknown) {
const payload = (data || {}) as { folders?: LibraryFolder[]; clips?: LibraryClip[]; stills?: LibraryStill[]; drafts?: RetryDraft[] }
folders.value = payload.folders || []
clips.value = payload.clips || []
stills.value = payload.stills || []
drafts.value = payload.drafts || []
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[] }>(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 || '')
}
async function pollLoras() {
const listed = await $fetch<{ image?: string[]; video?: string[] }>('/api/loras').catch(() => ({ image: [], video: [] }))
videoLoras.value = listed.video || []
imageLoras.value = listed.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 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) {
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'
videoStart.value = 'still'
minimaxGraph.value = 'v1'
try {
const saved = parseVideoWorkflow(localStorage.getItem('aigen-video-workflow') || '')
videoEngine.value = videoEngineOf(saved)
videoStart.value = videoStartOf(saved)
minimaxGraph.value = minimaxGraphOf(saved)
const engine = localStorage.getItem('aigen-video-engine')
const start = localStorage.getItem('aigen-video-start')
if (engine === 'ltx' || engine === 'minimax') videoEngine.value = engine
if (start === 'text' || start === 'still') videoStart.value = start
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')
imageV2LoraStack.value = readStoredLoraStack('aigen-image-v2-lora')
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
} catch { /* ignore */ }
applyInstancePrivacy()
window.addEventListener('click', onSitePointerDown, true)
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()
void loadGenerationPresets()
await loadDirectorScripts().catch(() => null)
userName.value = me.user?.name || me.user?.email || ''
authEnabled.value = Boolean(me.authEnabled)
authMode.value = me.authMode || ''
instanceName.value = me.instanceName || instanceName.value || 'AIGen'
applyInstancePrivacy()
setTimeout(() => { void loadLibrary(true).catch(() => null) }, 6000)
healthTimer = setInterval(() => {
if (typeof document !== 'undefined' && document.hidden) return
void pollComfyHealth()
void pollLoras()
void refreshStudioQueue()
if (activeQueueId.value) void refreshLiveQueue()
}, 5000)
await resumeActiveJob()
await refreshStudioQueue()
await refreshLiveQueue()
await loadPromptPresets()
applyIncomingDirectorScript()
})
onBeforeUnmount(() => {
window.removeEventListener('click', onSitePointerDown, true)
window.removeEventListener('keydown', onEditLightboxKey)
if (healthTimer) clearInterval(healthTimer)
healthTimer = null
if (libraryRefreshTimer) clearTimeout(libraryRefreshTimer)
libraryRefreshTimer = null
stopTimer()
stopListen()
closeExtend()
})
watch(folderId, () => {
applyThumbnailDefault()
})
watch(settingsOpen, (open) => {
if (!open) void loadPromptPresets()
})
watch(recommendPresetId, (id) => {
try { localStorage.setItem('aigen-prompt-preset', id) } catch { /* ignore */ }
})
watch([browseFolderId, libraryFilter], () => {
selectedKeys.value = []
lastSelectedKey.value = ''
})
watch(editBusy, (busy) => {
if (busy) outputFocus.value = 'edit'
})
watch(videoBusy, (busy) => {
if (busy) outputFocus.value = 'video'
})
watch(editLightboxUrl, (value) => {
if (value) window.addEventListener('keydown', onEditLightboxKey)
else window.removeEventListener('keydown', onEditLightboxKey)
})
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(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) => {
try { localStorage.setItem('aigen-image-v2-lora', JSON.stringify(value)) } catch { /* ignore */ }
}, { deep: true })
function clampDuration(value: number) {
if (!Number.isFinite(value)) return 5
return Math.min(120, Math.max(0.5, Math.round(value * 10) / 10))
}
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 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 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
}
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')
}
}
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 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',
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' ? undefined : clampImageScaleMegapixels(v2Megapixels.value, 1),
turbo: v2Turbo.value === true,
strength: v2Mode.value === 'generate' || (v2Mode.value !== 'refine' && v2Engine.value !== 'krea') ? undefined : clampImageV2Denoise(v2Denoise.value),
width: v2Mode.value === 'generate' ? v2Width.value : undefined,
height: v2Mode.value === 'generate' ? v2Height.value : undefined
}
}
}
if (studioMode.value === 'edit') {
return {
kind: 'image' as const,
loraStack: filterLoraStackForStudio(imageLoraStack.value, ltxEnabled.value),
settings: {
steps: clampImageSteps(editSteps.value),
cfg: clampImageCfg(editCfg.value),
negative: editNegative.value,
scaleToTotalPixels: editScaleToTotalPixels.value,
scaleMegapixels: clampImageScaleMegapixels(editScaleMegapixels.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
v2Mode.value = settings.mode === 'compose' ? 'compose' : settings.mode === 'refine' ? 'refine' : settings.mode === 'generate' ? 'generate' : 'edit'
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.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)
v2Turbo.value = settings.turbo === true
if (v2Mode.value === 'refine' || (v2Engine.value === 'krea' && v2Mode.value !== 'generate')) v2Denoise.value = clampImageV2Denoise(settings.strength)
if (v2Mode.value === 'generate') {
const size = clampImageV2Size(settings.width, settings.height)
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 {
steps?: number
cfg?: number
negative?: string
scaleToTotalPixels?: boolean
scaleMegapixels?: number
}
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)
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'
}
}
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' ? '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 }))
shotScriptBeforeRecommend.value = shotScriptMode.value
if (studioMode.value === 'edit') {
const passes = parseEditPasses(text)
if (passes && passes.length > 1) {
prompt.value = passes[0]
editPassQueue.value = passes.slice(1).map(item => ({ id: crypto.randomUUID(), prompt: item }))
} 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 }))
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() {
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 = ''
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]) : ''
}
function readFile(next: File, opts?: { keepThumbnailPref?: boolean; stillId?: string }) {
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
}, 250)
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 === '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)
const queuedMine = Boolean(
outputStudioJobId.value
&& studioJobs.value.some(job => job.id === outputStudioJobId.value && (job.status === 'waiting' || job.status === 'held'))
)
if (queuedMine && running && running.id !== outputStudioJobId.value) {
return
}
if (running?.liveJobId) videoAwaitingBeast.value = false
if (running?.kind === 'edit') {
if (running.liveJobId && running.liveJobId !== editJobId.value && !editBusy.value) {
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?.liveJobId && running.liveJobId !== videoJobId.value && !videoBusy.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 {
await $fetch(`/api/studio-queue/${id}/cut-in`, { method: 'POST' })
toast('This job will run after the current shot, as its own clip.')
await refreshStudioQueue()
} catch (error: any) {
toast(error?.data?.statusMessage || error?.statusMessage || 'Could not move that job next')
}
}
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 restoreStudioJob(id: string) {
const job = await $fetch<{
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; duration: number; permanenceRefs?: PermanenceRef[]; loraName?: string; loraStack?: LoraStackItem[] }[]
queueAutoRun?: boolean
globalLocks?: string
permanenceRefs?: PermanenceRef[]
shotPermanenceRefs?: PermanenceRef[][]
loraName?: string
loraStack?: LoraStackItem[]
shotLoras?: string[]
shotLoraStacks?: LoraStackItem[][]
}
}>(`/api/studio-queue/${id}`)
const payload = job.payload
if (!payload) throw new Error('That job has no saved inputs')
studioMode.value = 'video'
outputFocus.value = 'video'
closeExtend()
const extensions = payload.extensions || []
applyVideoWorkflow(payload.workflow)
prompt.value = extensions.length
? [payload.prompt.trim(), ...extensions.map((item, index) => `shot ${index + 2}\n${item.prompt.trim()}`)].join('\n\n')
: (payload.prompt || '')
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
identityRefs.value.forEach((_, index) => clearIdentityRef(index))
extensionQueue.value = extensions.map(item => ({
id: crypto.randomUUID(),
prompt: item.prompt,
duration: clampDuration(Number(item.duration)),
loraStack: readLoraStack(item)
}))
queueAutoRun.value = payload.queueAutoRun === true
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], 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 }
hideThumbnail?: boolean
folderLocked?: boolean
} | null>('/api/generate/active').catch(() => null)
const videoId = storedVideo?.jobId || active?.video?.jobId || (!active?.edit?.jobId ? active?.jobId : null) || ''
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 onEditLightboxKey(event: KeyboardEvent) {
if (event.key === 'Escape') closeEditLightbox()
}
function onSitePointerDown(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
}
}
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) || 'aigen'
}
function nextClipPartName(name: string) {
const trimmed = (name || '').trim()
if (!trimmed) return ''
const match = trimmed.match(/^(.*)\s+p(\d+)$/i)
const base = (match ? match[1] : trimmed).trim() || trimmed
const part = match ? Number(match[2]) + 1 : 2
const suffix = ` p${part}`
return `${base.slice(0, Math.max(1, 80 - suffix.length))}${suffix}`
}
function clipGenerateDuration(item: LibraryClip) {
if (item.parentClipId) {
const parent = clips.value.find(clip => clip.id === item.parentClipId)
if (parent?.duration && item.duration && item.duration > parent.duration + 0.4) {
return clampDuration(item.duration - parent.duration)
}
}
return clampDuration(Number(item.duration) || 5)
}
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], still?.filename || 'still.png', { 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
prompt.value = restoreAll ? familyScript(parts) : target.prompt
clipName.value = restoreAll
? (familyBaseName(collectionTitle({ head: target, parts })) || target.name || '')
: (target.name || '')
seedInput.value = String(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))
const ordered = chronologicalParts(parts)
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 => ({
id: crypto.randomUUID(),
prompt: part.prompt,
duration: clipGenerateDuration(part),
loraStack: 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
prompt.value = draft.prompt
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 => ({
id: crypto.randomUUID(),
prompt: String(item.prompt || ''),
duration: clampDuration(Number(item.duration)),
loraStack: 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
lastFrameLoading.value = false
if (lastFrameObjectUrl) {
URL.revokeObjectURL(lastFrameObjectUrl)
lastFrameObjectUrl = ''
}
lastFrameUrl.value = ''
}
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
const clip = clips.value.find(entry => entry.id === id)
extendPrompt.value = clip?.prompt || prompt.value
extendDuration.value = clampDuration(duration.value)
extendLoraStack.value = readLoraStack(clip)
extendOpen.value = true
lastFrameLoading.value = true
lastFrameUrl.value = ''
player.value?.pause()
const gen = ++lastFrameGen
try {
const blob = await $fetch<Blob>(`/api/library/clips/${id}/last-frame`, { responseType: 'blob' })
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
}
}
async function generateExtension() {
const id = clipIdFromPlayer()
if (!id || !extendPrompt.value.trim()) return
const switching = videoBusy.value
statusBusy.value = false
videoBusy.value = true
watchingLibrary.value = false
stopListen('video')
videoSettledUi = false
progress.value = 1
awaitingReveal.value = false
concealOutput.value = hideThumbnail.value || currentClip.value?.hideThumbnail === true
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')
try {
const started = await $fetch<{ jobId: string; steps: number; hideThumbnail?: boolean; folderLocked?: boolean }>('/api/extend', {
method: 'POST',
body: {
clipId: id,
prompt: extendPrompt.value.trim(),
duration: extendDuration.value,
loraStack: extendLoraStack.value
}
})
closeExtend()
if (switching) toast('Switched to this extension. The other job will keep saving to the library.')
videoJobId.value = started.jobId
maxStep.value = started.steps
videoSettledUi = false
persistActiveJob('video', started.jobId, started.hideThumbnail === true || concealOutput.value, started.folderLocked === true)
listen('video', started.jobId, started.hideThumbnail === true || concealOutput.value, started.folderLocked === true)
void pollComfyHealth()
} catch (error: any) {
videoBusy.value = false
statusBusy.value = false
stopTimer('video')
clearActiveJob('video')
const message = error?.data?.statusMessage || error?.statusMessage || error?.message || 'Failed to start extension'
statusMessage.value = message
toast(message)
}
}
function openClip(item: LibraryClip) {
studioMode.value = 'video'
outputFocus.value = 'video'
closeExtend()
watchingLibrary.value = true
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] || 'aigen')
if (!videoBusy.value) {
statusMessage.value = item.hideThumbnail ? 'Hidden clip ready' : 'Loaded from library'
progress.value = 100
}
}
function openStill(still: LibraryStill) {
studioMode.value = inferStillPipeline(still) === 'imagev2' ? 'editv2' : 'edit'
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
if (still.prompt) prompt.value = still.prompt
if (dest === 'imagev2') {
studioMode.value = 'editv2'
outputFocus.value = 'edit'
const mode = settings?.mode === 'compose' || settings?.mode === 'refine' || settings?.mode === 'generate'
? settings.mode
: 'edit'
setV2Mode(mode)
v2Engine.value = parseImageV2Engine(settings?.engine, 'flux')
if (!settings) return
if (typeof settings.negative === 'string') v2Negative.value = settings.negative
if (settings.steps != null) v2Steps.value = clampImageSteps(settings.steps, IMAGE_V2_STEPS_DEFAULT)
if (settings.cfg != null) v2Cfg.value = clampImageCfg(settings.cfg, IMAGE_V2_CFG_DEFAULT)
if (settings.megapixels != null) v2Megapixels.value = clampImageScaleMegapixels(settings.megapixels, 1)
v2Turbo.value = settings.turbo === true
if ((mode === 'refine' || (v2Engine.value === 'krea' && mode !== 'generate')) && settings.strength != null) v2Denoise.value = clampImageV2Denoise(settings.strength)
if (mode === 'generate') {
const size = clampImageV2Size(settings.width, settings.height)
v2Width.value = size.width
v2Height.value = size.height
}
if (mode === 'compose' && settings.task) v2Task.value = settings.task
if (settings.seed != null) seedInput.value = String(Math.round(settings.seed))
imageV2LoraStack.value = mergeLegacyImageV2Loras(
settings.loraStack?.length || settings.loraName ? readLoraStack(settings) : imageV2LoraStack.value,
{
engine: v2Engine.value,
mode,
xaigen: v2ConceptLoraEnabled.value,
snofsModel: settings.snofsModel,
snofsClip: settings.snofsClip,
consistencyModel: settings.consistencyModel,
consistencyClip: settings.consistencyClip,
kreaConcept: String(runtimeConfig.public.kreaConceptLora || IMAGE_V2_KREA_CONCEPT_LORA || '').trim()
}
)
return
}
studioMode.value = 'edit'
outputFocus.value = 'edit'
if (!settings) return
if (settings.steps != null) editSteps.value = clampImageSteps(settings.steps)
if (settings.cfg != null) editCfg.value = clampImageCfg(settings.cfg)
if (typeof settings.negative === 'string') editNegative.value = settings.negative
editScaleToTotalPixels.value = settings.scaleToTotalPixels === true
if (settings.scaleMegapixels != null) editScaleMegapixels.value = clampImageScaleMegapixels(settings.scaleMegapixels)
if (settings.loraStack?.length || settings.loraName) {
imageLoraStack.value = filterLoraStackForStudio(readLoraStack(settings), ltxEnabled.value)
}
if (settings.seed != null) seedInput.value = String(Math.round(settings.seed))
}
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], still?.filename || '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 loaded. Tweak and generate.'
if (dest === 'imagev2' && mode === 'compose') {
return still.settings?.referenceStillId
? 'Compose settings loaded. Tweak and generate.'
: 'Compose settings loaded. Re-add still B, then generate.'
}
if (dest === 'imagev2' && mode === 'refine') return 'Refine settings loaded. Paint a mask, then generate.'
if (dest === 'imagev2') return 'Image v2 settings loaded. Tweak and generate.'
if (dest === 'image' && still.settings) return 'Image edit settings loaded. Tweak and generate.'
return 'Loaded as input. It stays in Output, not the upload library.'
}
async function useStillAsInput(still: LibraryStill) {
try {
applyStillSettings(still)
const dest = inferStillPipeline(still)
const generate = dest === 'imagev2' && still.settings?.mode === 'generate'
if (!generate) {
const blob = await $fetch<Blob>(`/api/library/stills/${still.id}`, { responseType: 'blob' })
if (still.hideInput && showPrivacyToggles.value) hideInputPreview.value = true
readFile(new File([blob], still.filename, { type: blob.type || 'image/png' }), { stillId: still.id })
}
if (dest === 'imagev2' && still.settings?.mode === 'compose' && still.settings.referenceStillId) {
try {
await loadStillAsEditRef(still.settings.referenceStillId)
} catch {
toast('Compose settings loaded, but still B is missing. Re-add it.')
}
}
scrollToStudioInput()
toast(stillUseAsInputMessage(still))
} catch (error: any) {
toast(error?.data?.statusMessage || 'Could not load that still')
}
}
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 = libraryEntries.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] || 'aigen')
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() {
extensionQueue.value.push({
id: crypto.randomUUID(),
prompt: prompt.value.trim(),
duration: duration.value,
loraStack: []
})
}
function removeQueuedExtension(id: string) {
extensionQueue.value = extensionQueue.value.filter(item => item.id !== id)
}
function queueEditPass() {
editPassQueue.value.push({
id: crypto.randomUUID(),
prompt: ''
})
}
function removeEditPass(id: string) {
editPassQueue.value = editPassQueue.value.filter(item => item.id !== id)
}
async function editImage() {
if (!file.value || !prompt.value.trim()) {
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
}
const extraPasses = editPassQueue.value.map(item => ({ prompt: item.prompt.trim() }))
const hideOut = hideThumbnail.value
try {
const body = new FormData()
body.append('image', file.value)
if (editRefFile.value) body.append('image2', editRefFile.value)
body.append('prompt', prompt.value.trim())
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 (extraPasses.length) body.append('passes', JSON.stringify(extraPasses))
if (imageLoraStack.value.length) body.append('loraStack', JSON.stringify(imageLoraStack.value))
const downloadName = clipFileName(clipName.value.trim() || prompt.value.split(/[.!?\n]/)[0] || 'aigen-edit')
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
? `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 ? 'Pass 1' : ''
editOverallProgress.value = 0
editCompletedChainStep.value = 0
editActiveChainPlan.value = [
{ label: 'Pass 1', prompt: prompt.value.trim() },
...extraPasses.map((item, index) => ({ label: `Pass ${index + 2}`, prompt: item.prompt }))
]
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) {
v2Mode.value = mode
if (mode === 'edit') v2Task.value = 'scene'
if (mode === 'refine') {
v2Task.value = 'refine'
void ensureRefineCanvas()
}
if (mode === 'generate') {
v2Task.value = 't2i'
}
if (v2Engine.value === 'krea' && mode === 'edit') v2Denoise.value = IMAGE_V2_KREA_EDIT_DENOISE
if (v2Engine.value === 'krea' && mode === 'compose') v2Denoise.value = IMAGE_V2_KREA_COMPOSE_DENOISE
}
function setV2Engine(engine: ImageV2Engine) {
const previous = v2Engine.value
v2Engine.value = engine
if (engine === 'krea') {
v2Steps.value = IMAGE_V2_KREA_STEPS
v2Cfg.value = IMAGE_V2_KREA_CFG
v2Turbo.value = false
const dropped = imageV2LoraStack.value.filter(item => isKleinImageLora(item.name))
if (dropped.length) {
imageV2LoraStack.value = filterLoraStackForImageEngine(imageV2LoraStack.value, 'krea')
seedImageV2LorasIfEmpty()
toast(dropped.length === 1
? kleinLoraOnKreaMessage(dropped[0].name)
: 'Flux LoRAs were removed. They cannot be used with Krea.')
}
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
seedImageV2LorasIfEmpty()
}
}
function setGenerateSize(aspect: ImageV2GenerateAspect) {
const [width, height] = IMAGE_V2_GENERATE_ASPECTS[aspect]
v2Width.value = width
v2Height.value = height
}
function applyRefinePreset(kind: 'face' | 'hand' | 'heavy') {
if (kind === 'face') {
v2Denoise.value = IMAGE_V2_REFINE_FACE.strength
if (v2Engine.value === 'flux') {
if (v2ConceptLoraEnabled.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) {
imageV2LoraStack.value = upsertImageV2Lora(imageV2LoraStack.value, IMAGE_V2_SNOFS_LORA, IMAGE_V2_REFINE_HAND.snofsModel, IMAGE_V2_REFINE_HAND.snofsClip)
}
imageV2LoraStack.value = upsertImageV2Lora(imageV2LoraStack.value, IMAGE_V2_CONSISTENCY_LORA, IMAGE_V2_REFINE_HAND.consistencyModel, IMAGE_V2_REFINE_HAND.consistencyClip)
}
return
}
v2Denoise.value = IMAGE_V2_REFINE_HEAVY.strength
}
async function ensureRefineCanvas() {
if (file.value) return
const stillId = lastV2StillId.value || stillIdFromLibraryUrl(editResultUrl.value)
if (!stillId) return
try {
const blob = await $fetch<Blob>(`/api/library/stills/${stillId}`, { responseType: 'blob' })
if (!blob?.size) return
readFile(new File([blob], 'refine-canvas.png', { type: blob.type || 'image/png' }), { stillId })
} catch {
// leave Still A empty if the last output is gone
}
}
async function sendToEdit() {
if (!editResultUrl.value) return
studioMode.value = 'editv2'
outputFocus.value = 'edit'
try {
await useEditAsInput()
setV2Mode('edit')
toast('Output is Still A. Write an edit prompt.')
} catch {
toast('Could not send that still to Edit')
}
}
async function sendToRefine() {
if (!editResultUrl.value) return
studioMode.value = 'editv2'
outputFocus.value = 'edit'
try {
await useEditAsInput()
setV2Mode('refine')
toast('Output is Still A. Paint a mask, then refine.')
} catch {
toast('Could not send that still to Refine')
}
}
async function editImageV2() {
if (editV2Blocked.value) {
toast(editV2BlockReason.value || 'Load still A first.')
return
}
if (v2Mode.value === 'compose' && !editRefFile.value) {
toast('Compose requires still B. This will not fall back to one-image generation.')
return
}
if (v2Mode.value === 'refine' && !refineMaskDirty.value) {
toast('Paint a mask on Still A first. Refine will not fall back to Edit.')
return
}
const hideOut = hideThumbnail.value
try {
const body = new FormData()
body.append('mode', v2Mode.value)
body.append('task', v2Mode.value === 'generate' ? 't2i' : v2Mode.value === 'refine' ? 'refine' : v2Mode.value === 'compose' ? v2Task.value : 'scene')
if (v2Mode.value !== 'generate' && file.value) body.append('image_a', file.value)
if (v2Mode.value === 'compose' && editRefFile.value) body.append('image_b', editRefFile.value)
if (v2Mode.value === 'refine' || (v2Engine.value === 'krea' && v2Mode.value !== 'generate')) {
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') {
const size = clampImageV2Size(v2Width.value, v2Height.value)
body.append('width', String(size.width))
body.append('height', String(size.height))
}
body.append('engine', v2Engine.value)
body.append('prompt', prompt.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') 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())
const v2Stack = filterLoraStackForImageEngine(
filterLoraStackForStudio(imageV2LoraStack.value, ltxEnabled.value),
v2Engine.value
)
if (v2Engine.value === 'krea') {
const klein = imageV2LoraStack.value.find(item => isKleinImageLora(item.name))
if (klein) {
toast(kleinLoraOnKreaMessage(klein.name))
return
}
}
if (v2Stack.length) body.append('loraStack', JSON.stringify(v2Stack))
const downloadName = clipFileName(clipName.value.trim() || prompt.value.split(/[.!?\n]/)[0] || 'aigen-v2')
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) {
toast(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
editChainLabel.value = ''
editOverallProgress.value = 0
editCompletedChainStep.value = 0
editActiveChainPlan.value = [{ label: v2Mode.value === 'generate' ? 'Generate' : v2Mode.value === 'refine' ? 'Refine' : v2Mode.value === 'compose' ? 'Compose' : 'Edit', prompt: prompt.value.trim() }]
startTimer('edit')
editDownloadName.value = downloadName
editJobId.value = started.jobId
outputStudioJobId.value = started.studioJobId || ''
persistActiveJob('edit', started.jobId, started.hideThumbnail === true || hideOut, started.folderLocked === true)
v2JobPending.value = true
listen('edit', started.jobId, started.hideThumbnail === true || hideOut, started.folderLocked === true)
void pollComfyHealth()
void refreshStudioQueue()
} catch (error: any) {
editSettledUi = true
const message = error?.data?.statusMessage || error?.statusMessage || error?.message || 'Image v2 failed'
editStatusMessage.value = message
toast(message)
editBusy.value = false
editStatusBusy.value = false
stopTimer('edit')
void pollComfyHealth()
}
}
async function useEditAsInput() {
if (!editResultUrl.value) return
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: stillIdFromLibraryUrl(editResultUrl.value)
})
toast('Edited still is now the input')
} catch {
toast('Could not load that still as input')
}
}
function applyEngineDefaults(engine: VideoEngineId) {
if (engine === 'ltx') {
samplerName.value = 'euler'
if (!cfgTouched.value) cfg.value = 3.5
return
}
if (samplerName.value === 'euler') samplerName.value = 'res_multistep'
if (!cfgTouched.value) cfg.value = turbo.value ? CFG_TURBO : CFG_QUALITY
}
function selectVideoEngine(engine: VideoEngineId) {
if (engine === 'ltx' && !ltxEnabled.value) return
if (videoEngine.value === engine) return
cfgTouched.value = false
videoEngine.value = engine
applyEngineDefaults(engine)
}
function applyVideoWorkflow(raw?: string) {
const parsed = coerceVideoWorkflow(raw, ltxEnabled.value)
videoEngine.value = videoEngineOf(parsed)
videoStart.value = videoStartOf(parsed)
minimaxGraph.value = 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], still?.filename || 'still.png', { 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
}
try {
await ensureVideoInputStill()
} catch {
toast('Could not load the selected still for image-to-video.')
return
}
if (!textToVideo.value && !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(), duration: item.duration, ...persistLoraFields(item.loraStack || []) }))
const hideOut = hideThumbnail.value
const folderLockedPref = false
try {
const body = new FormData()
if (file.value) body.append('image', file.value)
if (inputStillId.value) body.append('stillId', inputStillId.value)
body.append('prompt', initialPrompt)
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 || [])]
if (videoLoraStack.value.length) 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))
}
const downloadName = clipFileName(clipName.value.trim() || initialPrompt.split(/[.!?\n]/)[0] || 'aigen')
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
})
if (started.queued) {
outputStudioJobId.value = started.studioJobId || ''
outputFocus.value = 'video'
videoAwaitingBeast.value = true
statusMessage.value = 'Queued. Waiting for Beast to be free.'
const n = 1 + queued.length
toast(n > 1
? `Queued ${n} shots behind the current job.`
: 'Queued behind the current job.')
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) {
videoBusy.value = false
statusBusy.value = false
stopTimer('video')
clearActiveJob('video')
const message = error?.data?.statusMessage || error?.statusMessage || error?.message || 'Failed to start generation'
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()
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 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 (!watchingLibrary.value && !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 (!watchingLibrary.value && !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)
const keepLibraryView = watchingLibrary.value && Boolean(currentClipId.value) && payload.clipId !== currentClipId.value
if (keepLibraryView) {
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')
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()
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
try {
await $fetch('/api/interrupt', { method: 'POST', body: { jobId: id, kind } })
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')
}
}
</script>