3277 lines
139 KiB
Vue
3277 lines
139 KiB
Vue
<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"><Picture 1></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"
|
||
/>
|
||
<div v-if="studioMode === 'edit'" class="mt-3 space-y-3">
|
||
<div
|
||
v-for="(item, index) in editPassQueue"
|
||
:key="item.id"
|
||
class="rounded-2xl border border-white/10 bg-zinc-950/50 p-4"
|
||
>
|
||
<div class="mb-3 flex items-center justify-between gap-2">
|
||
<p class="text-sm font-medium text-zinc-200">Pass {{ index + 2 }}</p>
|
||
<button
|
||
type="button"
|
||
class="text-xs text-red-300 hover:text-red-200"
|
||
:disabled="editBusy"
|
||
@click="removeEditPass(item.id)"
|
||
>
|
||
Remove
|
||
</button>
|
||
</div>
|
||
<textarea
|
||
v-model="item.prompt"
|
||
rows="3"
|
||
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
||
placeholder="Next edit prompt, applied to the previous pass output…"
|
||
/>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
class="w-full rounded-2xl border border-dashed border-white/15 px-4 py-2.5 text-sm text-zinc-300 transition hover:border-amber-300/40 hover:text-amber-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||
:disabled="editBusy"
|
||
@click="queueEditPass"
|
||
>
|
||
+ Pass
|
||
</button>
|
||
</div>
|
||
<details v-if="identityPrompting && prompt.trim()" class="mt-2 rounded-xl border border-white/10 bg-zinc-950/40 px-3 py-2">
|
||
<summary class="cursor-pointer text-xs text-zinc-400">Prompt sent to Reference to Video</summary>
|
||
<p class="mt-2 text-[10px] uppercase tracking-wide text-zinc-500">Identity prefix</p>
|
||
<p class="mt-1 whitespace-pre-wrap text-xs text-zinc-400">{{ 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…' : editLabel) : 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 }}
|
||
<template v-if="editChainTotal > 1"> · chain {{ editChainStep }}/{{ editChainTotal }}</template>
|
||
· 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>
|
||
<p v-if="editChainTotal > 1" class="mb-2 text-xs text-amber-200/80">
|
||
Step {{ editChainStep }}/{{ editChainTotal }}{{ editChainLabel ? `: ${editChainLabel}` : '' }}
|
||
<template v-if="editOverallProgress"> · {{ editOverallProgress }}% overall</template>
|
||
</p>
|
||
<ol v-if="editBusy && editActiveChainPlan.length > 1" class="mb-3 space-y-1">
|
||
<li
|
||
v-for="(part, index) in editActiveChainPlan"
|
||
:key="index"
|
||
class="flex items-center gap-2 rounded-lg px-2 py-1 text-xs"
|
||
:class="editChainPartClass(index)"
|
||
>
|
||
<span class="h-2 w-2 shrink-0 rounded-full" :class="editChainPartDot(index)" />
|
||
<span class="shrink-0 font-medium">{{ part.label }}</span>
|
||
<span class="min-w-0 truncate text-zinc-400">{{ part.prompt }}</span>
|
||
</li>
|
||
</ol>
|
||
<div class="h-2 overflow-hidden rounded-full bg-zinc-800">
|
||
<div class="h-full rounded-full bg-amber-400 progress-glow transition-all duration-300" :style="{ width: (editOverallProgress || 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">
|
||
<div>
|
||
<h2 class="font-display text-xl font-bold">Library</h2>
|
||
<p class="mt-1 text-xs text-zinc-500">Generated outputs only. Uploaded stills live in the image picker.</p>
|
||
</div>
|
||
<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 {{ folderOutputCount(browseFolder) }} output{{ folderOutputCount(browseFolder) === 1 ? '' : 's' }}.</p>
|
||
<input v-model="unlockPassword" type="password" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm" placeholder="Folder password">
|
||
<button type="submit" class="w-full rounded-xl bg-amber-400 px-4 py-2 font-semibold text-zinc-950">Unlock</button>
|
||
</form>
|
||
|
||
<template v-else>
|
||
<div class="mt-4 flex flex-wrap 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">({{ folderOutputCount(folder) }})</span>
|
||
<span v-if="folder.protected">{{ folder.unlocked ? '' : ' · lock' }}</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="mt-3 flex flex-wrap gap-2">
|
||
<button
|
||
v-for="option in [{ id: 'all', label: 'All' }, { id: 'images', label: 'Images' }, { id: 'videos', label: 'Videos' }] as const"
|
||
:key="option.id"
|
||
type="button"
|
||
class="rounded-full border px-3 py-1 text-xs"
|
||
:class="libraryFilter === option.id ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400'"
|
||
@click="libraryFilter = option.id"
|
||
>
|
||
{{ option.label }}
|
||
</button>
|
||
</div>
|
||
|
||
<p v-if="!libraryEntries.length" class="mt-4 text-sm text-zinc-500">{{ libraryEmptyLabel }}</p>
|
||
<ul class="mt-4 space-y-3">
|
||
<li
|
||
v-for="entry in libraryEntries"
|
||
:key="entry.key"
|
||
class="rounded-2xl border border-white/5 bg-zinc-950/40 p-3"
|
||
>
|
||
<template v-if="entry.kind === 'clip'">
|
||
<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(entry.group.head)">
|
||
<span v-if="entry.group.head.hideThumbnail" class="sensitive-glow block h-full w-full" />
|
||
<img v-else-if="entry.group.head.hasThumbnail" :src="`/api/library/clips/${entry.group.head.id}/thumb`" alt="" class="h-full w-full object-cover">
|
||
<video
|
||
v-else
|
||
:src="`/api/library/clips/${entry.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 === entry.group.head.id && renamingFamily"
|
||
ref="renameInput"
|
||
v-model="renameDraft"
|
||
class="min-w-0 flex-1 rounded-lg border border-amber-300/40 bg-zinc-950 px-2 py-1 text-sm outline-none ring-amber-300/40 focus:ring-2"
|
||
maxlength="80"
|
||
@keydown.enter.prevent="saveRename(entry.group.head, true)"
|
||
@keydown.escape.prevent="cancelRename"
|
||
@blur="saveRename(entry.group.head, true)"
|
||
>
|
||
<p v-else class="min-w-0 flex-1 truncate text-sm font-medium">{{ collectionTitle(entry.group) }}</p>
|
||
<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(entry.group)"
|
||
title="Copy prompt"
|
||
@click.stop="copyPrompt(familyScript(entry.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(entry.group.parts) }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<p class="text-xs text-zinc-500">{{ clipMetaLine(entry.group.head) }}</p>
|
||
<div class="mt-2 flex flex-wrap items-center gap-3">
|
||
<button v-if="entry.group.parts.length > 1" type="button" class="text-xs text-amber-200" @click="toggleFamily(entry.group.key)">
|
||
{{ expandedFamilies[entry.group.key] ? 'Collapse' : `Expand ${entry.group.parts.length} parts` }}
|
||
</button>
|
||
<button type="button" class="text-xs text-amber-300" @click="openClip(entry.group.head)">Open</button>
|
||
<button type="button" class="text-xs text-amber-300" @click="openExtend(entry.group.head)">Extend</button>
|
||
<button type="button" class="text-xs text-amber-300" @click="startRename(entry.group.head, true)">Rename</button>
|
||
<button type="button" class="text-xs text-amber-300" @click="rerun(entry.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(entry.group.head, $event, true)"
|
||
>
|
||
<option disabled value="">Move to…</option>
|
||
<option
|
||
v-for="folder in folders.filter(folder => folder.id !== entry.group.head.folderId)"
|
||
:key="folder.id"
|
||
:value="folder.id"
|
||
>
|
||
{{ folder.name }}{{ folder.protected && !folder.unlocked ? ' · locked' : '' }}
|
||
</option>
|
||
</select>
|
||
<button type="button" class="text-xs text-red-300" @click="removeClip(entry.group.head, true)">Delete</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<ul v-if="expandedFamilies[entry.group.key] && entry.group.parts.length > 1" class="mt-3 space-y-2 border-t border-white/5 pt-3">
|
||
<li
|
||
v-for="item in chronologicalParts(entry.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>
|
||
</template>
|
||
<template v-else-if="entry.kind === 'still' && entry.stillGroup">
|
||
<div class="flex gap-3">
|
||
<button type="button" class="relative h-16 w-16 shrink-0 overflow-hidden rounded-xl bg-zinc-900" @click="openStill(entry.stillGroup.head)">
|
||
<span v-if="entry.stillGroup.head.hideInput" class="sensitive-glow block h-full w-full" />
|
||
<img
|
||
v-else
|
||
:src="`/api/library/stills/${entry.stillGroup.head.id}`"
|
||
alt=""
|
||
class="h-full w-full object-cover"
|
||
>
|
||
</button>
|
||
<div class="min-w-0 flex-1">
|
||
<div class="flex items-start gap-2">
|
||
<input
|
||
v-if="renamingId === entry.stillGroup.head.id && renamingFamily"
|
||
v-model="renameDraft"
|
||
class="min-w-0 flex-1 rounded-lg border border-amber-300/40 bg-zinc-950 px-2 py-1 text-sm outline-none ring-amber-300/40 focus:ring-2"
|
||
maxlength="80"
|
||
@keydown.enter.prevent="saveRenameStill(entry.stillGroup.head, true)"
|
||
@keydown.escape.prevent="cancelRename"
|
||
@blur="saveRenameStill(entry.stillGroup.head, true)"
|
||
>
|
||
<p v-else class="min-w-0 flex-1 truncate text-sm font-medium">{{ stillCollectionTitle(entry.stillGroup) }}</p>
|
||
<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 ' + stillCollectionTitle(entry.stillGroup)"
|
||
title="Copy prompt"
|
||
@click.stop="copyPrompt(stillFamilyScript(entry.stillGroup.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">
|
||
{{ stillFamilyScript(entry.stillGroup.parts) }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<p class="text-xs text-zinc-500">{{ stillMetaLine(entry.stillGroup.head) }}</p>
|
||
<div class="mt-2 flex flex-wrap items-center gap-3">
|
||
<button v-if="entry.stillGroup.parts.length > 1" type="button" class="text-xs text-amber-200" @click="toggleFamily(entry.stillGroup.key)">
|
||
{{ expandedFamilies[entry.stillGroup.key] ? 'Collapse' : `Expand ${entry.stillGroup.parts.length} passes` }}
|
||
</button>
|
||
<button type="button" class="text-xs text-amber-300" @click="openStill(entry.stillGroup.head)">Open</button>
|
||
<button type="button" class="text-xs text-amber-300" @click="useStillAsInput(entry.stillGroup.head)">Use as input</button>
|
||
<a
|
||
:href="`/api/library/stills/${entry.stillGroup.head.id}`"
|
||
:download="stillTitle(entry.stillGroup.head)"
|
||
class="text-xs text-amber-300"
|
||
>Download</a>
|
||
<button type="button" class="text-xs text-amber-300" @click="startRenameStill(entry.stillGroup.head, true)">Rename</button>
|
||
<select
|
||
v-if="folders.length > 1"
|
||
class="max-w-[9.5rem] rounded-lg border border-white/10 bg-zinc-950 px-1.5 py-0.5 text-xs text-amber-300"
|
||
:value="''"
|
||
@change="onMoveStill(entry.stillGroup.head, $event, true)"
|
||
>
|
||
<option disabled value="">Move to…</option>
|
||
<option
|
||
v-for="folder in folders.filter(folder => folder.id !== entry.stillGroup.head.folderId)"
|
||
:key="folder.id"
|
||
:value="folder.id"
|
||
>
|
||
{{ folder.name }}{{ folder.protected && !folder.unlocked ? ' · locked' : '' }}
|
||
</option>
|
||
</select>
|
||
<button type="button" class="text-xs text-red-300" @click="removeStill(entry.stillGroup.head, true)">Delete</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<ul v-if="expandedFamilies[entry.stillGroup.key] && entry.stillGroup.parts.length > 1" class="mt-3 space-y-2 border-t border-white/5 pt-3">
|
||
<li
|
||
v-for="item in chronologicalStills(entry.stillGroup.parts)"
|
||
:key="item.id"
|
||
class="flex gap-3 rounded-xl border border-white/5 bg-black/20 p-2"
|
||
>
|
||
<button type="button" class="relative h-12 w-12 shrink-0 overflow-hidden rounded-lg bg-zinc-900" @click="openStill(item)">
|
||
<span v-if="item.hideInput" class="sensitive-glow block h-full w-full" />
|
||
<img v-else :src="`/api/library/stills/${item.id}`" alt="" class="h-full w-full object-cover">
|
||
</button>
|
||
<div class="min-w-0 flex-1">
|
||
<div class="flex items-start gap-2">
|
||
<input
|
||
v-if="renamingId === item.id && !renamingFamily"
|
||
v-model="renameDraft"
|
||
class="min-w-0 flex-1 rounded-lg border border-amber-300/40 bg-zinc-950 px-2 py-1 text-sm outline-none ring-amber-300/40 focus:ring-2"
|
||
maxlength="80"
|
||
@keydown.enter.prevent="saveRenameStill(item)"
|
||
@keydown.escape.prevent="cancelRename"
|
||
@blur="saveRenameStill(item)"
|
||
>
|
||
<p v-else class="min-w-0 flex-1 truncate text-sm font-medium">{{ stillTitle(item) }}</p>
|
||
</div>
|
||
<p class="text-xs text-zinc-500">{{ stillMetaLine(item) }}</p>
|
||
<div class="mt-1.5 flex flex-wrap items-center gap-3">
|
||
<button type="button" class="text-xs text-amber-300" @click="openStill(item)">Open</button>
|
||
<button type="button" class="text-xs text-amber-300" @click="useStillAsInput(item)">Use as input</button>
|
||
<a :href="`/api/library/stills/${item.id}`" :download="stillTitle(item)" class="text-xs text-amber-300">Download</a>
|
||
<button type="button" class="text-xs text-amber-300" @click="startRenameStill(item)">Rename</button>
|
||
<select
|
||
v-if="folders.length > 1"
|
||
class="max-w-[9.5rem] rounded-lg border border-white/10 bg-zinc-950 px-1.5 py-0.5 text-xs text-amber-300"
|
||
:value="''"
|
||
@change="onMoveStill(item, $event)"
|
||
>
|
||
<option disabled value="">Move to…</option>
|
||
<option
|
||
v-for="folder in folders.filter(folder => folder.id !== item.folderId)"
|
||
:key="folder.id"
|
||
:value="folder.id"
|
||
>
|
||
{{ folder.name }}{{ folder.protected && !folder.unlocked ? ' · locked' : '' }}
|
||
</option>
|
||
</select>
|
||
<button type="button" class="text-xs text-red-300" @click="removeStill(item)">Delete</button>
|
||
</div>
|
||
</div>
|
||
</li>
|
||
</ul>
|
||
</template>
|
||
</li>
|
||
</ul>
|
||
</template>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
|
||
<SettingsModal
|
||
v-if="settingsOpen"
|
||
:folders="folders"
|
||
:auth-mode="authMode"
|
||
:username="userName"
|
||
@close="settingsOpen = false"
|
||
@updated="applyLibrary"
|
||
@error="toast"
|
||
@renamed="userName = $event"
|
||
/>
|
||
<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
|
||
imageCount?: number
|
||
}
|
||
|
||
interface LibraryStill {
|
||
id: string
|
||
folderId: string
|
||
filename: string
|
||
width: number
|
||
height: number
|
||
createdAt: number
|
||
hideInput?: boolean
|
||
role?: 'input' | 'output'
|
||
name?: string
|
||
prompt?: string
|
||
familyId?: string
|
||
parentStillId?: string
|
||
chainIndex?: number
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
interface QueuedEditPass {
|
||
id: string
|
||
prompt: string
|
||
}
|
||
|
||
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 editPassQueue = ref<QueuedEditPass[]>([])
|
||
const clipName = ref('')
|
||
const videoDownloadName = ref('aigen')
|
||
const editDownloadName = ref('aigen-edit')
|
||
const aspect = ref<AspectId>('auto')
|
||
const imageWidth = ref(0)
|
||
const imageHeight = ref(0)
|
||
const turbo = ref(true)
|
||
const 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 editChainStep = ref(1)
|
||
const editChainTotal = ref(1)
|
||
const editChainLabel = ref('')
|
||
const editOverallProgress = ref(0)
|
||
const editCompletedChainStep = ref(0)
|
||
const editActiveChainPlan = ref<{ label: string; prompt: string }[]>([])
|
||
const videoJobId = ref('')
|
||
const editJobId = ref('')
|
||
const currentClipId = ref('')
|
||
const extendOpen = ref(false)
|
||
const extendPrompt = ref('')
|
||
const extendDuration = ref(5)
|
||
const lastFrameUrl = ref('')
|
||
const lastFrameLoading = ref(false)
|
||
const watchingLibrary = ref(false)
|
||
let lastFrameObjectUrl = ''
|
||
let lastFrameGen = 0
|
||
const awaitingReveal = ref(false)
|
||
const concealOutput = ref(false)
|
||
const 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 libraryFilter = ref<'all' | 'images' | 'videos'>('all')
|
||
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 (editPassQueue.value.some(item => !item.prompt.trim())) 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 editLabel = computed(() => {
|
||
const n = editPassQueue.value.length
|
||
if (!n) return 'Edit image'
|
||
return n === 1 ? 'Edit image + 1 pass' : `Edit image + ${n} passes`
|
||
})
|
||
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 visibleOutputStills = computed(() => stills.value.filter(still => still.folderId === browseFolderId.value && still.role === 'output'))
|
||
const stillGroups = computed(() => {
|
||
const groups = new Map<string, LibraryStill[]>()
|
||
for (const still of visibleOutputStills.value) {
|
||
const key = stillFamilyKey(still)
|
||
const list = groups.get(key) || []
|
||
list.push(still)
|
||
groups.set(key, list)
|
||
}
|
||
return [...groups.entries()]
|
||
.map(([key, parts]) => {
|
||
const sorted = [...parts].sort(sortStillParts)
|
||
return { key, head: sorted[0], parts: sorted }
|
||
})
|
||
.sort((a, b) => b.head.createdAt - a.head.createdAt)
|
||
})
|
||
const libraryGroups = computed(() => {
|
||
const groups = new Map<string, LibraryClip[]>()
|
||
for (const clip of visibleClips.value) {
|
||
const key = clipFamilyKey(clip)
|
||
const list = groups.get(key) || []
|
||
list.push(clip)
|
||
groups.set(key, list)
|
||
}
|
||
return [...groups.entries()]
|
||
.map(([key, parts]) => {
|
||
const sorted = [...parts].sort(sortClipParts)
|
||
return { key, head: sorted[0], parts: sorted }
|
||
})
|
||
.sort((a, b) => b.head.createdAt - a.head.createdAt)
|
||
})
|
||
const libraryEntries = computed(() => {
|
||
const videos = libraryFilter.value === 'images'
|
||
? []
|
||
: libraryGroups.value.map(group => ({
|
||
kind: 'clip' as const,
|
||
key: group.key,
|
||
createdAt: group.head.createdAt,
|
||
group
|
||
}))
|
||
const images = libraryFilter.value === 'videos'
|
||
? []
|
||
: stillGroups.value.map(group => ({
|
||
kind: 'still' as const,
|
||
key: group.key,
|
||
createdAt: group.head.createdAt,
|
||
stillGroup: group
|
||
}))
|
||
return [...videos, ...images].sort((a, b) => b.createdAt - a.createdAt)
|
||
})
|
||
const libraryEmptyLabel = computed(() => {
|
||
if (libraryFilter.value === 'images') return 'No generated stills in this folder yet.'
|
||
if (libraryFilter.value === 'videos') return 'No generated clips in this folder yet.'
|
||
return 'Nothing in this folder yet. Generate into it from the left.'
|
||
})
|
||
const browseFolder = computed(() => folders.value.find(folder => folder.id === browseFolderId.value))
|
||
const currentFolder = computed(() => folders.value.find(folder => folder.id === folderId.value))
|
||
|
||
function clipsIn(id: string) {
|
||
return clips.value.filter(clip => clip.folderId === id)
|
||
}
|
||
|
||
function stillsIn(id: string) {
|
||
return stills.value.filter(still => still.folderId === id && still.role === 'output')
|
||
}
|
||
|
||
function folderOutputCount(folder: LibraryFolder) {
|
||
if (!folder.unlocked) return (folder.clipCount || 0) + (folder.imageCount || 0)
|
||
const videos = clipsIn(folder.id).length
|
||
const images = stillsIn(folder.id).length
|
||
if (libraryFilter.value === 'videos') return videos
|
||
if (libraryFilter.value === 'images') return images
|
||
return videos + images
|
||
}
|
||
|
||
function familyBaseName(name: string) {
|
||
return String(name || '').trim().replace(/\s+p\d+$/i, '')
|
||
}
|
||
|
||
function clipFamilyKey(clip: LibraryClip) {
|
||
if (clip.familyId) return `family:${clip.folderId}:${clip.familyId}`
|
||
const base = familyBaseName(clip.name || '').toLowerCase()
|
||
if (base) return `name:${clip.folderId}:${base}`
|
||
return `solo:${clip.id}`
|
||
}
|
||
|
||
function stillFamilyKey(still: LibraryStill) {
|
||
if (still.familyId) return `still-family:${still.folderId}:${still.familyId}`
|
||
return `still-solo:${still.id}`
|
||
}
|
||
|
||
function sortStillParts(a: LibraryStill, b: LibraryStill) {
|
||
const chainDelta = (b.chainIndex || 0) - (a.chainIndex || 0)
|
||
if (chainDelta) return chainDelta
|
||
return b.createdAt - a.createdAt
|
||
}
|
||
|
||
function chronologicalStills(parts: LibraryStill[]) {
|
||
return [...parts].sort((a, b) => {
|
||
const chain = (a.chainIndex || 0) - (b.chainIndex || 0)
|
||
if (chain) return chain
|
||
return a.createdAt - b.createdAt
|
||
})
|
||
}
|
||
|
||
function sortClipParts(a: LibraryClip, b: LibraryClip) {
|
||
const durationDelta = (Number(b.duration) || 0) - (Number(a.duration) || 0)
|
||
if (durationDelta) return durationDelta
|
||
const chainDelta = (b.chainIndex || 0) - (a.chainIndex || 0)
|
||
if (chainDelta) return chainDelta
|
||
return a.createdAt - b.createdAt
|
||
}
|
||
|
||
function chronologicalParts(parts: LibraryClip[]) {
|
||
return [...parts].sort((a, b) => {
|
||
const chain = (a.chainIndex || 0) - (b.chainIndex || 0)
|
||
if (chain) return chain
|
||
return a.createdAt - b.createdAt
|
||
})
|
||
}
|
||
|
||
function 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 stillTitle(still: LibraryStill) {
|
||
return still.name || still.filename.replace(/\.[a-z0-9]+$/i, '') || 'Edited still'
|
||
}
|
||
|
||
function stillCollectionTitle(group: { head: LibraryStill; parts: LibraryStill[] }) {
|
||
const named = group.parts.map(part => familyBaseName(part.name || '')).find(name => name)
|
||
return named || stillTitle(group.head)
|
||
}
|
||
|
||
function stillFamilyScript(parts: LibraryStill[]) {
|
||
const ordered = chronologicalStills(parts)
|
||
if (ordered.length <= 1) return ordered[0]?.prompt || ''
|
||
return ordered.map((part, index) => (
|
||
index === 0 ? (part.prompt || '').trim() : `pass ${index + 1}\n${(part.prompt || '').trim()}`
|
||
)).join('\n\n')
|
||
}
|
||
|
||
function stillMetaLine(still: LibraryStill) {
|
||
const res = still.width && still.height ? `${still.width}×${still.height}` : 'image'
|
||
const pass = typeof still.chainIndex === 'number' ? `pass ${still.chainIndex + 1}` : ''
|
||
if (pass && still.prompt) return `${res} · ${pass} · ${still.prompt}`
|
||
if (still.prompt) return `${res} · ${still.prompt}`
|
||
return pass ? `${res} · ${pass}` : res
|
||
}
|
||
|
||
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 editChainPartDone(index: number) {
|
||
return index < editCompletedChainStep.value
|
||
}
|
||
|
||
function editChainPartCurrent(index: number) {
|
||
return editBusy.value && index === editCompletedChainStep.value
|
||
}
|
||
|
||
function editChainPartClass(index: number) {
|
||
if (editChainPartDone(index)) return 'border border-emerald-400/20 bg-emerald-400/10 text-emerald-100'
|
||
if (editChainPartCurrent(index)) return 'border border-amber-300/40 bg-amber-400/10 text-amber-100'
|
||
return 'border border-white/5 text-zinc-400'
|
||
}
|
||
|
||
function editChainPartDot(index: number) {
|
||
if (editChainPartDone(index)) return 'bg-emerald-400'
|
||
if (editChainPartCurrent(index)) return 'bg-amber-300 animate-pulse'
|
||
return 'bg-zinc-600'
|
||
}
|
||
|
||
function 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) {
|
||
studioMode.value = 'video'
|
||
closeExtend()
|
||
watchingLibrary.value = true
|
||
currentClipId.value = item.id
|
||
lockedSave.value = false
|
||
concealOutput.value = item.hideThumbnail
|
||
videoUrl.value = clipVideoUrl(item.id)
|
||
awaitingReveal.value = item.hideThumbnail
|
||
playerSize.value = { width: item.width || 1344, height: item.height || 768 }
|
||
browseFolderId.value = item.folderId
|
||
videoDownloadName.value = clipFileName(item.name || item.prompt.split(/[.!?\n]/)[0] || 'aigen')
|
||
if (!videoBusy.value) {
|
||
statusMessage.value = item.hideThumbnail ? 'Hidden clip ready' : 'Loaded from library'
|
||
progress.value = 100
|
||
}
|
||
}
|
||
|
||
function openStill(still: LibraryStill) {
|
||
studioMode.value = 'edit'
|
||
browseFolderId.value = still.folderId
|
||
editResultUrl.value = `/api/library/stills/${still.id}`
|
||
editAwaitingReveal.value = still.hideInput === true
|
||
editLockedSave.value = false
|
||
editDownloadName.value = clipFileName(stillTitle(still))
|
||
if (!editBusy.value) {
|
||
editStatusMessage.value = still.hideInput ? 'Hidden still ready' : 'Loaded from library'
|
||
editProgress.value = 100
|
||
}
|
||
}
|
||
|
||
async function useStillAsInput(still: LibraryStill) {
|
||
try {
|
||
const blob = await $fetch<Blob>(`/api/library/stills/${still.id}`, { responseType: 'blob' })
|
||
if (still.hideInput && showPrivacyToggles.value) hideInputPreview.value = true
|
||
readFile(new File([blob], still.filename, { type: blob.type || 'image/png' }))
|
||
toast('Loaded as input. It stays in Output, not the upload library.')
|
||
} catch (error: any) {
|
||
toast(error?.data?.statusMessage || 'Could not load that still')
|
||
}
|
||
}
|
||
|
||
async function removeStill(still: LibraryStill, family = false) {
|
||
const watched = family ? stillFamilyPartsFor(still) : [still]
|
||
const label = family && watched.length > 1
|
||
? `this ${watched.length}-pass edit`
|
||
: `“${stillTitle(still)}”`
|
||
if (!confirm(`Remove ${label} from this folder?`)) return
|
||
try {
|
||
await $fetch(`/api/library/stills/${still.id}`, {
|
||
method: 'DELETE',
|
||
query: family ? { family: '1' } : undefined
|
||
})
|
||
const ids = new Set(watched.map(item => item.id))
|
||
if ([...ids].some(id => editResultUrl.value.includes(id))) {
|
||
editResultUrl.value = ''
|
||
editAwaitingReveal.value = false
|
||
editLockedSave.value = false
|
||
}
|
||
await loadLibrary()
|
||
} catch (error: any) {
|
||
toast(error?.data?.statusMessage || 'Could not delete image')
|
||
}
|
||
}
|
||
|
||
async function onMoveStill(still: LibraryStill, event: Event, family = false) {
|
||
const select = event.target as HTMLSelectElement
|
||
const destId = select.value
|
||
select.value = ''
|
||
if (!destId || destId === still.folderId) return
|
||
try {
|
||
applyLibrary(await $fetch(`/api/library/stills/${still.id}`, {
|
||
method: 'PATCH',
|
||
body: { folderId: destId, family: family || undefined }
|
||
}))
|
||
const dest = folders.value.find(folder => folder.id === destId)
|
||
if (dest?.unlocked) browseFolderId.value = destId
|
||
editStatusMessage.value = dest?.unlocked
|
||
? `Moved ${family ? 'edit chain' : 'still'} to ${dest.name}`
|
||
: `Moved ${family ? 'edit chain' : 'still'} to ${dest?.name || 'folder'} (locked)`
|
||
} catch (error: any) {
|
||
toast(error?.data?.statusMessage || 'Could not move image')
|
||
}
|
||
}
|
||
|
||
function startRenameStill(still: LibraryStill, family = false) {
|
||
renamingId.value = still.id
|
||
renamingFamily.value = family
|
||
const source = family
|
||
? (familyBaseName(still.name || '') || still.name || '')
|
||
: (still.name || stillTitle(still))
|
||
renameDraft.value = source.trim() || 'edited'
|
||
nextTick(() => {
|
||
renameInput.value?.focus()
|
||
renameInput.value?.select()
|
||
})
|
||
}
|
||
|
||
async function saveRenameStill(still: LibraryStill, family = false) {
|
||
if (renamingId.value !== still.id || renamingFamily.value !== family) return
|
||
const name = renameDraft.value.trim().slice(0, 80)
|
||
if (!name) {
|
||
toast('A name is required')
|
||
return
|
||
}
|
||
const current = family ? familyBaseName(still.name || '') : still.name
|
||
if (name === current) {
|
||
cancelRename()
|
||
return
|
||
}
|
||
try {
|
||
applyLibrary(await $fetch(`/api/library/stills/${still.id}`, {
|
||
method: 'PATCH',
|
||
body: { name, family: family || undefined }
|
||
}))
|
||
cancelRename()
|
||
if (editResultUrl.value.includes(still.id)) {
|
||
editDownloadName.value = clipFileName(name)
|
||
}
|
||
} catch (error: any) {
|
||
toast(error?.data?.statusMessage || 'Could not rename image')
|
||
}
|
||
}
|
||
|
||
function stillFamilyPartsFor(still: LibraryStill) {
|
||
const group = stillGroups.value.find(entry => entry.parts.some(part => part.id === still.id))
|
||
return chronologicalStills(group?.parts || [still])
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
function queueEditPass() {
|
||
editPassQueue.value.push({
|
||
id: crypto.randomUUID(),
|
||
prompt: ''
|
||
})
|
||
}
|
||
|
||
function removeEditPass(id: string) {
|
||
editPassQueue.value = editPassQueue.value.filter(item => item.id !== id)
|
||
}
|
||
|
||
async function editImage() {
|
||
if (!file.value || !prompt.value.trim()) return
|
||
if (editPassQueue.value.some(item => !item.prompt.trim())) {
|
||
toast('Each extra pass needs a prompt.')
|
||
return
|
||
}
|
||
if (!folderId.value) {
|
||
toast('Choose a library folder before editing.')
|
||
return
|
||
}
|
||
const extraPasses = editPassQueue.value.map(item => ({ prompt: item.prompt.trim() }))
|
||
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
|
||
editChainStep.value = 1
|
||
editChainTotal.value = 1 + extraPasses.length
|
||
editChainLabel.value = extraPasses.length ? 'Pass 1' : ''
|
||
editOverallProgress.value = 0
|
||
editCompletedChainStep.value = 0
|
||
editActiveChainPlan.value = [
|
||
{ label: 'Pass 1', prompt: prompt.value.trim() },
|
||
...extraPasses.map((item, index) => ({ label: `Pass ${index + 2}`, prompt: item.prompt }))
|
||
]
|
||
startTimer('edit')
|
||
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())
|
||
if (extraPasses.length) body.append('passes', JSON.stringify(extraPasses))
|
||
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
|
||
if (typeof payload.chainStep === 'number') editChainStep.value = payload.chainStep
|
||
if (typeof payload.chainTotal === 'number') editChainTotal.value = payload.chainTotal
|
||
if (payload.chainLabel) editChainLabel.value = String(payload.chainLabel)
|
||
if (typeof payload.overallProgress === 'number') editOverallProgress.value = payload.overallProgress
|
||
|
||
const failed = payload.type === 'error' || payload.status === 'error' || payload.status === 'cancelled'
|
||
const complete = payload.type === 'complete' || payload.status === 'complete'
|
||
if (editSettledUi) return
|
||
|
||
const dest = folders.value.find(folder => folder.id === folderId.value)
|
||
const locked = dest
|
||
? Boolean(dest.protected && !dest.unlocked)
|
||
: (folderLocked || payload.folderLocked === true)
|
||
|
||
if (payload.stillId && !locked) {
|
||
editResultUrl.value = `/api/library/stills/${payload.stillId}`
|
||
editLockedSave.value = false
|
||
if (payload.hideThumbnail || hidden) editAwaitingReveal.value = payload.type !== 'checkpoint'
|
||
}
|
||
|
||
if (payload.type === 'checkpoint' && payload.stillId) {
|
||
editCompletedChainStep.value = Math.max(editCompletedChainStep.value, Number(payload.chainStep || editChainStep.value) || 0)
|
||
loadLibrary().catch(() => null)
|
||
return
|
||
}
|
||
|
||
if (complete && payload.stillId) {
|
||
const total = Math.max(1, Number(payload.chainTotal || 0) || 0, Number(editChainTotal.value || 0) || 0)
|
||
const step = Math.max(1, Number(payload.chainStep || 0) || 0, Number(editChainStep.value || 0) || 0)
|
||
if (total > 1 && step < total) {
|
||
editCompletedChainStep.value = Math.max(editCompletedChainStep.value, step)
|
||
loadLibrary().catch(() => null)
|
||
if (locked) {
|
||
editResultUrl.value = ''
|
||
editLockedSave.value = true
|
||
}
|
||
editStatusMessage.value = `Pass ${step} of ${total} saved, but the remaining passes did not start.`
|
||
editProgress.value = Math.min(Number(payload.progress || editProgress.value || 99), 99)
|
||
toast(`Pass ${step} of ${total} saved. Remaining passes did not run.`)
|
||
editBusy.value = false
|
||
editStatusBusy.value = false
|
||
stopTimer('edit')
|
||
stopListen('edit')
|
||
clearActiveJob('edit')
|
||
void pollComfyHealth()
|
||
editSettledUi = true
|
||
return
|
||
}
|
||
editSettledUi = true
|
||
if (locked) {
|
||
editResultUrl.value = ''
|
||
editLockedSave.value = true
|
||
browseFolderId.value = folderId.value
|
||
editStatusMessage.value = 'Saved to the locked folder. Unlock it to view.'
|
||
} else {
|
||
editResultUrl.value = `/api/library/stills/${payload.stillId}`
|
||
editAwaitingReveal.value = Boolean(payload.hideThumbnail || hidden)
|
||
editLockedSave.value = false
|
||
editStatusMessage.value = total > 1 ? 'Edit chain finished' : 'Edit finished'
|
||
editCompletedChainStep.value = total
|
||
}
|
||
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>
|