Files
aigen/pages/index.vue
T

2766 lines
116 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-screen">
<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="text-[11px] uppercase tracking-[0.22em] text-zinc-500">MiniMax H3 · Flux.2 Klein · ComfyUI</p>
</div>
</div>
<div class="flex items-center gap-3 text-sm">
<span
class="items-center gap-2 rounded-full border px-3 py-1"
:class="[studioMode === 'video' ? 'hidden sm:inline-flex' : 'hidden xl:inline-flex opacity-60', comfyBadge.className]"
>
<span class="h-2 w-2 rounded-full" :class="comfyBadge.dot" />
{{ comfyBadge.label }}
</span>
<span
class="items-center gap-2 rounded-full border px-3 py-1"
:class="[studioMode === 'edit' ? 'hidden sm:inline-flex' : 'hidden xl:inline-flex opacity-60', imageComfyBadge.className]"
:title="imageComfyHost || 'Image edit on Beast'"
>
<span class="h-2 w-2 rounded-full" :class="imageComfyBadge.dot" />
{{ imageComfyBadge.label }}
</span>
<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>
<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 max-w-7xl gap-6 px-4 py-6 sm:px-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<section class="glass 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'"
>
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'"
>
Image edit
</button>
</div>
<h2 class="font-display text-2xl font-bold">Input</h2>
<p class="text-sm text-zinc-400">{{ studioMode === 'edit'
? 'Drop the still to edit. An optional second still applies image 2 onto image 1. Both run on Beast; files are removed from the desktop after save.'
: 'Drop a still, describe the motion, then relay the job to the local desktop.' }}</p>
</div>
<div
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="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' ? 'Image 1' : 'Choose an image' }}</p>
<p class="mt-1 text-sm text-zinc-500">Browse the library or upload a new PNG, JPG, or WEBP</p>
</div>
</div>
<div v-if="studioMode === 'edit'" 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">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">
Apply this still onto image 1
</span>
</button>
<p class="mt-2 text-xs text-zinc-500">
{{ 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 === 'edit' ? '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>
<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>
<p v-if="shotScriptMode && studioMode === 'video'" class="mb-2 text-xs text-zinc-500">
Paste a block. 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.
<span v-if="identityPrompting"> The Comfy identity prefix is prepended to every shot, and the same Picture 1 / extra stills are sent with each shot.</span>
</p>
<p v-else-if="identityPrompting" class="mb-2 text-xs text-zinc-500">
Write camera, action, and audio. That is the dynamic action prompt. We concatenate the <span class="font-medium text-zinc-300">&lt;Picture 1&gt;</span> identity prefix in front of it and send the result to <span class="font-medium text-zinc-300">Reference to Video</span>, not Image to Video.
</p>
<textarea
v-model="prompt"
:rows="shotScriptMode || identityPrompting ? 12 : 6"
class="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 focus:ring-2"
:placeholder="promptPlaceholder"
/>
<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">{{ 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">{{ 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">{{ composedIdentityPrompt }}</p>
</details>
<div v-if="shotScriptMode && studioMode === 'video'" class="mt-3 space-y-2">
<p v-if="!parsedShots.length" class="text-xs text-zinc-500">Add a first shot, then <span class="text-zinc-300">shot 2</span> for an extension.</p>
<div
v-for="(shot, index) in parsedShots"
:key="shot.n + '-' + index"
class="rounded-xl border border-white/10 bg-zinc-950/50 px-3 py-2"
>
<p class="text-xs font-medium text-amber-100">
{{ index === 0 ? 'Initial' : `Extension ${index}` }}
<span class="font-normal text-zinc-500"> · shot {{ shot.n }} · {{ formatClipDuration(duration) }}</span>
</p>
<p class="mt-1 whitespace-pre-wrap text-xs text-zinc-300">{{ identityPrompting ? buildIdentityPrompt(shot.prompt, extraIdentityPictures) : shot.prompt }}</p>
</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="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="Motion prompt for this extension…"
/>
<DurationField v-model="item.duration" class="mt-3" label="Duration" />
</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'" 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">Output preview hides library thumbs and autoplay. Click the player to reveal; click anywhere else on the page to hide it again. Folder default applies to output preview when you pick a new still.</p>
</div>
</div>
<div v-if="studioMode === 'video'" class="space-y-3">
<div>
<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="videoWorkflow === 'v1' ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 hover:border-white/20'"
@click="videoWorkflow = '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="videoWorkflow === 'v2' ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 hover:border-white/20'"
@click="videoWorkflow = 'v2'"
>
<span class="block font-semibold">v2 · identity refs</span>
<span class="text-xs text-zinc-400">Optional lock with up to 4 stills</span>
</button>
</div>
</div>
<div v-if="videoWorkflow === 'v2'" class="rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3 space-y-3">
<label class="flex cursor-pointer items-center justify-between gap-3 text-sm">
<span>
<span class="block font-medium text-zinc-200">Use identity references</span>
<span class="mt-0.5 block text-xs text-zinc-500">Off uses the start still as first-frame only. On keeps that still as Picture 1 and adds extra stills as Picture 2–5.</span>
</span>
<span class="relative inline-flex h-6 w-11 shrink-0 items-center">
<input v-model="useIdentityRefs" 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>
<div v-if="useIdentityRefs" 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="useIdentityRefs" class="text-xs text-zinc-500">Picture 1 is the start still and stays Picture 1 on every shot, including queued extensions. Extra stills map to Picture 2–5. Empty extra slots reuse the start still.</p>
</div>
</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' ? ' · matching the still' : '' }}</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">{{ videoWorkflow === 'v2' ? 6 : 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">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-20 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">{{ turbo ? 'Default 1.5 with Turbo LoRA' : 'Default 4.0 with High-Fidelity' }}</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>
<details v-else class="rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3">
<summary class="cursor-pointer text-sm font-medium">Image edit settings</summary>
<div class="mt-4 space-y-4">
<p class="text-xs text-zinc-500">
Flux.2 Klein 9B on Beast. Sidecar is off.
<span class="text-zinc-200">{{ imageComfyBadge.label }}</span>
</p>
<p class="text-[11px] text-zinc-500">
Beast {{ imageBeastHost || imageComfyHost || 'unset' }}{{ comfyOk ? (comfyBusy ? ' · busy' : ' · free') : ' · offline' }}
</p>
<label class="block text-sm">
<span class="mb-1 block text-zinc-400">Negative prompt</span>
<textarea v-model="editNegative" rows="2" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm" />
</label>
<div class="grid gap-4 sm:grid-cols-2">
<label class="block text-sm">
<span class="mb-1 block text-zinc-400">Steps · {{ editSteps }}</span>
<input v-model.number="editSteps" type="range" min="4" max="40" step="1" class="w-full">
</label>
<label class="block text-sm">
<span class="mb-1 block text-zinc-400">CFG · {{ editCfg }}</span>
<input v-model.number="editCfg" type="range" min="0.5" max="4" step="0.1" class="w-full">
</label>
<label class="block text-sm sm:col-span-2">
<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. A second still switches the two-image branch on.</p>
</div>
</details>
<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 === 'edit' ? editBlocked : videoGenerateDisabled"
@click="studioMode === 'edit' ? editImage() : generate()"
>
{{ studioMode === 'edit' ? (editBusy ? 'Editing…' : 'Edit image') : generateLabel }}
</button>
<button
v-if="studioMode === 'edit' ? editBusy : videoBusy"
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 === 'edit' && editBlockReason" class="text-xs text-zinc-400">{{ editBlockReason }}</p>
<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">Comfy was still busy after 3 minutes. 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="space-y-5">
<div ref="outputPanel" class="glass rounded-3xl p-5 sm:p-6">
<div class="flex items-center justify-between gap-3">
<h2 class="font-display text-2xl font-bold">Output</h2>
<p v-if="studioMode === 'edit'" class="text-xs text-zinc-500">
{{ editElapsedLabel }}
· step {{ editStep }}/{{ editMaxStep }}
</p>
<p v-else class="text-xs text-zinc-500">
{{ elapsedLabel }}
<template v-if="chainTotal > 1"> · chain {{ chainStep }}/{{ chainTotal }}</template>
· step {{ step }}/{{ maxStep || defaultVideoSteps(turbo, videoWorkflow) }}
</p>
</div>
<p v-if="studioMode === 'edit' && videoBusy" class="mt-2 text-xs text-zinc-500">Video is using Beast. This edit waits in the Comfy queue.</p>
<p v-else-if="studioMode === 'video' && editBusy" class="mt-2 text-xs text-zinc-500">Image edit is using Beast. Video waits until that finishes.</p>
<div v-show="studioMode === 'video'">
<div class="mt-4">
<div class="mb-2 flex items-center justify-between text-sm">
<span :class="statusBusy ? 'text-amber-200' : 'text-zinc-300'">{{ statusMessage }}</span>
<span class="text-amber-200">{{ progress }}%</span>
</div>
<p v-if="chainTotal > 1" class="mb-2 text-xs text-amber-200/80">
Step {{ chainStep }}/{{ chainTotal }}{{ chainLabel ? `: ${chainLabel}` : '' }}
<template v-if="overallProgress"> · {{ overallProgress }}% overall</template>
</p>
<ol v-if="videoBusy && activeChainPlan.length > 1" class="mb-3 space-y-1">
<li
v-for="(part, index) in activeChainPlan"
:key="index"
class="flex items-center gap-2 rounded-lg px-2 py-1 text-xs"
:class="chainPartClass(index)"
>
<span class="h-2 w-2 shrink-0 rounded-full" :class="chainPartDot(index)" />
<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>
<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: progress + '%' }" />
</div>
</div>
<div
class="relative mt-5 overflow-hidden rounded-2xl bg-black/50"
:class="{ 'sensitive-glow': awaitingReveal || lockedSave }"
:style="{ aspectRatio: `${playerSize.width} / ${playerSize.height}` }"
>
<video
v-if="videoUrl && !lockedSave"
ref="player"
:src="videoUrl"
class="h-full w-full bg-black"
:class="{ 'opacity-0': awaitingReveal }"
:controls="!awaitingReveal"
:autoplay="autoplayEnabled && !awaitingReveal"
loop
playsinline
@play="onPlayed"
/>
<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>
<div v-if="videoUrl && !lockedSave" class="mt-4 flex flex-wrap gap-3">
<a
v-if="!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>
</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."
/>
<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="studioMode === 'edit'">
<div class="mt-4">
<div class="mb-2 flex items-center justify-between text-sm">
<span :class="editStatusBusy ? 'text-amber-200' : 'text-zinc-300'">{{ editStatusMessage }}</span>
<span class="text-amber-200">{{ editProgress }}%</span>
</div>
<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: editProgress + '%' }" />
</div>
</div>
<div
class="relative mt-5 overflow-hidden rounded-2xl bg-black/50"
:class="{ 'sensitive-glow': editAwaitingReveal || editLockedSave }"
style="min-height: 12rem"
>
<img
v-if="editResultUrl && !editLockedSave"
:src="editResultUrl"
alt="Edited still"
class="max-h-96 w-full bg-black object-contain"
:class="{ 'opacity-0': editAwaitingReveal }"
>
<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>
</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>
<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>
<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 {{ browseFolder.clipCount || 0 }} clip{{ browseFolder.clipCount === 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 gap-2">
<button
v-for="folder in folders"
:key="folder.id"
type="button"
class="rounded-full border px-3 py-1 text-xs"
: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">({{ folder.unlocked ? clipsIn(folder.id).length : folder.clipCount }})</span>
<span v-if="folder.protected">{{ folder.unlocked ? '' : ' · lock' }}</span>
</button>
</div>
<p v-if="!libraryGroups.length" class="mt-4 text-sm text-zinc-500">Nothing in this folder yet. Generate into it from the left.</p>
<ul class="mt-4 space-y-3">
<li
v-for="group in libraryGroups"
:key="group.key"
class="rounded-2xl border border-white/5 bg-zinc-950/40 p-3"
>
<div class="flex gap-3">
<button type="button" class="relative h-16 w-16 shrink-0 overflow-hidden rounded-xl bg-zinc-900" @click="openClip(group.head)">
<span v-if="group.head.hideThumbnail" class="sensitive-glow block h-full w-full" />
<img v-else-if="group.head.hasThumbnail" :src="`/api/library/clips/${group.head.id}/thumb`" alt="" class="h-full w-full object-cover">
<video
v-else
:src="`/api/library/clips/${group.head.id}/video#t=0.1`"
class="h-full w-full object-cover"
muted
playsinline
preload="metadata"
/>
</button>
<div class="min-w-0 flex-1">
<div class="flex items-start gap-2">
<input
v-if="renamingId === 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(group.head, true)"
@keydown.escape.prevent="cancelRename"
@blur="saveRename(group.head, true)"
>
<p v-else class="min-w-0 flex-1 truncate text-sm font-medium">{{ collectionTitle(group) }}</p>
<div class="group relative shrink-0">
<button
type="button"
class="flex h-6 w-6 items-center justify-center rounded-full text-zinc-500 hover:text-amber-200"
:aria-label="'Copy prompt for ' + collectionTitle(group)"
title="Copy prompt"
@click.stop="copyPrompt(familyScript(group.parts))"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-4 w-4">
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4M12 8h.01" stroke-linecap="round" />
</svg>
</button>
<div class="pointer-events-none invisible absolute right-0 top-7 z-20 w-64 whitespace-pre-wrap rounded-xl border border-white/10 bg-zinc-950 p-3 text-xs leading-relaxed text-zinc-300 shadow-2xl group-hover:visible group-focus-within:visible">
{{ familyScript(group.parts) }}
</div>
</div>
</div>
<p class="text-xs text-zinc-500">{{ clipMetaLine(group.head) }}</p>
<div class="mt-2 flex flex-wrap items-center gap-3">
<button v-if="group.parts.length > 1" type="button" class="text-xs text-amber-200" @click="toggleFamily(group.key)">
{{ expandedFamilies[group.key] ? 'Collapse' : `Expand ${group.parts.length} parts` }}
</button>
<button type="button" class="text-xs text-amber-300" @click="openClip(group.head)">Open</button>
<button type="button" class="text-xs text-amber-300" @click="openExtend(group.head)">Extend</button>
<button type="button" class="text-xs text-amber-300" @click="startRename(group.head, true)">Rename</button>
<button type="button" class="text-xs text-amber-300" @click="rerun(group.head, true)">Rerun</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(group.head, $event, true)"
>
<option disabled value="">Move to…</option>
<option
v-for="folder in folders.filter(folder => folder.id !== 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="removeClip(group.head, true)">Delete</button>
</div>
</div>
</div>
<ul v-if="expandedFamilies[group.key] && group.parts.length > 1" class="mt-3 space-y-2 border-t border-white/5 pt-3">
<li
v-for="item in chronologicalParts(group.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="openClip(item)">
<span v-if="item.hideThumbnail" class="sensitive-glow block h-full w-full" />
<img v-else-if="item.hasThumbnail" :src="`/api/library/clips/${item.id}/thumb`" alt="" class="h-full w-full object-cover">
<video
v-else
:src="`/api/library/clips/${item.id}/video#t=0.1`"
class="h-full w-full object-cover"
muted
playsinline
preload="metadata"
/>
</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="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="text-xs text-zinc-500">{{ clipMetaLine(item) }}</p>
<div class="mt-1.5 flex flex-wrap items-center gap-3">
<button type="button" class="text-xs text-amber-300" @click="openClip(item)">Open</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-amber-300" @click="rerun(item)">Rerun</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="removeClip(item)">Delete</button>
</div>
</div>
</li>
</ul>
</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"
/>
<ImagePickerModal
v-if="pickerOpen"
:folders="folders"
:stills="stills"
:folder-id="folderId"
:hide-input-preview="hideInputPreview"
:heading="pickerHeading"
@close="pickerOpen = false"
@updated="applyLibrary"
@error="toast"
@select="onPickedImage"
/>
<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 w-80 rounded-2xl border border-red-400/30 bg-zinc-950/90 px-4 py-3 text-sm text-red-100 shadow-2xl"
>
{{ toast.message }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
const runtimeConfig = useRuntimeConfig()
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
}
interface LibraryStill {
id: string
folderId: string
filename: string
width: number
height: number
createdAt: number
hideInput?: boolean
}
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?: 'v1' | 'v2'
stillId?: string
sound?: boolean
hideInput?: boolean
}
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 }[]
workflow?: 'v1' | 'v2'
}
interface QueuedExtension {
id: string
prompt: string
duration: number
}
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 file = ref<File | null>(null)
const preview = ref('')
const dragging = ref(false)
const prompt = ref('')
const shotScriptMode = ref(false)
const extensionQueue = ref<QueuedExtension[]>([])
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 CFG_MIN = 1
const CFG_MAX = 10
const CFG_STEP = 0.5
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 videoBusy = ref(false)
const editBusy = ref(false)
const studioMode = ref('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 elapsedMs = ref(0)
const videoUrl = ref('')
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 editRefFile = ref<File | null>(null)
const editRefPreview = ref('')
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 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 userName = ref('')
const authEnabled = ref(false)
const authMode = ref('')
const instanceName = ref(String(runtimeConfig.public.instanceName || 'AIGen'))
const showPrivacyToggles = computed(() => /xaigen/i.test(String(instanceName.value)))
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 pickerOpen = ref(false)
const pickerSlot = ref<'main' | 'editRef' | number>('main')
const videoWorkflow = ref('v1')
const useIdentityRefs = ref(false)
const identityRefs = ref<(File | null)[]>([null, null, null, null])
const identityPreviews = ref(['', '', '', ''])
const pickerHeading = computed(() => {
if (pickerSlot.value === 'main') return studioMode.value === 'edit' ? 'Choose image 1' : 'Choose an image'
if (pickerSlot.value === 'editRef') return 'Choose image 2'
return `Choose Picture ${Number(pickerSlot.value) + 2}`
})
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 comfyBadge = computed(() => {
if (!comfyOk.value) {
return {
label: 'ComfyUI offline',
className: 'border-red-400/30 text-red-300',
dot: 'bg-red-400'
}
}
if (comfyBusy.value) {
return {
label: 'ComfyUI busy',
className: 'border-amber-400/40 text-amber-200',
dot: 'bg-amber-400'
}
}
return {
label: 'ComfyUI online',
className: 'border-emerald-400/30 text-emerald-300',
dot: 'bg-emerald-400'
}
})
const imageComfyBadge = computed(() => {
if (!imageComfyConfigured.value && !comfyOk.value) {
return {
label: 'Edit unset',
className: 'border-white/10 text-zinc-500',
dot: 'bg-zinc-500'
}
}
if (comfyOk.value && !comfyBusy.value) {
return {
label: 'Beast · Klein ready',
className: 'border-emerald-400/30 text-emerald-300',
dot: 'bg-emerald-400'
}
}
if (comfyBusy.value) {
return {
label: 'Beast · busy',
className: 'border-amber-400/40 text-amber-200',
dot: 'bg-amber-400'
}
}
return {
label: 'Beast offline',
className: 'border-red-400/30 text-red-300',
dot: 'bg-red-400'
}
})
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 'Match the still'
const size = autoResolution(imageWidth.value, imageHeight.value)
return `${size.width} × ${size.height} · from still`
})
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 videoGenerateDisabled = computed(() => {
return videoBusy.value || !file.value || !prompt.value.trim() || !folderId.value || !queueReady.value
})
const editBlocked = computed(() => {
if (editBusy.value) return true
if (!file.value || !prompt.value.trim() || !folderId.value) return true
if (!comfyOk.value && !imageComfyOk.value) return true
return false
})
const editBlockReason = computed(() => {
if (!imageComfyConfigured.value && !comfyOk.value) return 'Image edit is not configured. Set COMFY_HOST.'
if (!comfyOk.value && !imageComfyOk.value) return 'Beast is offline.'
return ''
})
const generateLabel = computed(() => {
const n = shotScriptMode.value ? Math.max(0, parsedShots.value.length - 1) : extensionQueue.value.length
if (videoBusy.value) return 'Generating…'
if (!n) return 'Generate Video'
return n === 1 ? 'Generate Initial + 1 Extension' : `Generate Initial + ${n} Extensions`
})
const parsedShots = computed(() => parseShotScript(prompt.value))
const identityPrompting = computed(() => studioMode.value === 'video' && videoWorkflow.value === 'v2' && useIdentityRefs.value)
const extraIdentityPictures = computed(() => identityRefs.value.flatMap((item, index) => item ? [index + 2] : []))
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 === '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 visibleClips = computed(() => clips.value.filter(clip => clip.folderId === browseFolderId.value && clip.hasVideo !== false))
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 currentFolder = computed(() => folders.value.find(folder => folder.id === folderId.value))
const browseFolder = computed(() => folders.value.find(folder => folder.id === browseFolderId.value))
function clipsIn(id: string) {
return clips.value.filter(clip => clip.folderId === id)
}
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 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 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 || '—'}`
return duration ? `${res} · ${duration} · ${steps} · ${seed}` : `${res} · ${steps} · ${seed}`
}
function toggleFamily(key: string) {
expandedFamilies.value = { ...expandedFamilies.value, [key]: !expandedFamilies.value[key] }
}
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 applyThumbnailDefault() {
if (!showPrivacyToggles.value) {
hideThumbnail.value = false
return
}
hideThumbnail.value = currentFolder.value ? !currentFolder.value.thumbnailDefault : false
}
function applyInstancePrivacy() {
if (showPrivacyToggles.value) return
hideInputPreview.value = false
hideThumbnail.value = false
}
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() {
const data = await $fetch<{ folders?: LibraryFolder[]; clips?: LibraryClip[]; stills?: LibraryStill[]; drafts?: RetryDraft[] }>('/api/library')
applyLibrary(data)
}
function applyComfyHealth(health: {
comfy?: { ok?: boolean; busy?: 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) {
comfyOk.value = Boolean(health?.comfy?.ok)
comfyBusy.value = Boolean(health?.comfy?.ok && health?.comfy?.busy)
imageComfyConfigured.value = Boolean(health?.imageComfy?.configured)
imageComfyOk.value = Boolean(health?.imageComfy?.ok)
imageComfyBusy.value = Boolean(health?.imageComfy?.ok && health?.imageComfy?.busy)
imageComfyHost.value = String(health?.imageComfy?.host || '')
imageBeastOk.value = Boolean(health?.imageComfy?.beast?.ok)
imageBeastBusy.value = Boolean(health?.imageComfy?.beast?.busy)
imageBeastHost.value = String(health?.imageComfy?.beast?.host || '')
imageSidecarOk.value = Boolean(health?.imageComfy?.sidecar?.ok)
imageSidecarBusy.value = Boolean(health?.imageComfy?.sidecar?.busy)
imageSidecarConfigured.value = Boolean(health?.imageComfy?.sidecar?.configured)
imageSidecarHost.value = String(health?.imageComfy?.sidecar?.host || '')
}
async function pollComfyHealth() {
const health = await $fetch<{ comfy?: { ok?: boolean; busy?: boolean } }>('/api/health').catch(() => ({ comfy: { ok: false, busy: false } }))
applyComfyHealth(health)
}
onMounted(async () => {
try {
hideInputPreview.value = localStorage.getItem('aigen-hide-input-preview') === 'true'
withSound.value = localStorage.getItem('aigen-generate-sound') !== 'false'
autoplayEnabled.value = localStorage.getItem('aigen-autoplay') === 'true'
videoWorkflow.value = localStorage.getItem('aigen-video-workflow') === 'v2' ? 'v2' : 'v1'
useIdentityRefs.value = localStorage.getItem('aigen-use-identity-refs') === 'true'
} catch { /* ignore */ }
applyInstancePrivacy()
window.addEventListener('pointerdown', 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)
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()
healthTimer = setInterval(() => {
void pollComfyHealth()
}, 5000)
await resumeActiveJob()
})
onBeforeUnmount(() => {
window.removeEventListener('pointerdown', onSitePointerDown, true)
if (healthTimer) clearInterval(healthTimer)
healthTimer = null
stopTimer()
stopListen()
closeExtend()
})
watch(folderId, () => {
applyThumbnailDefault()
})
watch(hideInputPreview, (value) => {
try {
localStorage.setItem('aigen-hide-input-preview', String(value))
} catch { /* ignore */ }
})
watch(withSound, (value) => {
try {
localStorage.setItem('aigen-generate-sound', String(value))
} catch { /* ignore */ }
})
watch(autoplayEnabled, (value) => {
try {
localStorage.setItem('aigen-autoplay', String(value))
} catch { /* ignore */ }
if (value && videoUrl.value && !awaitingReveal.value) {
player.value?.play().catch(() => null)
}
})
watch(videoWorkflow, (value) => {
try {
localStorage.setItem('aigen-video-workflow', value)
} catch { /* ignore */ }
})
watch(useIdentityRefs, (value) => {
try {
localStorage.setItem('aigen-use-identity-refs', String(value))
} catch { /* ignore */ }
})
watch(turbo, (on) => {
if (!cfgTouched.value) cfg.value = on ? CFG_TURBO : CFG_QUALITY
})
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) {
if (!Number.isFinite(value)) return turbo.value ? CFG_TURBO : CFG_QUALITY
return Math.min(CFG_MAX, Math.max(CFG_MIN, Math.round(value / CFG_STEP) * CFG_STEP))
}
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 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
}
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() {
file.value = null
preview.value = ''
imageWidth.value = 0
imageHeight.value = 0
}
function readFile(next: File, opts?: { keepThumbnailPref?: boolean }) {
file.value = next
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') {
pickerSlot.value = slot
pickerOpen.value = true
}
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) {
const slot = pickerSlot.value
pickerOpen.value = false
if (slot === 'editRef') {
setEditRef(next)
return
}
if (slot !== 'main') {
setIdentityRef(Number(slot), next)
return
}
if (hidden && showPrivacyToggles.value) hideInputPreview.value = true
readFile(next)
}
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 {
applyLibrary(await $fetch('/api/library/stills', { method: 'POST', body }))
} 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 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
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 onSitePointerDown(event: PointerEvent) {
if (!concealOutput.value || awaitingReveal.value || lockedSave.value || !videoUrl.value) return
const root = outputPanel.value
const target = event.target as Node | null
if (root && target && root.contains(target)) return
concealAndPause()
}
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 = index > 0
? parts[index - 1]
: clips.value.find(clip => clip.id === item.parentClipId)
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 })
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'
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
videoWorkflow.value = target.workflow === 'v2' ? 'v2' : 'v1'
if (typeof target.sound === 'boolean') withSound.value = target.sound
shotScriptMode.value = restoreAll
useIdentityRefs.value = false
identityRefs.value.forEach((_, index) => clearIdentityRef(index))
extensionQueue.value = restoreAll
? parts.slice(1).map(part => ({
id: crypto.randomUUID(),
prompt: part.prompt,
duration: clipGenerateDuration(part)
}))
: []
try {
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 = 'Clip settings loaded. Re-drop the start still if it is missing.'
toast('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))
if (draft.workflow === 'v2') videoWorkflow.value = 'v2'
extensionQueue.value = (draft.extensions || []).map(item => ({
id: crypto.randomUUID(),
prompt: String(item.prompt || ''),
duration: clampDuration(Number(item.duration))
}))
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 })
statusMessage.value = 'Held job restored. Generate when ComfyUI is free.'
} catch {
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)
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
}
})
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) {
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
}
}
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)
if (dest?.unlocked) browseFolderId.value = destId
statusMessage.value = dest?.unlocked
? `Moved ${family ? 'collection' : 'clip'} to ${dest.name}`
: `Moved ${family ? 'collection' : 'clip'} to ${dest?.name || 'folder'} (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 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
})
}
function removeQueuedExtension(id: string) {
extensionQueue.value = extensionQueue.value.filter(item => item.id !== id)
}
async function editImage() {
if (!file.value || !prompt.value.trim()) return
if (!folderId.value) {
toast('Choose a library folder before editing.')
return
}
editStatusBusy.value = false
editBusy.value = true
stopListen('edit')
editSettledUi = false
editProgress.value = 2
editResultUrl.value = ''
editAwaitingReveal.value = false
editLockedSave.value = false
editStatusMessage.value = 'Checking Beast ComfyUI...'
editMaxStep.value = editSteps.value
startTimer('edit')
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(editSteps.value))
body.append('cfg', String(editCfg.value))
body.append('seed', seedInput.value || 'random')
body.append('folderId', folderId.value)
body.append('hideInput', String(showPrivacyToggles.value && hideInputPreview.value))
body.append('name', clipName.value.trim())
editDownloadName.value = clipFileName(clipName.value.trim() || prompt.value.split(/[.!?\n]/)[0] || 'aigen-edit')
const started = await $fetch<{ jobId: string; steps: number; hideThumbnail?: boolean; folderLocked?: boolean }>('/api/edit', {
method: 'POST',
body
})
editJobId.value = started.jobId
editMaxStep.value = started.steps
persistActiveJob('edit', started.jobId, started.hideThumbnail === true || hideInputPreview.value, started.folderLocked === true)
listen('edit', started.jobId, started.hideThumbnail === true || hideInputPreview.value, started.folderLocked === true)
void pollComfyHealth()
} 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()
}
}
async function useEditAsInput() {
if (!editResultUrl.value) return
try {
const blob = await $fetch<Blob>(editResultUrl.value, { responseType: 'blob' })
readFile(new File([blob], `${editDownloadName.value || 'edited'}.png`, { type: blob.type || 'image/png' }))
toast('Edited still is now the input')
} catch {
toast('Could not load that still as input')
}
}
async function generate() {
if (!file.value || !prompt.value.trim()) return
if (!queueReady.value) {
toast(shotScriptMode.value ? 'Each shot in the script needs a prompt.' : 'Each queued extension needs a prompt.')
return
}
if (!folderId.value) {
toast('Choose a library folder before generating.')
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 }))
: extensionQueue.value.map(item => ({ prompt: item.prompt.trim(), duration: item.duration }))
statusBusy.value = false
videoBusy.value = true
stopListen('video')
videoSettledUi = false
progress.value = 1
videoUrl.value = ''
currentClipId.value = ''
closeExtend()
awaitingReveal.value = false
concealOutput.value = hideThumbnail.value
lockedSave.value = false
watchingLibrary.value = false
playerSize.value = outputSize.value
statusMessage.value = 'Checking ComfyUI...'
chainStep.value = 1
chainTotal.value = 1 + queued.length
chainLabel.value = queued.length ? 'Initial' : ''
overallProgress.value = 0
completedChainStep.value = 0
activeChainPlan.value = plannedChain.value.map(part => ({ ...part }))
startTimer('video')
try {
const body = new FormData()
body.append('image', file.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))
if (videoWorkflow.value === 'v2' && useIdentityRefs.value) {
identityRefs.value.forEach((ref, index) => {
if (ref) body.append(`refImage${index}`, ref)
})
}
if (activeDraftId) body.append('draftId', activeDraftId)
if (queued.length) {
body.append('extensions', JSON.stringify(queued))
}
videoDownloadName.value = clipFileName(clipName.value.trim() || initialPrompt.split(/[.!?\n]/)[0] || 'aigen')
const started = await $fetch<{ jobId: string; seed: number; width: number; height: number; steps: number; turbo: boolean; hideThumbnail?: boolean; folderLocked?: boolean }>('/api/generate', {
method: 'POST',
body
})
videoJobId.value = started.jobId
maxStep.value = started.steps
playerSize.value = { width: started.width, height: started.height }
videoSettledUi = false
persistActiveJob('video', started.jobId, started.hideThumbnail === true || hideThumbnail.value, started.folderLocked === true)
listen('video', started.jobId, started.hideThumbnail === true || hideThumbnail.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 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
const failed = payload.type === 'error' || payload.status === 'error' || payload.status === 'cancelled'
const complete = payload.type === 'complete' || payload.status === 'complete'
if (editSettledUi) return
if (complete && payload.stillId) {
editSettledUi = true
const dest = folders.value.find(folder => folder.id === folderId.value)
const locked = dest
? Boolean(dest.protected && !dest.unlocked)
: (folderLocked || payload.folderLocked === 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}`
editAwaitingReveal.value = Boolean(payload.hideThumbnail || hidden)
editLockedSave.value = false
editStatusMessage.value = 'Edit finished'
}
editBusy.value = false
editStatusBusy.value = false
stopTimer('edit')
stopListen('edit')
clearActiveJob('edit')
loadLibrary().catch(() => null)
void pollComfyHealth()
return
}
if (failed) {
editSettledUi = true
editBusy.value = false
editStatusBusy.value = false
stopTimer('edit')
stopListen('edit')
clearActiveJob('edit')
loadLibrary().catch(() => null)
void pollComfyHealth()
toast(payload.error || payload.message || 'Image edit failed')
}
}
function applyVideoEvent(payload: Record<string, any>, hidden: boolean, folderLocked: boolean) {
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.stillId) return
if (payload.type === 'checkpoint' && payload.clipId) {
completedChainStep.value = Math.max(completedChainStep.value, Number(payload.chainStep || chainStep.value) || 0)
loadLibrary().catch(() => null)
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')
loadLibrary().catch(() => null)
void pollComfyHealth()
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)
if (total > 1 && step < total) {
completedChainStep.value = Math.max(completedChainStep.value, step)
loadLibrary().catch(() => null)
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
}
statusMessage.value = `Shot ${step} of ${total} saved, but the remaining shots did not start.`
progress.value = Math.min(Number(payload.progress || progress.value || 99), 99)
toast(`Shot ${step} of ${total} saved. Remaining shots did not run — extend this clip from the library.`)
videoBusy.value = false
statusBusy.value = false
stopTimer('video')
stopListen('video')
clearActiveJob('video')
activeDraftId = ''
void pollComfyHealth()
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 = ''
loadLibrary().catch(() => null)
void pollComfyHealth()
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 = ''
loadLibrary().catch(() => null)
void pollComfyHealth()
return
}
if (failed) {
videoSettledUi = true
videoBusy.value = false
statusBusy.value = false
stopTimer('video')
stopListen('video')
clearActiveJob('video')
loadLibrary().catch(() => null)
void pollComfyHealth()
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(() => {
void tick()
}, 1000)
if (isEdit) editPollTimer = poll
else videoPollTimer = poll
}
async function cancelJob() {
const kind = 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. Sidecar is idle — edit again.'
else statusMessage.value = 'Job cancelled. ComfyUI is idle — generate again.'
} catch (error: any) {
toast(error?.statusMessage || error?.data?.statusMessage || 'Failed to interrupt ComfyUI')
if (kind === 'edit') editStatusMessage.value = 'Cancelled locally. Edit again when a box is free.'
else statusMessage.value = 'Cancelled locally. Generate again when ComfyUI 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>