Files
aigen/pages/index.vue
T

4543 lines
192 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="min-h-screen min-w-0 overflow-x-hidden">
<header class="sticky top-0 z-20 border-b border-white/5 bg-zinc-950/70 backdrop-blur-xl">
<div class="mx-auto flex max-w-7xl items-center justify-between px-4 py-4 sm:px-6">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-amber-400 text-zinc-950 font-display font-extrabold">A</div>
<div>
<h1 class="font-display text-xl font-bold leading-none">{{ instanceName }}</h1>
<p class="hidden text-[11px] uppercase tracking-[0.22em] text-zinc-500 sm:block">MiniMax H3 · LTX-2.3 · Flux.2 Klein</p>
</div>
</div>
<div class="flex items-center gap-3 text-sm">
<span
class="hidden items-center gap-2 rounded-full border px-3 py-1 sm:inline-flex"
:class="rigBadge.className"
:title="rigBadge.title"
>
<span class="h-2 w-2 rounded-full" :class="rigBadge.dot" />
{{ rigBadge.label }}
</span>
<span class="hidden md:block text-zinc-400">{{ userName }}</span>
<button type="button" class="rounded-full border border-white/10 px-3 py-1 text-zinc-300 hover:border-amber-300/50 hover:text-white" @click="settingsOpen = true">
Settings
</button>
<NuxtLink
to="/queue"
class="rounded-full border px-3 py-1 text-zinc-300 hover:border-amber-300/50 hover:text-white"
:class="queueBadgeCount ? 'border-amber-300/40 text-amber-100' : 'border-white/10'"
>
Queue<span v-if="queueBadgeCount" class="ml-1.5 text-amber-200">{{ queueBadgeCount }}</span>
</NuxtLink>
<a v-if="authEnabled" href="/api/auth/logout" class="text-zinc-400 hover:text-white">Sign out</a>
</div>
</div>
</header>
<main class="mx-auto grid min-w-0 max-w-7xl gap-6 px-4 py-6 sm:px-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<section class="glass min-w-0 max-w-full rounded-3xl p-5 sm:p-6 space-y-5">
<div>
<div class="mb-3 flex flex-wrap gap-2">
<button
type="button"
class="rounded-full border px-3 py-1 text-xs font-medium"
:class="studioMode === 'video' ? 'border-amber-300/70 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400 hover:text-white'"
@click="studioMode = 'video'; outputFocus = 'video'"
>
Video
</button>
<button
type="button"
class="rounded-full border px-3 py-1 text-xs font-medium"
:class="studioMode === 'edit' ? 'border-amber-300/70 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400 hover:text-white'"
@click="studioMode = 'edit'; outputFocus = 'edit'"
>
Image edit
</button>
</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. Files leave the desktop after save.'
: 'Drop a still, describe the motion, then send the job. Generate stays available — a long shot list takes one queue slot, and you can line up more while it runs.' }}</p>
</div>
<div
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' : (textToVideo ? 'Start still · optional' : 'Choose an image') }}</p>
<p class="mt-1 text-sm text-zinc-500">{{ textToVideo ? 'Text-to-video does not need a still. Shot 2+ will still extend from the last frame.' : 'Browse the library or upload a new PNG, JPG, or WEBP' }}</p>
</div>
</div>
<div v-if="studioMode === '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>
<select
v-model="recommendPresetId"
class="max-w-[10rem] rounded-lg border border-white/10 bg-zinc-950 px-2 py-1 text-xs text-zinc-300 outline-none ring-amber-300/40 focus:ring-2"
:disabled="recommendBusy"
:title="recommendHint"
>
<option v-for="item in promptPresets" :key="item.id" :value="item.id">{{ item.name }}</option>
</select>
<button
type="button"
class="rounded-full border px-3 py-1 text-xs font-medium disabled:cursor-not-allowed disabled:opacity-50"
:class="recommendBusy ? 'border-amber-300/40 text-amber-100' : 'border-amber-300/40 text-amber-100 hover:bg-amber-400/10'"
:disabled="recommendDisabled"
:title="recommendHint"
@click="recommendPrompt"
>
{{ recommendBusy ? 'Recommending…' : 'Recommend' }}
</button>
<label v-if="studioMode === 'video'" class="flex cursor-pointer items-center gap-2 text-xs text-zinc-400">
<span class="relative inline-flex h-5 w-9 shrink-0 items-center">
<input v-model="shotScriptMode" type="checkbox" class="peer sr-only" role="switch">
<span class="absolute inset-0 rounded-full bg-zinc-700 transition peer-checked:bg-amber-400" />
<span class="absolute left-0.5 top-0.5 h-4 w-4 rounded-full bg-white transition peer-checked:translate-x-4" />
</span>
Shot script
</label>
</div>
</div>
<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. Global locks are inherited; each shot should add one beat.
<span v-if="identityPrompting"> The Comfy identity prefix is prepended to every shot, and the same Picture 1 / extra stills are sent with each shot.</span>
</p>
<p v-else-if="identityPrompting" class="mb-2 text-xs text-zinc-500">
Write camera, action, and audio. That is the dynamic action prompt. We concatenate the <span class="font-medium text-zinc-300">&lt;Picture 1&gt;</span> identity prefix in front of it and send the result to <span class="font-medium text-zinc-300">Reference to Video</span>, not Image to Video.
</p>
<textarea
v-model="prompt"
:rows="shotScriptMode || identityPrompting ? 12 : 6"
class="hidden max-h-[min(28rem,70vh)] w-full overflow-y-auto rounded-2xl border border-white/10 bg-zinc-950/70 px-4 py-3 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 [overflow-wrap:anywhere] focus:ring-2 md:block"
:placeholder="promptPlaceholder"
/>
<button
type="button"
class="w-full rounded-2xl border border-white/10 bg-zinc-950/70 px-4 py-3 text-left md:hidden"
@click="promptDrawerOpen = true"
>
<p v-if="prompt.trim()" class="line-clamp-4 whitespace-pre-wrap text-sm text-zinc-200 [overflow-wrap:anywhere]">{{ prompt }}</p>
<p v-else class="text-sm text-zinc-600">{{ promptPlaceholder }}</p>
<span class="mt-2 inline-block text-xs text-amber-200">{{ prompt.trim() ? 'View / edit prompt' : 'Write prompt' }}</span>
</button>
<p v-if="shotScriptMode && studioMode === 'video' && parsedShots.length" class="mt-2 text-xs text-zinc-500 md:hidden">
{{ parsedShots.length }} shot{{ parsedShots.length === 1 ? '' : 's' }} in this script. Open the prompt to scroll through them.
</p>
<div v-if="recommendBusy || recommendText || recommendError" class="mt-3 max-h-64 overflow-y-auto rounded-2xl border border-amber-300/20 bg-amber-400/5 p-4">
<p class="text-xs font-medium uppercase tracking-wide text-amber-200/80">Recommended prompt</p>
<p v-if="recommendBusy" class="mt-2 text-sm text-zinc-400">{{ recommendMessage || 'Sidecar Qwen VL is reading the still…' }}</p>
<p v-else-if="recommendError" class="mt-2 text-sm text-red-300">{{ recommendError }}</p>
<div v-else-if="recommendedShots.length > 1" class="mt-2 space-y-2">
<div
v-for="shot in recommendedShots"
:key="shot.n"
class="rounded-xl border border-amber-300/15 bg-zinc-950/50 px-3 py-2"
>
<p class="text-[11px] font-medium uppercase tracking-wide text-amber-200/80">shot {{ shot.n }}</p>
<p class="mt-1 whitespace-pre-wrap text-sm text-zinc-200 [overflow-wrap:anywhere]">{{ shot.prompt }}</p>
</div>
</div>
<p v-else class="mt-2 whitespace-pre-wrap text-sm text-zinc-200 [overflow-wrap:anywhere]">{{ recommendText }}</p>
<div v-if="recommendText && !recommendBusy" class="mt-3 flex flex-wrap gap-2">
<button
type="button"
class="rounded-xl bg-amber-400 px-3 py-1.5 text-xs font-semibold text-zinc-950 disabled:opacity-50"
:disabled="recommendApplied"
@click="applyRecommend"
>
{{ recommendApplied ? 'Applied' : 'Apply to prompt' }}
</button>
<button
v-if="recommendApplied"
type="button"
class="rounded-xl border border-white/15 px-3 py-1.5 text-xs text-zinc-300 hover:border-amber-300/40"
@click="revertRecommend"
>
Revert
</button>
<button
type="button"
class="rounded-xl border border-white/15 px-3 py-1.5 text-xs text-zinc-400 hover:text-white"
@click="dismissRecommend"
>
Keep mine
</button>
</div>
</div>
<div v-if="studioMode === 'edit'" class="mt-3 space-y-3">
<div
v-for="(item, index) in editPassQueue"
:key="item.id"
class="rounded-2xl border border-white/10 bg-zinc-950/50 p-4"
>
<div class="mb-3 flex items-center justify-between gap-2">
<p class="text-sm font-medium text-zinc-200">Pass {{ index + 2 }}</p>
<button
type="button"
class="text-xs text-red-300 hover:text-red-200"
:disabled="editBusy"
@click="removeEditPass(item.id)"
>
Remove
</button>
</div>
<textarea
v-model="item.prompt"
rows="3"
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
placeholder="Next edit prompt, applied to the previous pass output…"
/>
</div>
<button
type="button"
class="w-full rounded-2xl border border-dashed border-white/15 px-4 py-2.5 text-sm text-zinc-300 transition hover:border-amber-300/40 hover:text-amber-100 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="editBusy"
@click="queueEditPass"
>
+ Pass
</button>
</div>
<details v-if="identityPrompting && prompt.trim()" class="mt-2 rounded-xl border border-white/10 bg-zinc-950/40 px-3 py-2">
<summary class="cursor-pointer text-xs text-zinc-400">Prompt sent to Reference to Video</summary>
<p class="mt-2 text-[10px] uppercase tracking-wide text-zinc-500">Identity prefix</p>
<p class="mt-1 whitespace-pre-wrap text-xs text-zinc-400 [overflow-wrap:anywhere]">{{ identityBoilerplate(extraIdentityPictures) }}</p>
<p class="mt-3 text-[10px] uppercase tracking-wide text-zinc-500">Dynamic action prompt</p>
<p class="mt-1 whitespace-pre-wrap text-xs text-zinc-300 [overflow-wrap:anywhere]">{{ identityActionPrompt }}</p>
<p class="mt-3 text-[10px] uppercase tracking-wide text-zinc-500">Concatenated result</p>
<p class="mt-1 whitespace-pre-wrap text-xs text-zinc-300 [overflow-wrap:anywhere]">{{ composedIdentityPrompt }}</p>
</details>
<div v-if="studioMode === 'video'" class="mt-3 space-y-3">
<GlobalLocksEditor v-model="globalLocks" />
<PermanenceRefStrip
:refs="familyPermanenceRefs"
label="Permanence refs"
hint="Labels for object permanence. Last-frame I2V inherits them in the prompt; they are not extra Comfy identity pictures."
:hidden="hideInputPreview"
@add="openPermanencePicker()"
@remove="removeFamilyRef"
@update-label="updateFamilyRefLabel"
/>
</div>
<div v-if="shotScriptMode && studioMode === 'video'" class="mt-3 hidden space-y-2 md:block">
<p v-if="!parsedShots.length" class="text-xs text-zinc-500">Add a first shot, then <span class="text-zinc-300">shot 2</span> for an extension.</p>
<div
v-for="(shot, index) in parsedShots"
:key="shot.n + '-' + index"
class="rounded-xl border border-white/10 bg-zinc-950/50 px-3 py-2"
>
<p class="text-xs font-medium text-amber-100">
{{ index === 0 ? 'Initial' : `Extension ${index}` }}
<span class="font-normal text-zinc-500"> · shot {{ shot.n }} · {{ formatClipDuration(duration) }}</span>
</p>
<p class="mt-1 whitespace-pre-wrap text-xs text-zinc-300 [overflow-wrap:anywhere]">{{ composedShotPreview(shot.prompt, index, shot.n) }}</p>
<PermanenceRefStrip
class="mt-2"
:refs="shotPermanenceRefs[shot.n] || []"
hint="This shot"
:hidden="hideInputPreview"
:max="4"
@add="openPermanencePicker(shot.n)"
@remove="(refIndex) => removeShotRef(shot.n, refIndex)"
@update-label="(refIndex, label) => updateShotRefLabel(shot.n, refIndex, label)"
/>
</div>
</div>
</div>
<div v-if="studioMode === 'video' && !shotScriptMode" class="space-y-3">
<div
v-for="(item, index) in extensionQueue"
:key="item.id"
class="rounded-2xl border border-white/10 bg-zinc-950/50 p-4"
>
<div class="mb-3 flex items-center justify-between gap-2">
<p class="text-sm font-medium text-zinc-200">Extension #{{ index + 1 }}</p>
<button
type="button"
class="text-xs text-red-300 hover:text-red-200"
:disabled="videoBusy"
@click="removeQueuedExtension(item.id)"
>
Remove
</button>
</div>
<textarea
v-model="item.prompt"
rows="3"
class="max-h-32 w-full overflow-y-auto rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 [overflow-wrap:anywhere] focus:ring-2 md:max-h-none"
placeholder="Motion prompt for this extension…"
/>
<DurationField v-model="item.duration" class="mt-3" label="Duration" />
<PermanenceRefStrip
class="mt-3"
:refs="shotPermanenceRefs[index + 2] || []"
hint="This extension"
:hidden="hideInputPreview"
:max="4"
@add="openPermanencePicker(index + 2)"
@remove="(refIndex) => removeShotRef(index + 2, refIndex)"
@update-label="(refIndex, label) => updateShotRefLabel(index + 2, refIndex, label)"
/>
</div>
<button
type="button"
class="w-full rounded-2xl border border-dashed border-white/15 px-4 py-2.5 text-sm text-zinc-300 transition hover:border-amber-300/40 hover:text-amber-100 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="videoBusy"
@click="queueExtension"
>
+ Queue Extension
</button>
</div>
<div>
<p class="mb-2 text-sm font-medium text-zinc-300">Save to library</p>
<div class="space-y-3">
<select v-model="folderId" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm">
<option v-for="folder in folders" :key="folder.id" :value="folder.id">
{{ folder.name }}{{ folder.protected ? (folder.unlocked ? ' · unlocked' : ' · locked') : '' }}
</option>
</select>
<label class="block text-sm">
<span class="mb-1 block text-zinc-400">Name</span>
<input
v-model="clipName"
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm"
placeholder="Shown in the library and used for the download filename"
maxlength="80"
>
</label>
<form v-if="currentFolder?.protected && !currentFolder.unlocked" class="space-y-2" @submit.prevent="unlockFolder(folderId)">
<p class="text-sm text-zinc-400">This folder is locked. You can still generate into it; unlock to view clips.</p>
<div class="flex gap-2">
<input v-model="unlockPassword" type="password" class="min-w-0 flex-1 rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm" placeholder="Folder password">
<button type="submit" class="rounded-xl bg-amber-400 px-3 py-2 text-sm font-semibold text-zinc-950">Unlock</button>
</div>
</form>
<label v-if="showPrivacyToggles" class="flex cursor-pointer items-center justify-between gap-3 rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3 text-sm">
<span class="font-medium text-zinc-200">Hide input still</span>
<span class="relative inline-flex h-6 w-11 shrink-0 items-center">
<input v-model="hideInputPreview" type="checkbox" class="peer sr-only" role="switch">
<span class="absolute inset-0 rounded-full bg-zinc-700 transition peer-checked:bg-amber-400" />
<span class="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition peer-checked:translate-x-5" />
</span>
</label>
<label v-if="showPrivacyToggles" class="flex cursor-pointer items-center justify-between gap-3 rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3 text-sm">
<span class="font-medium text-zinc-200">Hide output preview</span>
<span class="relative inline-flex h-6 w-11 shrink-0 items-center">
<input v-model="hideThumbnail" type="checkbox" class="peer sr-only" role="switch">
<span class="absolute inset-0 rounded-full bg-zinc-700 transition peer-checked:bg-amber-400" />
<span class="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition peer-checked:translate-x-5" />
</span>
</label>
<label v-if="studioMode === 'video' && !ltxVideo" class="flex cursor-pointer items-center justify-between gap-3 rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3 text-sm">
<span>
<span class="block font-medium text-zinc-200">Generate sound</span>
<span class="mt-0.5 block text-xs text-zinc-500">Off skips the audio VAE so Comfy encodes a silent clip.</span>
</span>
<span class="relative inline-flex h-6 w-11 shrink-0 items-center">
<input v-model="withSound" type="checkbox" class="peer sr-only" role="switch">
<span class="absolute inset-0 rounded-full bg-zinc-700 transition peer-checked:bg-amber-400" />
<span class="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition peer-checked:translate-x-5" />
</span>
</label>
<label v-if="studioMode === 'video'" class="flex cursor-pointer items-center justify-between gap-3 rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3 text-sm">
<span>
<span class="block font-medium text-zinc-200">Autoplay</span>
<span class="mt-0.5 block text-xs text-zinc-500">Off leaves finished clips paused until you press play.</span>
</span>
<span class="relative inline-flex h-6 w-11 shrink-0 items-center">
<input v-model="autoplayEnabled" type="checkbox" class="peer sr-only" role="switch">
<span class="absolute inset-0 rounded-full bg-zinc-700 transition peer-checked:bg-amber-400" />
<span class="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition peer-checked:translate-x-5" />
</span>
</label>
<p v-if="showPrivacyToggles" class="text-xs text-zinc-500">Output preview hides library thumbs and autoplay. Click the player or still 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">Video model</p>
<div class="grid grid-cols-2 gap-2">
<button
type="button"
class="rounded-xl border px-3 py-2 text-left text-sm"
:class="videoEngine === 'minimax' ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 hover:border-white/20'"
@click="selectVideoEngine('minimax')"
>
<span class="block font-semibold">MiniMax H3</span>
<span class="text-xs text-zinc-400">Native stereo audio. Identity refs on v2.</span>
</button>
<button
type="button"
class="rounded-xl border px-3 py-2 text-left text-sm"
:class="videoEngine === 'ltx' ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 hover:border-white/20'"
@click="selectVideoEngine('ltx')"
>
<span class="block font-semibold">LTX-2.3 PinkCherry</span>
<span class="text-xs text-zinc-400">PinkCherry + distilled LoRA. I2V and T2V.</span>
</button>
</div>
</div>
<div>
<p class="mb-2 text-xs uppercase tracking-wider text-zinc-500">Start from</p>
<div class="grid grid-cols-2 gap-2">
<button
type="button"
class="rounded-xl border px-3 py-2 text-left text-sm"
:class="videoStart === 'still' ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 hover:border-white/20'"
@click="videoStart = 'still'"
>
<span class="block font-semibold">A still</span>
<span class="text-xs text-zinc-400">Image to video. Extensions use the last frame.</span>
</button>
<button
type="button"
class="rounded-xl border px-3 py-2 text-left text-sm"
:class="videoStart === 'text' ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 hover:border-white/20'"
@click="videoStart = 'text'"
>
<span class="block font-semibold">Text only</span>
<span class="text-xs text-zinc-400">No start still. Shot 2+ still continues from last frame.</span>
</button>
</div>
<p class="mt-2 text-xs text-zinc-500">{{ videoModelHint }}</p>
</div>
<div v-if="videoEngine === 'minimax' && videoStart === 'still'">
<p class="mb-2 text-xs uppercase tracking-wider text-zinc-500">MiniMax graph</p>
<div class="grid grid-cols-2 gap-2">
<button
type="button"
class="rounded-xl border px-3 py-2 text-left text-sm"
:class="minimaxGraph === 'v1' ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 hover:border-white/20'"
@click="minimaxGraph = 'v1'"
>
<span class="block font-semibold">v1 · validated</span>
<span class="text-xs text-zinc-400">First-frame image to video</span>
</button>
<button
type="button"
class="rounded-xl border px-3 py-2 text-left text-sm"
:class="minimaxGraph === 'v2' ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 hover:border-white/20'"
@click="minimaxGraph = 'v2'"
>
<span class="block font-semibold">v2 · identity refs</span>
<span class="text-xs text-zinc-400">Optional lock with up to 4 stills</span>
</button>
</div>
</div>
<div v-if="videoEngine === 'minimax' && videoStart === 'still' && minimaxGraph === 'v2'" class="rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3 space-y-3">
<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. Extra stills are optional identity views of the same person, not extra characters.</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 only if you add them. Empty slots are left out of the graph, not filled with copies of Picture 1.</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' ? (file ? ' · matching the still' : ' · 16:9 until a still is loaded') : '' }}</p>
</div>
<div>
<p class="mb-2 text-xs uppercase tracking-wider text-zinc-500">Steps</p>
<div class="grid grid-cols-2 gap-2">
<button type="button" class="rounded-xl border px-3 py-2 text-sm" :class="turbo ? 'border-amber-300 bg-amber-400/10' : 'border-white/10'" @click="turbo = true">{{ ltxVideo ? '8 Distilled LoRA' : (videoWorkflow === 'v2' ? '6 Turbo · LoRA on' : '8 Turbo · LoRA on') }}</button>
<button type="button" class="rounded-xl border px-3 py-2 text-sm" :class="!turbo ? 'border-amber-300 bg-amber-400/10' : 'border-white/10'" @click="turbo = false">{{ ltxVideo ? '12 Quality · LoRA on' : '20 High-Fidelity · LoRA bypass' }}</button>
</div>
</div>
<div class="text-sm">
<span class="mb-1 block text-zinc-400">CFG / Guidance scale</span>
<div class="flex items-center gap-3">
<input
class="aigen-slider min-w-0 flex-1"
type="range"
:min="CFG_MIN"
:max="CFG_MAX"
:step="CFG_STEP"
:value="cfg"
:style="{ background: `linear-gradient(to right, #fbbf24 ${cfgPct}%, #27272a ${cfgPct}%)` }"
aria-label="CFG guidance scale"
@input="onCfgSlider"
>
<input
v-model.number="cfg"
type="number"
:min="CFG_MIN"
:max="CFG_MAX"
:step="CFG_STEP"
class="w-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">{{ ltxVideo ? 'Default 3.5 for PinkCherry' : (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. Shares the desktop GPU with video.
</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>
<label
v-if="studioMode === 'video' && queuedExtensionCount > 0"
class="flex cursor-pointer items-start gap-3 rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3"
>
<input v-model="queueAutoRun" type="checkbox" class="mt-1">
<span>
<span class="block text-sm font-medium text-zinc-200">Run remaining shots automatically</span>
<span class="mt-0.5 block text-xs text-zinc-500">Overnight set-and-forget. Leave this off to edit prompts on Queue (under this job) and process the next shot when you are ready.</span>
</span>
</label>
<div class="flex flex-wrap gap-3">
<button
type="button"
class="flex-1 rounded-2xl bg-amber-400 px-5 py-3 font-semibold text-zinc-950 transition hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="studioMode === 'edit' ? editBlocked : videoGenerateDisabled"
@click="studioMode === 'edit' ? editImage() : generate()"
>
{{ studioMode === 'edit' ? (editBusy ? 'Editing…' : editLabel) : generateLabel }}
</button>
<button
v-if="videoBusy || editBusy"
type="button"
class="rounded-2xl border border-red-400/40 px-4 py-3 text-red-300"
@click="cancelJob"
>
Cancel / Interrupt
</button>
</div>
<p v-if="studioMode === 'video' && videoBusy" class="text-xs text-zinc-400">The current job keeps running in Output. This submit waits in the job queue.</p>
<p v-if="studioMode === 'edit' && editBlockReason" class="text-xs text-zinc-400">{{ editBlockReason }}</p>
<div v-if="studioMode === 'video' && studioJobs.length" class="rounded-2xl border border-white/10 bg-zinc-950/40 p-4">
<div class="flex items-start justify-between gap-3">
<div>
<h3 class="text-sm font-semibold text-zinc-100">Job queue</h3>
<p class="mt-1 text-xs text-zinc-500">Each submit is one job. Shots inside a job live on the Queue page.</p>
</div>
<NuxtLink to="/queue" class="shrink-0 text-xs text-amber-200 hover:text-white">Open Queue</NuxtLink>
</div>
<ul class="mt-3 space-y-2">
<li
v-for="job in studioJobs"
:key="job.id"
class="flex items-start gap-3 rounded-xl border px-3 py-2"
:class="job.status === 'running'
? 'border-amber-300/40 bg-amber-400/5'
: job.cutIn
? 'border-sky-300/30 bg-sky-400/5'
: 'border-white/10 bg-zinc-950/50'"
>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-zinc-100">{{ job.name }}</p>
<p class="mt-0.5 text-xs text-zinc-500">
{{ studioJobStatusLabel(job) }}
· {{ studioJobShotLine(job) }}
</p>
</div>
<button
v-if="job.status === 'waiting' && canCutInStudioJob"
type="button"
class="shrink-0 rounded-full border border-sky-300/40 px-2.5 py-1 text-xs text-sky-100 hover:border-sky-200 hover:text-white"
@click="cutInStudioJob(job.id)"
>
{{ job.cutIn ? 'Next' : 'Run next' }}
</button>
<button
v-if="job.status === 'waiting' || job.status === 'held'"
type="button"
class="shrink-0 text-xs text-zinc-400 hover:text-red-200"
@click="dismissStudioJob(job.id)"
>
Remove
</button>
</li>
</ul>
</div>
<div v-if="drafts.length" class="rounded-2xl border border-amber-300/20 bg-amber-400/5 p-4">
<h3 class="text-sm font-semibold text-amber-100">Held jobs</h3>
<p class="mt-1 text-xs text-zinc-400">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="min-w-0 max-w-full space-y-5">
<div ref="outputPanel" class="glass min-w-0 max-w-full rounded-3xl p-5 sm:p-6">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3">
<h2 class="font-display text-2xl font-bold">Output</h2>
<button
v-if="canRestoreOutput"
type="button"
class="inline-flex h-8 w-8 items-center justify-center rounded-full border border-white/15 text-zinc-300 hover:border-amber-300/50 hover:text-white"
title="Load this job back into Input"
@click="restoreOutputJob"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="h-4 w-4">
<path d="M13.586 3.586a2 2 0 1 1 2.828 2.828l-.793.793-2.828-2.828.793-.793ZM11.379 5.793 3 14.172V17h2.828l8.38-8.379-2.83-2.828Z" />
</svg>
</button>
<button
v-if="canClearPreview"
type="button"
class="rounded-full border border-white/15 px-2.5 py-0.5 text-xs text-zinc-300 hover:border-amber-300/50 hover:text-white"
@click="clearPreview"
>
Clear preview
</button>
</div>
<p class="text-xs text-zinc-500">
{{ liveElapsedLabel }}
<template v-if="outputProgressKind === 'edit' && editChainTotal > 1"> · chain {{ editChainStep }}/{{ editChainTotal }}</template>
<template v-else-if="outputProgressKind === 'video' && chainTotal > 1"> · chain {{ chainStep }}/{{ chainTotal }}</template>
· step {{ liveStep }}/{{ liveMaxStep }}
</p>
</div>
<div class="mt-4">
<div class="mb-2 flex items-center justify-between gap-3 text-sm">
<span class="min-w-0 truncate" :class="liveStatusBusy ? 'text-amber-200' : 'text-zinc-300'">
<span
v-if="outputProgressKind !== studioMode"
class="mr-2 inline-flex rounded-full border border-amber-300/30 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-200"
>{{ outputProgressKind === 'edit' ? 'Edit' : 'Video' }}</span>
{{ liveStatusMessage }}
</span>
<span class="shrink-0 text-amber-200">{{ liveProgressPct }}%</span>
</div>
<p v-if="outputProgressKind === 'video' && chainTotal > 1" class="mb-2 text-xs text-amber-200/80">
Step {{ chainStep }}/{{ chainTotal }}{{ chainLabel ? `: ${chainLabel}` : '' }}
<template v-if="overallProgress"> · {{ overallProgress }}% overall</template>
</p>
<p v-else-if="outputProgressKind === 'edit' && editChainTotal > 1" class="mb-2 text-xs text-amber-200/80">
Step {{ editChainStep }}/{{ editChainTotal }}{{ editChainLabel ? `: ${editChainLabel}` : '' }}
<template v-if="editOverallProgress"> · {{ editOverallProgress }}% overall</template>
</p>
<ol v-if="outputProgressKind === 'video' && shotChecklist.length > 1 && showShotChecklist" class="mb-3 space-y-1">
<li
v-for="(part, index) in shotChecklist"
:key="index"
class="flex items-center gap-2 rounded-lg px-2 py-1 text-xs"
:class="shotCheckClass(part)"
>
<span class="flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-[10px] font-bold" :class="shotCheckMarkClass(part)">{{ shotCheckMark(part) }}</span>
<span class="shrink-0 font-medium">{{ part.label }}</span>
<span class="min-w-0 truncate text-zinc-400">{{ part.prompt }}</span>
<span class="shrink-0 text-zinc-500">{{ formatClipDuration(part.duration) }}</span>
</li>
</ol>
<p v-if="shotChecklistResume" class="mb-3 text-xs text-amber-100">
{{ shotChecklistResume }}
<NuxtLink to="/queue" class="ml-1 underline decoration-amber-300/50 underline-offset-2 hover:text-white">Open Queue</NuxtLink>
</p>
<ol v-else-if="outputProgressKind === 'edit' && editBusy && editActiveChainPlan.length > 1" class="mb-3 space-y-1">
<li
v-for="(part, index) in editActiveChainPlan"
:key="index"
class="flex items-center gap-2 rounded-lg px-2 py-1 text-xs"
:class="editChainPartClass(index)"
>
<span class="h-2 w-2 shrink-0 rounded-full" :class="editChainPartDot(index)" />
<span class="shrink-0 font-medium">{{ part.label }}</span>
<span class="min-w-0 truncate text-zinc-400">{{ part.prompt }}</span>
</li>
</ol>
<div class="h-2 overflow-hidden rounded-full bg-zinc-800">
<div class="h-full rounded-full bg-amber-400 progress-glow transition-all duration-300" :style="{ width: liveProgressPct + '%' }" />
</div>
</div>
<div v-show="outputPreviewKind === 'video'">
<div
class="relative mx-auto mt-5 w-full max-w-full overflow-hidden rounded-2xl bg-black/50"
:class="{ 'sensitive-glow': awaitingReveal || lockedSave }"
:style="{ aspectRatio: `${playerSize.width} / ${playerSize.height}`, maxHeight: 'min(70dvh, 32rem)' }"
>
<video
v-if="videoUrl && !lockedSave"
ref="player"
:src="videoUrl"
class="h-full w-full max-h-[min(70dvh,32rem)] bg-black object-contain"
:class="{ 'opacity-0': awaitingReveal }"
:controls="!awaitingReveal"
:autoplay="autoplayEnabled && !awaitingReveal"
loop
playsinline
preload="metadata"
@play="onPlayed"
@loadedmetadata="onVideoMeta"
@durationchange="onVideoMeta"
/>
<button
v-if="awaitingReveal && !lockedSave"
type="button"
class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-amber-100"
@click="revealAndPlay"
>
<span class="font-display text-lg font-bold">Ready</span>
<span class="text-sm text-amber-100/80">Click to play · preview hidden</span>
</button>
<div
v-else-if="lockedSave"
class="absolute inset-0 flex flex-col items-center justify-center gap-2 px-6 text-center text-amber-100"
>
<span class="font-display text-lg font-bold">Saved to a locked folder</span>
<span class="text-sm text-amber-100/80">Unlock that folder in the library to view this clip</span>
</div>
<div
v-else-if="!videoUrl"
class="flex h-full min-h-[12rem] items-center justify-center text-sm text-zinc-500"
>
Generated MP4 appears here
</div>
</div>
<p v-if="videoUrl && !lockedSave && !videoSeekable" class="mt-3 text-xs text-amber-200/80">
Encoding… this file cannot be scrubbed until Comfy finishes writing it.
</p>
<p v-else-if="playingLastSavedWhileBusy" class="mt-3 text-xs text-zinc-500">
Playing the last saved part. The shot still encoding cannot be scrubbed until it saves.
</p>
<div v-if="videoUrl && !lockedSave" 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="outputPreviewKind === 'edit'">
<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="mx-auto max-h-[min(70dvh,24rem)] w-full bg-black object-contain"
:class="{ 'opacity-0': editAwaitingReveal }"
>
<button
v-if="editAwaitingReveal && !editLockedSave"
type="button"
class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-amber-100"
@click="editAwaitingReveal = false"
>
<span class="font-display text-lg font-bold">Ready</span>
<span class="text-sm text-amber-100/80">Click to reveal · preview hidden</span>
</button>
<div
v-else-if="editLockedSave"
class="absolute inset-0 flex flex-col items-center justify-center gap-2 px-6 text-center text-amber-100"
>
<span class="font-display text-lg font-bold">Saved to a locked folder</span>
<span class="text-sm text-amber-100/80">Unlock that folder in the library to view this still</span>
</div>
<div
v-else-if="!editResultUrl"
class="flex h-full min-h-[12rem] items-center justify-center text-sm text-zinc-500"
>
Edited still appears here
</div>
</div>
<div v-if="editResultUrl && !editLockedSave && !editAwaitingReveal" class="mt-4 flex flex-wrap gap-3">
<a
:href="editResultUrl"
:download="`${editDownloadName || 'aigen-edit'}.png`"
class="inline-flex rounded-2xl border border-white/15 px-4 py-2 text-sm hover:border-amber-300/50"
>
Download still
</a>
<button
type="button"
class="inline-flex rounded-2xl border border-amber-300/40 px-4 py-2 text-sm text-amber-100 hover:border-amber-300/70"
@click="useEditAsInput"
>
Use as input still
</button>
</div>
</div>
</div>
<div class="glass rounded-3xl p-5 sm:p-6">
<div class="flex items-center justify-between gap-3">
<h2 class="font-display text-xl font-bold">Library</h2>
<div class="flex items-center gap-3">
<button
v-if="libraryEntries.length"
type="button"
class="text-xs text-zinc-400 hover:text-white"
@click="selectedKeys.length === libraryEntries.length ? clearSelection() : selectAllLibrary()"
>
{{ selectedKeys.length && selectedKeys.length === libraryEntries.length ? 'Clear selection' : 'Select all' }}
</button>
<button
v-if="browseFolder?.protected && browseFolder.unlocked"
type="button"
class="text-xs text-zinc-400 hover:text-white"
@click="lockFolder(browseFolderId)"
>
Lock folder
</button>
</div>
</div>
<form v-if="browseFolder?.protected && !browseFolder.unlocked" class="mt-4 space-y-3" @submit.prevent="unlockFolder(browseFolderId)">
<p class="text-sm text-zinc-400">This folder is locked. Unlock to view {{ folderOutputCount(browseFolder) }} output{{ folderOutputCount(browseFolder) === 1 ? '' : 's' }}.</p>
<input v-model="unlockPassword" type="password" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm" placeholder="Folder password">
<button type="submit" class="w-full rounded-xl bg-amber-400 px-4 py-2 font-semibold text-zinc-950">Unlock</button>
</form>
<template v-else>
<div class="mt-4 flex flex-wrap items-center justify-between gap-2">
<div class="flex min-w-0 flex-wrap gap-1.5">
<button
v-for="folder in folders"
:key="folder.id"
type="button"
class="rounded-full border px-2.5 py-1 text-[11px]"
:class="browseFolderId === folder.id ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400'"
@click="browseFolderId = folder.id"
>
{{ folder.name }}
<span class="text-zinc-500">{{ folderOutputCount(folder) }}</span>
<span v-if="folder.protected && !folder.unlocked"> · lock</span>
</button>
</div>
<div class="inline-flex rounded-full border border-white/10 p-0.5">
<button
v-for="option in [{ id: 'all', label: 'All' }, { id: 'images', label: 'Images' }, { id: 'videos', label: 'Videos' }] as const"
:key="option.id"
type="button"
class="rounded-full px-2.5 py-1 text-[11px]"
:class="libraryFilter === option.id ? 'bg-amber-400/15 text-amber-100' : 'text-zinc-500 hover:text-zinc-300'"
@click="libraryFilter = option.id"
>
{{ option.label }}
</button>
</div>
</div>
<p v-if="!libraryEntries.length" class="mt-4 text-sm text-zinc-500">{{ libraryEmptyLabel }}</p>
<div
v-if="selectedKeys.length"
class="mt-3 flex flex-wrap items-center gap-2 rounded-xl border border-amber-300/20 bg-amber-400/5 px-3 py-2 text-xs"
>
<span class="font-medium text-amber-100">{{ selectedKeys.length }} selected</span>
<select
v-if="folders.length > 1"
class="max-w-[9.5rem] rounded-lg border border-white/10 bg-zinc-950 px-1.5 py-0.5 text-amber-300"
:value="''"
@change="onMoveSelected($event)"
>
<option disabled value="">Move to…</option>
<option
v-for="folder in folders.filter(folder => folder.id !== browseFolderId)"
:key="folder.id"
:value="folder.id"
>
{{ folder.name }}{{ folder.protected && !folder.unlocked ? ' · locked' : '' }}
</option>
</select>
<button type="button" class="text-red-300 hover:text-red-200" @click="removeSelected">Delete</button>
<button type="button" class="text-zinc-400 hover:text-white" @click="clearSelection">Clear</button>
</div>
<ul class="mt-4 space-y-3">
<li
v-for="entry in libraryEntries"
:key="entry.key"
class="rounded-2xl border p-3"
:class="selectedKeys.includes(entry.key) ? 'border-amber-300/30 bg-amber-400/5' : 'border-white/5 bg-zinc-950/40'"
>
<template v-if="entry.kind === 'clip'">
<div class="flex gap-3">
<label class="flex shrink-0 items-start pt-1" @click.stop>
<input
type="checkbox"
class="mt-0.5 h-4 w-4 rounded border-white/20 bg-zinc-950 accent-amber-400"
:checked="selectedKeys.includes(entry.key)"
@click.prevent="onSelectClick(entry.key, $event)"
>
</label>
<button type="button" class="relative h-16 w-16 shrink-0 overflow-hidden rounded-xl bg-zinc-900" @click="openClip(entry.group.head)">
<span v-if="entry.group.head.hideThumbnail" class="sensitive-glow block h-full w-full" />
<img v-else-if="entry.group.head.hasThumbnail" :src="`/api/library/clips/${entry.group.head.id}/thumb`" alt="" loading="lazy" decoding="async" class="h-full w-full object-cover">
<span v-else class="block h-full w-full bg-zinc-800" />
</button>
<div class="min-w-0 flex-1">
<div class="flex items-start gap-2">
<input
v-if="renamingId === entry.group.head.id && renamingFamily"
ref="renameInput"
v-model="renameDraft"
class="min-w-0 flex-1 rounded-lg border border-amber-300/40 bg-zinc-950 px-2 py-1 text-sm outline-none ring-amber-300/40 focus:ring-2"
maxlength="80"
@keydown.enter.prevent="saveRename(entry.group.head, true)"
@keydown.escape.prevent="cancelRename"
@blur="saveRename(entry.group.head, true)"
>
<p v-else class="min-w-0 flex-1 truncate text-sm font-medium">{{ collectionTitle(entry.group) }}</p>
<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 max-h-48 w-64 max-w-[calc(100vw-2rem)] overflow-y-auto whitespace-pre-wrap break-words 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>
<p v-if="entry.group.head.globalLocks" class="mt-1 text-[11px] text-zinc-600">Global locks saved with this collection.</p>
<PermanenceRefStrip
v-if="entry.group.parts.length === 1 && !entry.group.head.hideThumbnail"
class="mt-2"
:refs="entry.group.head.permanenceRefs || []"
:hidden="entry.group.head.hideInput || hideInputPreview"
@add="openPermanencePicker(undefined, entry.group.head.id)"
@remove="(refIndex) => removeClipRef(entry.group.head, refIndex)"
@update-label="(refIndex, label) => updateClipRefLabel(entry.group.head, refIndex, label)"
/>
<div class="mt-2 flex flex-wrap items-center gap-3">
<button v-if="entry.group.parts.length > 1" type="button" class="text-xs text-amber-200" @click="toggleFamily(entry.group.key)">
{{ expandedFamilies[entry.group.key] ? 'Collapse' : `Expand ${entry.group.parts.length} parts` }}
</button>
<button 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, partIndex) in chronologicalParts(entry.group.parts)"
:key="item.id"
class="flex gap-3 rounded-xl border border-white/5 bg-black/20 p-2"
>
<ClipFrameThumbs
:hidden="item.hideThumbnail"
:still-id="item.hideThumbnail || partIndex !== 0 ? undefined : item.stillId"
:from-clip-id="item.hideThumbnail ? undefined : previousChronoClipId(entry.group.parts, partIndex)"
:clip-id="item.id"
:show-first-frame="!item.hideThumbnail && partIndex > 0"
/>
<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>
<PermanenceRefStrip
v-if="!item.hideThumbnail"
class="mt-2"
:refs="item.permanenceRefs || []"
:hidden="item.hideInput || hideInputPreview"
@add="openPermanencePicker(undefined, item.id)"
@remove="(refIndex) => removeClipRef(item, refIndex)"
@update-label="(refIndex, label) => updateClipRefLabel(item, refIndex, label)"
/>
<div class="mt-1.5 flex flex-wrap items-center gap-3">
<button 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">
<label class="flex shrink-0 items-start pt-1" @click.stop>
<input
type="checkbox"
class="mt-0.5 h-4 w-4 rounded border-white/20 bg-zinc-950 accent-amber-400"
:checked="selectedKeys.includes(entry.key)"
@click.prevent="onSelectClick(entry.key, $event)"
>
</label>
<button type="button" class="relative h-16 w-16 shrink-0 overflow-hidden rounded-xl bg-zinc-900" @click="openStill(entry.stillGroup.head)">
<span v-if="entry.stillGroup.head.hideInput" class="sensitive-glow block h-full w-full" />
<img
v-else
:src="`/api/library/stills/${entry.stillGroup.head.id}?thumb=1`"
alt=""
loading="lazy"
decoding="async"
class="h-full w-full object-cover"
>
</button>
<div class="min-w-0 flex-1">
<div class="flex items-start gap-2">
<input
v-if="renamingId === entry.stillGroup.head.id && renamingFamily"
v-model="renameDraft"
class="min-w-0 flex-1 rounded-lg border border-amber-300/40 bg-zinc-950 px-2 py-1 text-sm outline-none ring-amber-300/40 focus:ring-2"
maxlength="80"
@keydown.enter.prevent="saveRenameStill(entry.stillGroup.head, true)"
@keydown.escape.prevent="cancelRename"
@blur="saveRenameStill(entry.stillGroup.head, true)"
>
<p v-else class="min-w-0 flex-1 truncate text-sm font-medium">{{ stillCollectionTitle(entry.stillGroup) }}</p>
<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 max-h-48 w-64 max-w-[calc(100vw-2rem)] overflow-y-auto whitespace-pre-wrap break-words 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}?thumb=1`" alt="" loading="lazy" decoding="async" class="h-full w-full object-cover">
</button>
<div class="min-w-0 flex-1">
<div class="flex items-start gap-2">
<input
v-if="renamingId === item.id && !renamingFamily"
v-model="renameDraft"
class="min-w-0 flex-1 rounded-lg border border-amber-300/40 bg-zinc-950 px-2 py-1 text-sm outline-none ring-amber-300/40 focus:ring-2"
maxlength="80"
@keydown.enter.prevent="saveRenameStill(item)"
@keydown.escape.prevent="cancelRename"
@blur="saveRenameStill(item)"
>
<p v-else class="min-w-0 flex-1 truncate text-sm font-medium">{{ stillTitle(item) }}</p>
</div>
<p class="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"
/>
<PromptDrawer
:open="promptDrawerOpen"
v-model:text="prompt"
:title="studioMode === 'edit' ? 'Edit prompt' : (shotScriptMode ? 'Shot script' : 'Prompt')"
:placeholder="promptPlaceholder"
@update:open="promptDrawerOpen = $event"
/>
<ImagePickerModal
v-if="pickerOpen"
:folders="folders"
:stills="stills"
:folder-id="folderId"
:hide-input-preview="hideInputPreview"
:heading="pickerHeading"
:still-only="permanencePicker"
@close="pickerOpen = false"
@updated="applyLibrary"
@error="toast"
@select="onPickedImage"
@pick="onPickedPermanence"
/>
<div class="pointer-events-none fixed bottom-4 right-4 z-50 space-y-2">
<div
v-for="toast in toasts"
:key="toast.id"
class="pointer-events-auto max-w-[min(20rem,calc(100vw-2rem))] rounded-2xl border border-red-400/30 bg-zinc-950/90 px-4 py-3 text-sm text-red-100 shadow-2xl [overflow-wrap:anywhere]"
>
{{ toast.message }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { parseEditPasses, looksLikeShotScript } from '~/utils/parseRecommend'
import { parseShotScript } from '~/utils/parseShots'
import {
composeShotPrompt,
extractGlobalLocks,
isBlankGlobalLocks,
nextPermanenceLabel,
type PermanenceRef
} from '~/utils/globalLocks'
import {
composeVideoWorkflow,
isLtxWorkflow,
isTextToVideo,
minimaxGraphOf,
parseVideoWorkflow,
videoEngineOf,
videoStartOf,
type MiniMaxGraphId,
type VideoEngineId,
type VideoStartId
} from '~/utils/videoModels'
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?: string
stillId?: string
sound?: boolean
hideInput?: boolean
globalLocks?: string
permanenceRefs?: PermanenceRef[]
}
interface RetryDraft {
id: string
createdAt: number
folderId: string
stillId?: string
stillFilename?: string
name?: string
prompt: string
aspect: string
width: number
height: number
steps: number
turbo: boolean
seed: number
duration: number
hideThumbnail: boolean
sound: boolean
hideInput?: boolean
cfg?: number
fps?: number
samplerName?: string
scheduler?: string
extensions?: { prompt: string; duration: number; permanenceRefs?: PermanenceRef[] }[]
workflow?: string
globalLocks?: string
permanenceRefs?: PermanenceRef[]
shotPermanenceRefs?: PermanenceRef[][]
}
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 promptDrawerOpen = ref(false)
const shotScriptMode = ref(false)
const recommendPresetId = ref('enhance')
const promptPresets = ref<Array<{ id: string; name: string; description?: string; builtin?: boolean }>>([
{ id: 'enhance', name: 'Enhance', builtin: true },
{ id: 'shot-script', name: 'Shot script', builtin: true }
])
const recommendBusy = ref(false)
const recommendText = ref('')
const recommendError = ref('')
const recommendMessage = ref('')
const recommendJobId = ref('')
const recommendApplied = ref(false)
const promptBeforeRecommend = ref('')
const editPassesBeforeRecommend = ref<QueuedEditPass[]>([])
const shotScriptBeforeRecommend = ref(false)
const extensionQueue = ref<QueuedExtension[]>([])
const editPassQueue = ref<QueuedEditPass[]>([])
const clipName = ref('')
const videoDownloadName = ref('aigen')
const editDownloadName = ref('aigen-edit')
const aspect = ref<AspectId>('auto')
const imageWidth = ref(0)
const imageHeight = ref(0)
const turbo = ref(true)
const 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 QUEUE_STORE = 'aigen-active-queue'
const videoBusy = ref(false)
const editBusy = ref(false)
const studioMode = ref('video')
const outputFocus = ref<'video' | 'edit'>('video')
const imageComfyOk = ref(false)
const imageComfyBusy = ref(false)
const imageComfyConfigured = ref(false)
const imageComfyHost = ref('')
const imageBeastOk = ref(false)
const imageBeastBusy = ref(false)
const imageBeastHost = ref('')
const imageSidecarOk = ref(false)
const imageSidecarBusy = ref(false)
const imageSidecarConfigured = ref(false)
const imageSidecarHost = ref('')
const progress = ref(0)
const statusMessage = ref('Waiting for a still.')
const statusBusy = ref(false)
const step = ref(0)
const maxStep = ref(8)
const chainStep = ref(1)
const chainTotal = ref(1)
const chainLabel = ref('')
const overallProgress = ref(0)
const completedChainStep = ref(0)
const activeChainPlan = ref<{ label: string; prompt: string; duration: number }[]>([])
const expandedFamilies = ref<Record<string, boolean>>({})
const elapsedMs = ref(0)
const videoUrl = ref('')
const videoSeekable = ref(true)
const editResultUrl = ref('')
const editProgress = ref(0)
const editStatusMessage = ref('Waiting for a still.')
const editStatusBusy = ref(false)
const editStep = ref(0)
const editMaxStep = ref(20)
const editElapsedMs = ref(0)
const editAwaitingReveal = ref(false)
const editLockedSave = ref(false)
const editSteps = ref(20)
const editCfg = ref(1)
const editNegative = ref('')
const 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 concealEditOutput = ref(false)
const lockedSave = ref(false)
const playerSize = ref({ width: 1344, height: 768 })
const toasts = ref<{ id: string; message: string }[]>([])
const comfyOk = ref(false)
const comfyBusy = ref(false)
const 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 queueAutoRun = ref(false)
const queuePendingCount = ref(0)
const activeQueueId = ref('')
const liveQueue = ref<{
id: string
status: string
pendingCount: number
completedCount: number
totalCount: number
segments: Array<{ index: number; prompt: string; duration: number; status: string; clipId?: string }>
} | null>(null)
const studioJobs = ref<Array<{
id: string
status: string
name: string
prompt: string
shotCount: number
liveJobId?: string
shotQueueId?: string
stillId?: string
hideThumbnail?: boolean
folderLocked?: boolean
cutIn?: boolean
holdForCutIn?: boolean
lastError?: string
completedCount?: number
pendingCount?: number
}>>([])
const outputStudioJobId = ref('')
const pickerOpen = ref(false)
const pickerSlot = ref<'main' | 'editRef' | number | 'perm' | `perm:${number}` | `clip:${string}`>('main')
const libraryFilter = ref<'all' | 'images' | 'videos'>('all')
const selectedKeys = ref<string[]>([])
const lastSelectedKey = ref('')
const videoEngine = ref<VideoEngineId>('minimax')
const videoStart = ref<VideoStartId>('still')
const minimaxGraph = ref<MiniMaxGraphId>('v1')
const videoWorkflow = computed(() => composeVideoWorkflow(videoEngine.value, videoStart.value, minimaxGraph.value))
const textToVideo = computed(() => isTextToVideo(videoWorkflow.value))
const ltxVideo = computed(() => isLtxWorkflow(videoWorkflow.value))
const useIdentityRefs = ref(false)
const identityRefs = ref<(File | null)[]>([null, null, null, null])
const identityPreviews = ref(['', '', '', ''])
const globalLocks = ref('')
const familyPermanenceRefs = ref<PermanenceRef[]>([])
const shotPermanenceRefs = ref<Record<number, PermanenceRef[]>>({})
const LOCKS_STORE = 'aigen-global-locks'
const REFS_STORE = 'aigen-permanence-refs'
const pickerHeading = computed(() => {
if (pickerSlot.value === 'main') return studioMode.value === 'edit' ? 'Choose image 1' : 'Choose an image'
if (pickerSlot.value === 'editRef') return 'Choose image 2'
if (typeof pickerSlot.value === 'string') return 'Choose a permanence still'
return `Choose Picture ${Number(pickerSlot.value) + 2}`
})
const permanencePicker = computed(() => typeof pickerSlot.value === 'string' && pickerSlot.value !== 'main' && pickerSlot.value !== 'editRef')
let videoTimer: ReturnType<typeof setInterval> | null = null
let editTimer: ReturnType<typeof setInterval> | null = null
let videoSource: EventSource | null = null
let editSource: EventSource | null = null
let videoPollTimer: ReturnType<typeof setInterval> | null = null
let editPollTimer: ReturnType<typeof setInterval> | null = null
let healthTimer: ReturnType<typeof setInterval> | null = null
let videoSettledUi = false
let editSettledUi = false
let videoListenGen = 0
let editListenGen = 0
let activeDraftId = ''
function formatElapsed(ms: number) {
const seconds = Math.floor(ms / 1000)
const mm = String(Math.floor(seconds / 60)).padStart(2, '0')
const ss = String(seconds % 60).padStart(2, '0')
return `${mm}:${ss}`
}
const elapsedLabel = computed(() => formatElapsed(elapsedMs.value))
const editElapsedLabel = computed(() => formatElapsed(editElapsedMs.value))
const outputProgressKind = computed<'video' | 'edit'>(() => {
if (editBusy.value && !videoBusy.value) return 'edit'
if (videoBusy.value && !editBusy.value) return 'video'
return outputFocus.value
})
const outputPreviewKind = computed<'video' | 'edit'>(() => {
if (outputFocus.value === 'video') {
if (editBusy.value && !videoBusy.value && !videoUrl.value) return 'edit'
return 'video'
}
if (videoBusy.value && !editBusy.value && !editResultUrl.value) return 'video'
return 'edit'
})
const liveElapsedLabel = computed(() => (
outputProgressKind.value === 'edit' ? editElapsedLabel.value : elapsedLabel.value
))
const playingLastSavedWhileBusy = computed(() => (
videoBusy.value && Boolean(videoUrl.value) && !watchingLibrary.value && !lockedSave.value
))
const liveStatusMessage = computed(() => (
outputProgressKind.value === 'edit' ? editStatusMessage.value : statusMessage.value
))
const liveStatusBusy = computed(() => (
outputProgressKind.value === 'edit' ? editStatusBusy.value : statusBusy.value
))
const liveProgressPct = computed(() => (
outputProgressKind.value === 'edit'
? (editOverallProgress.value || editProgress.value)
: progress.value
))
const liveStep = computed(() => (
outputProgressKind.value === 'edit' ? editStep.value : step.value
))
const liveMaxStep = computed(() => (
outputProgressKind.value === 'edit'
? editMaxStep.value
: (maxStep.value || defaultVideoSteps(turbo.value, videoWorkflow.value))
))
const rigBusy = computed(() => (
comfyBusy.value || imageComfyBusy.value || videoBusy.value || editBusy.value
))
const rigBadge = computed(() => {
if (!comfyOk.value && !imageComfyOk.value) {
return {
label: 'Offline',
className: 'border-red-400/30 text-red-300',
dot: 'bg-red-400',
title: 'Desktop GPU is unreachable'
}
}
if (rigBusy.value) {
return {
label: 'Live',
className: 'border-amber-400/40 text-amber-200',
dot: 'bg-amber-400 animate-pulse',
title: 'Desktop GPU is rendering'
}
}
return {
label: 'Ready',
className: 'border-emerald-400/30 text-emerald-300',
dot: 'bg-emerald-400',
title: 'Desktop GPU is free'
}
})
const canClearPreview = computed(() => Boolean(
videoUrl.value || editResultUrl.value || extendOpen.value || lockedSave.value || editLockedSave.value
))
function snap32(value: number) {
return Math.max(32, Math.round(value / 32) * 32)
}
function autoResolution(srcWidth: number, srcHeight: number) {
const ratio = srcWidth / Math.max(srcHeight, 1)
let width: number
let height: number
if (ratio >= 1) {
height = 768
width = Math.min(1344, height * ratio)
} else {
width = 768
height = Math.min(1344, width / ratio)
}
width = snap32(width)
height = snap32(height)
if (width > 1344) width = 1344
if (height > 1344) height = 1344
return { width: Math.max(32, width), height: Math.max(32, height) }
}
function normalizeAspect(raw?: string): AspectId {
if (raw === 'auto' || raw === '16:9' || raw === '9:16' || raw === '1:1') return raw
return 'auto'
}
const outputSize = computed(() => {
if (aspect.value !== 'auto') return PRESETS[aspect.value]
if (imageWidth.value && imageHeight.value) return autoResolution(imageWidth.value, imageHeight.value)
return { width: 1344, height: 768 }
})
const autoHint = computed(() => {
if (!imageWidth.value || !imageHeight.value) {
return textToVideo.value ? '16:9 without a still' : 'Match the still'
}
const size = autoResolution(imageWidth.value, imageHeight.value)
return `${size.width} × ${size.height} · from still`
})
const videoModelHint = computed(() => {
if (ltxVideo.value) {
return textToVideo.value
? 'PinkCherry text-to-video. Distilled LoRA stays on. Silent MP4 — LTX has no audio VAE here.'
: 'PinkCherry image-to-video. Distilled LoRA stays on. Silent MP4 — LTX has no audio VAE here.'
}
if (textToVideo.value) return 'MiniMax H3 text-to-video with native stereo audio. Shot 2+ switches to last-frame image-to-video.'
if (videoWorkflow.value === 'v2') return 'MiniMax H3 image-to-video. Identity refs stay on v2 with a start still.'
return 'MiniMax H3 image-to-video with native stereo audio.'
})
const frameCount = computed(() => Math.max(5, Math.floor(duration.value * fps.value)))
const cfgPct = computed(() => ((cfg.value - CFG_MIN) / (CFG_MAX - CFG_MIN)) * 100)
const currentClip = computed(() => clips.value.find(clip => clip.id === currentClipId.value))
const canExtend = computed(() => Boolean(
videoUrl.value
&& !lockedSave.value
&& (currentClipId.value || clipIdFromPlayer())
))
const extendSaveName = computed(() => nextClipPartName(currentClip.value?.name || currentClip.value?.prompt || ''))
const queueReady = computed(() => {
if (shotScriptMode.value) return parsedShots.value.length > 0 && parsedShots.value.every(shot => shot.prompt.trim())
return extensionQueue.value.every(item => item.prompt.trim())
})
const videoGenerateDisabled = computed(() => {
return (!textToVideo.value && !file.value) || !prompt.value.trim() || !folderId.value || !queueReady.value
})
const recommendDisabled = computed(() => {
if (recommendBusy.value) return true
if (!file.value || !prompt.value.trim()) return true
if (!imageSidecarConfigured.value) return true
return false
})
const recommendHint = computed(() => {
if (!imageSidecarConfigured.value) return 'Sidecar Qwen VL is not configured. Set IMAGE_COMFY_HOST.'
if (!file.value) return 'Load a still first.'
if (!prompt.value.trim()) return 'Type a draft prompt to enhance.'
if (!imageSidecarOk.value) return 'Sidecar is offline. Recommend will start Qwen VL on 192.168.77.101.'
if (imageSidecarBusy.value) return 'Sidecar is busy — this may wait in its queue.'
return 'Send this still and draft to Sidecar Qwen VL.'
})
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 'Desktop GPU is offline.'
return ''
})
const generateLabel = computed(() => {
const n = queuedExtensionCount.value
if (videoBusy.value) return n ? `Queue ${n + 1} shots` : 'Queue job'
if (!n) return 'Generate Video'
return n === 1 ? 'Generate Initial + 1 Extension' : `Generate Initial + ${n} Extensions`
})
const studioWaitingCount = computed(() => studioJobs.value.filter(job => job.status === 'waiting').length)
const queueBadgeCount = computed(() => studioWaitingCount.value || queuePendingCount.value)
const canCutInStudioJob = computed(() => studioJobs.value.some(job => job.status === 'running' || job.status === 'held') || videoBusy.value)
const outputRestoreId = computed(() => (
outputStudioJobId.value
|| studioJobs.value.find(job => job.liveJobId && job.liveJobId === videoJobId.value)?.id
|| ''
))
const canRestoreOutput = computed(() => Boolean(outputRestoreId.value && outputProgressKind.value === 'video'))
const queuedExtensionCount = computed(() => (
shotScriptMode.value ? Math.max(0, parsedShots.value.length - 1) : extensionQueue.value.length
))
const editLabel = computed(() => {
const n = editPassQueue.value.length
if (!n) return 'Edit image'
return n === 1 ? 'Edit image + 1 pass' : `Edit image + ${n} passes`
})
const parsedShots = computed(() => parseShotScript(prompt.value))
const recommendedShots = computed(() => {
if (!recommendText.value || recommendBusy.value) return []
return parseShotScript(recommendText.value)
})
const identityPrompting = computed(() => studioMode.value === 'video' && videoWorkflow.value === 'v2' && useIdentityRefs.value)
const extraIdentityPictures = computed(() => identityRefs.value.flatMap((item, index) => item ? [index + 2] : []))
function composedShotPreview(text: string, index: number, shotN?: number) {
const composed = composeShotPrompt({
globalLocks: globalLocks.value,
prompt: text,
shotIndex: index,
familyRefs: familyPermanenceRefs.value,
shotRefs: shotPermanenceRefs.value[shotN ?? index + 1]
})
return identityPrompting.value ? buildIdentityPrompt(composed, extraIdentityPictures.value) : composed
}
const identityActionPrompt = computed(() => {
if (shotScriptMode.value) return parsedShots.value[0]?.prompt || prompt.value
return prompt.value
})
const composedIdentityPrompt = computed(() => {
return buildIdentityPrompt(identityActionPrompt.value, extraIdentityPictures.value)
})
const promptPlaceholder = computed(() => {
if (studioMode.value === 'edit') {
return editRefFile.value
? 'Apply the dress in image 2 to the person in image 1. Preserve the exact features, proportions, pose, and setting of image 1.'
: 'Make the lighting warmer. Replace the background with a rainy street at night. Keep the face unchanged.'
}
if (identityPrompting.value) {
return shotScriptMode.value
? `${IDENTITY_ACTION_TEMPLATE}\n\nshot 2\nThe subject from <Picture 1> continues along the path as the camera eases past her.`
: IDENTITY_ACTION_TEMPLATE
}
if (shotScriptMode.value) {
return 'A woman walks into the village.\n\nshot 2\nShe crosses the stone bridge at dusk.\n\nshot 3\nShe opens the shop door and steps inside.'
}
return 'Camera slowly pushes in. Soft daylight, fabric ripples, dust motes in the air. Audio: quiet room tone, distant birds.'
})
const plannedChain = computed(() => {
if (shotScriptMode.value) {
return parsedShots.value.map((shot, index) => ({
label: index === 0 ? 'Initial' : `Extension ${index}`,
prompt: shot.prompt,
duration: duration.value
}))
}
return [
{ label: 'Initial', prompt: prompt.value, duration: duration.value },
...extensionQueue.value.map((item, index) => ({
label: `Extension ${index + 1}`,
prompt: item.prompt,
duration: item.duration
}))
]
})
const shotChecklist = computed(() => {
if (liveQueue.value?.segments?.length) {
return liveQueue.value.segments.map(segment => ({
label: `Shot ${segment.index + 1}`,
prompt: segment.prompt,
duration: segment.duration,
status: segment.status,
clipId: segment.clipId
}))
}
return activeChainPlan.value.map((part, index) => ({
label: part.label,
prompt: part.prompt,
duration: part.duration,
status: index < completedChainStep.value
? 'complete'
: (videoBusy.value && index === completedChainStep.value ? 'running' : (index === completedChainStep.value ? 'pending' : 'pending')),
clipId: undefined as string | undefined
}))
})
const showShotChecklist = computed(() => (
shotChecklist.value.length > 1 && (videoBusy.value || shotChecklist.value.some(part => part.status !== 'complete'))
))
const shotChecklistResume = computed(() => {
if (videoBusy.value) return ''
const next = shotChecklist.value.find(part => part.status === 'pending' || part.status === 'error' || part.status === 'running')
const done = shotChecklist.value.filter(part => part.status === 'complete').length
if (!next || !done) return ''
return `Left off after shot ${done} of ${shotChecklist.value.length}. Continue with ${next.label}.`
})
const visibleClips = computed(() => clips.value.filter(clip => clip.folderId === browseFolderId.value && clip.hasVideo !== false))
const visibleOutputStills = computed(() => stills.value.filter(still => still.folderId === browseFolderId.value && still.role === 'output'))
const stillGroups = computed(() => {
const groups = new Map<string, LibraryStill[]>()
for (const still of visibleOutputStills.value) {
const key = stillFamilyKey(still)
const list = groups.get(key) || []
list.push(still)
groups.set(key, list)
}
return [...groups.entries()]
.map(([key, parts]) => {
const sorted = [...parts].sort(sortStillParts)
return { key, head: sorted[0], parts: sorted }
})
.sort((a, b) => b.head.createdAt - a.head.createdAt)
})
const libraryGroups = computed(() => {
const groups = new Map<string, LibraryClip[]>()
for (const clip of visibleClips.value) {
const key = clipFamilyKey(clip)
const list = groups.get(key) || []
list.push(clip)
groups.set(key, list)
}
return [...groups.entries()]
.map(([key, parts]) => {
const sorted = [...parts].sort(sortClipParts)
return { key, head: sorted[0], parts: sorted }
})
.sort((a, b) => b.head.createdAt - a.head.createdAt)
})
const libraryEntries = computed(() => {
const videos = libraryFilter.value === 'images'
? []
: libraryGroups.value.map(group => ({
kind: 'clip' as const,
key: group.key,
createdAt: group.head.createdAt,
group
}))
const images = libraryFilter.value === 'videos'
? []
: stillGroups.value.map(group => ({
kind: 'still' as const,
key: group.key,
createdAt: group.head.createdAt,
stillGroup: group
}))
return [...videos, ...images].sort((a, b) => b.createdAt - a.createdAt)
})
const libraryEmptyLabel = computed(() => {
if (libraryFilter.value === 'images') return 'No generated stills in this folder yet.'
if (libraryFilter.value === 'videos') return 'No generated clips in this folder yet.'
return 'Nothing in this folder yet. Generate into it from the left.'
})
const browseFolder = computed(() => folders.value.find(folder => folder.id === browseFolderId.value))
const currentFolder = computed(() => folders.value.find(folder => folder.id === folderId.value))
function clipsIn(id: string) {
return clips.value.filter(clip => clip.folderId === id)
}
function stillsIn(id: string) {
return stills.value.filter(still => still.folderId === id && still.role === 'output')
}
function folderOutputCount(folder: LibraryFolder) {
if (!folder.unlocked) return (folder.clipCount || 0) + (folder.imageCount || 0)
const videos = clipsIn(folder.id).length
const images = stillsIn(folder.id).length
if (libraryFilter.value === 'videos') return videos
if (libraryFilter.value === 'images') return images
return videos + images
}
function familyBaseName(name: string) {
return String(name || '').trim().replace(/\s+p\d+$/i, '')
}
function clipFamilyKey(clip: LibraryClip) {
if (clip.familyId) return `family:${clip.folderId}:${clip.familyId}`
const base = familyBaseName(clip.name || '').toLowerCase()
if (base) return `name:${clip.folderId}:${base}`
return `solo:${clip.id}`
}
function stillFamilyKey(still: LibraryStill) {
if (still.familyId) return `still-family:${still.folderId}:${still.familyId}`
return `still-solo:${still.id}`
}
function sortStillParts(a: LibraryStill, b: LibraryStill) {
const chainDelta = (b.chainIndex || 0) - (a.chainIndex || 0)
if (chainDelta) return chainDelta
return b.createdAt - a.createdAt
}
function chronologicalStills(parts: LibraryStill[]) {
return [...parts].sort((a, b) => {
const chain = (a.chainIndex || 0) - (b.chainIndex || 0)
if (chain) return chain
return a.createdAt - b.createdAt
})
}
function sortClipParts(a: LibraryClip, b: LibraryClip) {
const durationDelta = (Number(b.duration) || 0) - (Number(a.duration) || 0)
if (durationDelta) return durationDelta
const chainDelta = (b.chainIndex || 0) - (a.chainIndex || 0)
if (chainDelta) return chainDelta
return a.createdAt - b.createdAt
}
function chronologicalParts(parts: LibraryClip[]) {
return [...parts].sort((a, b) => {
const chain = (a.chainIndex || 0) - (b.chainIndex || 0)
if (chain) return chain
return a.createdAt - b.createdAt
})
}
function previousChronoClipId(parts: LibraryClip[], index: number) {
if (index <= 0) return undefined
return chronologicalParts(parts)[index - 1]?.id
}
function collectionTitle(group: { head: LibraryClip; parts: LibraryClip[] }) {
const named = group.parts.map(part => familyBaseName(part.name || '')).find(name => name)
return named || group.head.name || group.head.prompt
}
function familyScript(parts: LibraryClip[]) {
const ordered = chronologicalParts(parts)
if (ordered.length <= 1) return ordered[0]?.prompt || ''
return ordered.map((part, index) => (
index === 0 ? part.prompt.trim() : `shot ${index + 1}\n${part.prompt.trim()}`
)).join('\n\n')
}
function formatClipDuration(value?: number) {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return ''
const rounded = Math.round(value * 10) / 10
return Number.isInteger(rounded) ? `${rounded}s` : `${rounded.toFixed(1)}s`
}
function clipMetaLine(item: LibraryClip) {
const res = item.width && item.height ? `${item.width}×${item.height}` : 'size unknown'
const duration = formatClipDuration(item.duration)
const steps = `${item.steps || '—'} steps`
const seed = `seed ${item.seed || '—'}`
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 shotCheckClass(part: { status: string }) {
if (part.status === 'complete') return 'border border-emerald-400/20 bg-emerald-400/10 text-emerald-100'
if (part.status === 'running') return 'border border-amber-300/40 bg-amber-400/10 text-amber-100'
if (part.status === 'error') return 'border border-red-400/30 bg-red-400/10 text-red-200'
return 'border border-white/5 text-zinc-400'
}
function shotCheckMarkClass(part: { status: string }) {
if (part.status === 'complete') return 'bg-emerald-400 text-zinc-950'
if (part.status === 'running') return 'bg-amber-400 text-zinc-950'
if (part.status === 'error') return 'bg-red-400 text-zinc-950'
return 'border border-white/20 text-zinc-500'
}
function shotCheckMark(part: { status: string; label: string }) {
if (part.status === 'complete') return '✓'
if (part.status === 'running') return '▶'
if (part.status === 'error') return '!'
return part.label.replace(/\D/g, '') || '•'
}
function editChainPartDone(index: number) {
return index < editCompletedChainStep.value
}
function editChainPartCurrent(index: number) {
return editBusy.value && index === editCompletedChainStep.value
}
function editChainPartClass(index: number) {
if (editChainPartDone(index)) return 'border border-emerald-400/20 bg-emerald-400/10 text-emerald-100'
if (editChainPartCurrent(index)) return 'border border-amber-300/40 bg-amber-400/10 text-amber-100'
return 'border border-white/5 text-zinc-400'
}
function editChainPartDot(index: number) {
if (editChainPartDone(index)) return 'bg-emerald-400'
if (editChainPartCurrent(index)) return 'bg-amber-300 animate-pulse'
return 'bg-zinc-600'
}
function 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(light = false) {
const data = await $fetch<{ folders?: LibraryFolder[]; clips?: LibraryClip[]; stills?: LibraryStill[]; drafts?: RetryDraft[] }>(light ? '/api/library?light=1' : '/api/library')
applyLibrary(data)
if (light) return
const queued = await $fetch<{ pendingCount?: number }>('/api/queues').catch(() => ({ pendingCount: 0 }))
queuePendingCount.value = queued.pendingCount || 0
await refreshStudioQueue()
await refreshLiveQueue()
}
let libraryRefreshTimer: ReturnType<typeof setTimeout> | null = null
function scheduleLibraryRefresh() {
if (libraryRefreshTimer) return
libraryRefreshTimer = setTimeout(() => {
libraryRefreshTimer = null
loadLibrary(true).catch(() => null)
}, 1500)
}
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'
videoEngine.value = 'minimax'
videoStart.value = 'still'
minimaxGraph.value = 'v1'
try {
const saved = parseVideoWorkflow(localStorage.getItem('aigen-video-workflow') || '')
videoEngine.value = videoEngineOf(saved)
videoStart.value = videoStartOf(saved)
minimaxGraph.value = minimaxGraphOf(saved)
const engine = localStorage.getItem('aigen-video-engine')
const start = localStorage.getItem('aigen-video-start')
if (engine === 'ltx' || engine === 'minimax') videoEngine.value = engine
if (start === 'text' || start === 'still') videoStart.value = start
if (videoEngine.value === 'ltx') applyEngineDefaults('ltx')
} catch { /* ignore */ }
useIdentityRefs.value = localStorage.getItem('aigen-use-identity-refs') === 'true'
globalLocks.value = localStorage.getItem(LOCKS_STORE) || ''
try {
const savedRefs = JSON.parse(localStorage.getItem(REFS_STORE) || '')
familyPermanenceRefs.value = Array.isArray(savedRefs?.family) ? savedRefs.family : []
shotPermanenceRefs.value = savedRefs?.shots && typeof savedRefs.shots === 'object' ? savedRefs.shots : {}
} catch { /* ignore */ }
activeQueueId.value = localStorage.getItem(QUEUE_STORE) || ''
const savedPreset = localStorage.getItem('aigen-prompt-preset')
if (savedPreset) recommendPresetId.value = savedPreset
} catch { /* ignore */ }
applyInstancePrivacy()
window.addEventListener('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()
setTimeout(() => { void loadLibrary(true).catch(() => null) }, 6000)
healthTimer = setInterval(() => {
if (typeof document !== 'undefined' && document.hidden) return
void pollComfyHealth()
void refreshStudioQueue()
if (activeQueueId.value) void refreshLiveQueue()
}, 5000)
await resumeActiveJob()
await refreshStudioQueue()
await refreshLiveQueue()
await loadPromptPresets()
})
onBeforeUnmount(() => {
window.removeEventListener('pointerdown', onSitePointerDown, true)
if (healthTimer) clearInterval(healthTimer)
healthTimer = null
if (libraryRefreshTimer) clearTimeout(libraryRefreshTimer)
libraryRefreshTimer = null
stopTimer()
stopListen()
closeExtend()
})
watch(folderId, () => {
applyThumbnailDefault()
})
watch(settingsOpen, (open) => {
if (!open) void loadPromptPresets()
})
watch(recommendPresetId, (id) => {
try { localStorage.setItem('aigen-prompt-preset', id) } catch { /* ignore */ }
})
watch([browseFolderId, libraryFilter], () => {
selectedKeys.value = []
lastSelectedKey.value = ''
})
watch(editBusy, (busy) => {
if (busy) outputFocus.value = 'edit'
})
watch(videoBusy, (busy) => {
if (busy) outputFocus.value = 'video'
})
watch(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(videoUrl, () => {
videoSeekable.value = true
})
watch(autoplayEnabled, (value) => {
try {
localStorage.setItem('aigen-autoplay', String(value))
} catch { /* ignore */ }
if (value && videoUrl.value && !awaitingReveal.value) {
player.value?.play().catch(() => null)
}
})
watch(videoWorkflow, (value) => {
try {
localStorage.setItem('aigen-video-workflow', value)
} catch { /* ignore */ }
})
watch(videoEngine, (value) => {
try {
localStorage.setItem('aigen-video-engine', value)
} catch { /* ignore */ }
})
watch(videoStart, (value) => {
try {
localStorage.setItem('aigen-video-start', value)
} catch { /* ignore */ }
})
watch(useIdentityRefs, (value) => {
try {
localStorage.setItem('aigen-use-identity-refs', String(value))
} catch { /* ignore */ }
})
watch([globalLocks, familyPermanenceRefs, shotPermanenceRefs], () => {
try {
localStorage.setItem(LOCKS_STORE, globalLocks.value)
localStorage.setItem(REFS_STORE, JSON.stringify({ family: familyPermanenceRefs.value, shots: shotPermanenceRefs.value }))
} catch { /* ignore */ }
}, { deep: true })
watch(turbo, (on) => {
if (cfgTouched.value) return
cfg.value = ltxVideo.value ? 3.5 : (on ? CFG_TURBO : CFG_QUALITY)
})
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
}
interface PromptRecommendSnap {
jobId?: string
status?: string
message?: string
text?: string
error?: string
}
async function loadPromptPresets() {
const data = await $fetch<{ presets?: Array<{ id: string; name: string; description?: string; builtin?: boolean }> }>('/api/prompt/presets').catch(() => ({ presets: [] }))
const presets = data.presets || []
if (presets.length) promptPresets.value = presets
const saved = (() => {
try { return localStorage.getItem('aigen-prompt-preset') || '' }
catch { return '' }
})()
if (saved && promptPresets.value.some(item => item.id === saved)) {
recommendPresetId.value = saved
} else if (!promptPresets.value.some(item => item.id === recommendPresetId.value)) {
recommendPresetId.value = promptPresets.value[0]?.id || 'enhance'
}
}
async function recommendPrompt() {
if (recommendDisabled.value || !file.value) return
recommendBusy.value = true
recommendError.value = ''
recommendText.value = ''
recommendApplied.value = false
recommendMessage.value = imageSidecarOk.value
? 'Uploading still to Sidecar Qwen VL…'
: 'Sidecar is offline. Starting Qwen VL…'
const body = new FormData()
body.append('image', file.value)
body.append('prompt', prompt.value)
body.append('presetId', recommendPresetId.value)
body.append(
'mode',
studioMode.value === 'edit' ? 'edit' : (shotScriptMode.value ? 'shot-script' : 'video')
)
try {
const started = await $fetch<PromptRecommendSnap>('/api/prompt/recommend', { method: 'POST', body })
const jobId = String(started.jobId || '')
recommendJobId.value = jobId
if (!jobId) throw new Error('Recommend job did not start')
while (recommendJobId.value === jobId) {
const snap = await $fetch<PromptRecommendSnap>(`/api/prompt/recommend/${jobId}`)
recommendMessage.value = snap.message || recommendMessage.value
if (snap.status === 'complete') {
recommendText.value = String(snap.text || '').trim()
if (!recommendText.value) recommendError.value = 'Sidecar returned an empty prompt'
break
}
if (snap.status === 'error') {
recommendError.value = snap.error || snap.message || 'Prompt recommend failed'
toast(recommendError.value)
break
}
await new Promise(resolve => setTimeout(resolve, 1500))
}
} catch (error: any) {
recommendError.value = error?.data?.statusMessage || error?.statusMessage || error?.message || 'Prompt recommend failed'
toast(recommendError.value)
} finally {
recommendBusy.value = false
if (recommendJobId.value) recommendJobId.value = ''
void pollComfyHealth()
}
}
function applyRecommend() {
const text = recommendText.value.trim()
if (!text) return
promptBeforeRecommend.value = prompt.value
editPassesBeforeRecommend.value = editPassQueue.value.map(item => ({ ...item }))
shotScriptBeforeRecommend.value = shotScriptMode.value
if (studioMode.value === 'edit') {
const passes = parseEditPasses(text)
if (passes && passes.length > 1) {
prompt.value = passes[0]
editPassQueue.value = passes.slice(1).map(item => ({ id: crypto.randomUUID(), prompt: item }))
} else {
prompt.value = text
}
} else {
const extracted = extractGlobalLocks(text)
if (extracted.locks && isBlankGlobalLocks(globalLocks.value)) globalLocks.value = extracted.locks
prompt.value = extracted.script || text
if (looksLikeShotScript(prompt.value) || parseShotScript(prompt.value).length > 1) shotScriptMode.value = true
}
recommendApplied.value = true
toast('Recommended prompt applied')
}
function revertRecommend() {
prompt.value = promptBeforeRecommend.value
editPassQueue.value = editPassesBeforeRecommend.value.map(item => ({ ...item }))
shotScriptMode.value = shotScriptBeforeRecommend.value
recommendApplied.value = false
}
function dismissRecommend() {
recommendText.value = ''
recommendError.value = ''
recommendMessage.value = ''
recommendApplied.value = false
}
function toast(message: string) {
const id = crypto.randomUUID()
toasts.value.push({ id, message })
setTimeout(() => {
toasts.value = toasts.value.filter(item => item.id !== id)
}, 7000)
}
async function copyPrompt(text: string) {
const promptText = (text || '').trim()
if (!promptText) {
toast('This clip has no prompt to copy')
return
}
try {
await navigator.clipboard.writeText(promptText)
toast('Prompt copied')
} catch {
toast('Could not copy the prompt')
}
}
function resetImage() {
file.value = null
preview.value = ''
imageWidth.value = 0
imageHeight.value = 0
}
function clearVideoForm() {
resetImage()
prompt.value = ''
clipName.value = ''
extensionQueue.value = []
shotScriptMode.value = false
globalLocks.value = ''
familyPermanenceRefs.value = []
shotPermanenceRefs.value = {}
identityRefs.value.forEach((_, index) => clearIdentityRef(index))
recommendText.value = ''
recommendError.value = ''
recommendApplied.value = false
activeDraftId = ''
}
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 openPermanencePicker(shot?: number, clipId?: string) {
if (clipId) pickerSlot.value = `clip:${clipId}`
else if (typeof shot === 'number') pickerSlot.value = `perm:${shot}`
else pickerSlot.value = 'perm'
pickerOpen.value = true
}
function addPermanenceRef(stillId: string, shot?: number) {
if (typeof shot === 'number') {
const current = shotPermanenceRefs.value[shot] || []
if (current.some(item => item.stillId === stillId) || current.length >= 4) return
shotPermanenceRefs.value = { ...shotPermanenceRefs.value, [shot]: [...current, { stillId, label: nextPermanenceLabel(current) }] }
return
}
if (familyPermanenceRefs.value.some(item => item.stillId === stillId) || familyPermanenceRefs.value.length >= 6) return
familyPermanenceRefs.value = [...familyPermanenceRefs.value, { stillId, label: nextPermanenceLabel(familyPermanenceRefs.value) }]
}
function removeFamilyRef(index: number) {
familyPermanenceRefs.value = familyPermanenceRefs.value.filter((_, i) => i !== index)
}
function updateFamilyRefLabel(index: number, label: string) {
familyPermanenceRefs.value = familyPermanenceRefs.value.map((item, i) => i === index ? { ...item, label } : item)
}
function removeShotRef(shot: number, index: number) {
const current = (shotPermanenceRefs.value[shot] || []).filter((_, i) => i !== index)
const next = { ...shotPermanenceRefs.value }
if (current.length) next[shot] = current
else delete next[shot]
shotPermanenceRefs.value = next
}
function updateShotRefLabel(shot: number, index: number, label: string) {
const current = (shotPermanenceRefs.value[shot] || []).map((item, i) => i === index ? { ...item, label } : item)
shotPermanenceRefs.value = { ...shotPermanenceRefs.value, [shot]: current }
}
async function persistClipRefs(clip: LibraryClip, refs: PermanenceRef[]) {
try {
applyLibrary(await $fetch(`/api/library/clips/${clip.id}`, {
method: 'PATCH',
body: { permanenceRefs: refs }
}))
} catch (error: any) {
toast(error?.data?.statusMessage || 'Could not save permanence refs')
}
}
function removeClipRef(clip: LibraryClip, index: number) {
void persistClipRefs(clip, (clip.permanenceRefs || []).filter((_, i) => i !== index))
}
function updateClipRefLabel(clip: LibraryClip, index: number, label: string) {
void persistClipRefs(clip, (clip.permanenceRefs || []).map((item, i) => i === index ? { ...item, label } : item))
}
function onPickedPermanence(still: { id: string }) {
const slot = pickerSlot.value
if (slot === 'main' || slot === 'editRef' || typeof slot === 'number') return
pickerOpen.value = false
if (typeof slot === 'string' && slot.startsWith('clip:')) {
const clipId = slot.slice(5)
const clip = clips.value.find(item => item.id === clipId)
if (!clip) return
if ((clip.permanenceRefs || []).some(item => item.stillId === still.id)) return
void persistClipRefs(clip, [...(clip.permanenceRefs || []), { stillId: still.id, label: nextPermanenceLabel(clip.permanenceRefs || []) }])
return
}
if (typeof slot === 'string' && (slot === 'perm' || slot.startsWith('perm:'))) {
const shot = slot === 'perm' ? undefined : Number(slot.slice(5))
addPermanenceRef(still.id, Number.isFinite(shot) ? shot : undefined)
}
}
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 (typeof slot === 'string' && slot !== 'main' && slot !== 'editRef') return
if (slot === 'editRef') {
setEditRef(next)
return
}
if (slot !== 'main') {
setIdentityRef(Number(slot), next)
return
}
if (hidden && showPrivacyToggles.value) hideInputPreview.value = true
readFile(next)
}
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 persistActiveQueue(id: string) {
activeQueueId.value = id
try {
localStorage.setItem(QUEUE_STORE, id)
} catch { /* ignore */ }
}
async function refreshLiveQueue() {
const id = activeQueueId.value
if (!id) return
const queue = await $fetch<{
id: string
status: string
pendingCount: number
completedCount: number
totalCount: number
segments: Array<{ index: number; prompt: string; duration: number; status: string; clipId?: string }>
}>(`/api/queues/${id}`).catch(() => null)
if (!queue) return
liveQueue.value = queue
queuePendingCount.value = queue.status === 'complete' ? queuePendingCount.value : queue.pendingCount
completedChainStep.value = queue.completedCount
if (queue.status === 'complete' && !videoBusy.value) {
try { localStorage.removeItem(QUEUE_STORE) } catch { /* ignore */ }
}
}
function studioJobStatusLabel(job: { status: string; cutIn?: boolean; holdForCutIn?: boolean; lastError?: string }) {
if (job.status === 'running') return 'Generating'
if (job.status === 'held') return job.holdForCutIn ? 'Paused for cut-in' : 'Paused'
if (job.status === 'waiting') return job.cutIn ? 'Next after current shot' : 'Waiting'
if (job.status === 'error') return job.lastError || 'Error'
return job.status
}
function studioJobShotLine(job: { shotCount: number; completedCount?: number; status: string }) {
const total = job.shotCount || 1
const done = job.completedCount || 0
if (total <= 1) return '1 clip'
if (job.status === 'waiting') return `${total} shots`
return `shot ${Math.min(total, done + 1)} of ${total}`
}
async function refreshStudioQueue() {
const data = await $fetch<{
jobs?: Array<{
id: string
status: string
name: string
prompt: string
shotCount: number
liveJobId?: string
shotQueueId?: string
stillId?: string
hideThumbnail?: boolean
folderLocked?: boolean
cutIn?: boolean
holdForCutIn?: boolean
lastError?: string
completedCount?: number
pendingCount?: number
}>
}>('/api/studio-queue').catch(() => ({ jobs: [] }))
studioJobs.value = data.jobs || []
const running = studioJobs.value.find(job => job.status === 'running' && job.liveJobId)
if (running?.liveJobId && running.liveJobId !== videoJobId.value && !videoBusy.value) {
outputStudioJobId.value = running.id
videoJobId.value = running.liveJobId
persistActiveJob('video', running.liveJobId, running.hideThumbnail === true, running.folderLocked === true)
listen('video', running.liveJobId, running.hideThumbnail === true, running.folderLocked === true)
if (running.shotQueueId) {
persistActiveQueue(running.shotQueueId)
void refreshLiveQueue()
}
videoBusy.value = true
statusMessage.value = 'Starting queued job…'
startTimer('video')
} else if (running?.id && running.liveJobId === videoJobId.value) {
outputStudioJobId.value = running.id
}
}
async function cutInStudioJob(id: string) {
try {
await $fetch(`/api/studio-queue/${id}/cut-in`, { method: 'POST' })
toast('This job will run after the current shot, as its own clip.')
await refreshStudioQueue()
} catch (error: any) {
toast(error?.data?.statusMessage || error?.statusMessage || 'Could not move that job next')
}
}
async function dismissStudioJob(id: string) {
try {
await $fetch(`/api/studio-queue/${id}`, { method: 'DELETE' })
if (outputStudioJobId.value === id) outputStudioJobId.value = ''
await refreshStudioQueue()
} catch (error: any) {
toast(error?.data?.statusMessage || error?.statusMessage || 'Could not remove that job')
}
}
async function restoreStudioJob(id: string) {
const job = await $fetch<{
payload?: {
prompt?: string
name?: string
folderId?: string
aspect?: string
width?: number
height?: number
turbo?: boolean
seed?: number
cfg?: number
fps?: number
samplerName?: string
scheduler?: string
duration?: number
sound?: boolean
workflow?: string
useIdentityRefs?: boolean
stillId?: string
stillFilename?: string
hideThumbnail?: boolean
hideInput?: boolean
referenceStillIds?: Array<string | null>
extensions?: { prompt: string; duration: number; permanenceRefs?: PermanenceRef[] }[]
queueAutoRun?: boolean
globalLocks?: string
permanenceRefs?: PermanenceRef[]
shotPermanenceRefs?: PermanenceRef[][]
}
}>(`/api/studio-queue/${id}`)
const payload = job.payload
if (!payload) throw new Error('That job has no saved inputs')
studioMode.value = 'video'
outputFocus.value = 'video'
closeExtend()
const extensions = payload.extensions || []
applyVideoWorkflow(payload.workflow)
prompt.value = extensions.length
? [payload.prompt.trim(), ...extensions.map((item, index) => `shot ${index + 2}\n${item.prompt.trim()}`)].join('\n\n')
: (payload.prompt || '')
clipName.value = payload.name || ''
seedInput.value = String(payload.seed || '')
restoreAdvanced({
turbo: payload.turbo === true,
aspect: payload.aspect,
width: payload.width,
height: payload.height,
cfg: payload.cfg,
fps: payload.fps,
samplerName: payload.samplerName,
scheduler: payload.scheduler
})
duration.value = clampDuration(Number(payload.duration))
hideThumbnail.value = showPrivacyToggles.value && payload.hideThumbnail === true
if (showPrivacyToggles.value) hideInputPreview.value = payload.hideInput === true
if (payload.folderId) {
folderId.value = payload.folderId
browseFolderId.value = payload.folderId
}
if (typeof payload.sound === 'boolean') withSound.value = payload.sound
shotScriptMode.value = extensions.length > 0
useIdentityRefs.value = payload.useIdentityRefs === true
identityRefs.value.forEach((_, index) => clearIdentityRef(index))
extensionQueue.value = extensions.map(item => ({
id: crypto.randomUUID(),
prompt: item.prompt,
duration: clampDuration(Number(item.duration))
}))
queueAutoRun.value = payload.queueAutoRun === true
globalLocks.value = payload.globalLocks || ''
familyPermanenceRefs.value = payload.permanenceRefs || []
const shotRefs: Record<number, PermanenceRef[]> = {}
;(payload.shotPermanenceRefs || []).forEach((refs, index) => {
if (refs?.length) shotRefs[index + 1] = refs
})
shotPermanenceRefs.value = shotRefs
const stillId = payload.stillId
if (stillId) {
const blob = await $fetch<Blob>(`/api/library/stills/${stillId}`, { responseType: 'blob' })
readFile(new File([blob], payload.stillFilename || 'still.png', { type: blob.type || 'image/png' }), { keepThumbnailPref: true })
} else {
resetImage()
}
for (const [index, refId] of (payload.referenceStillIds || []).entries()) {
if (!refId || index >= 4) continue
try {
const blob = await $fetch<Blob>(`/api/library/stills/${refId}`, { responseType: 'blob' })
setIdentityRef(index, new File([blob], `identity-ref-${index + 2}.png`, { type: blob.type || 'image/png' }))
} catch { /* skip missing identity stills */ }
}
}
async function restoreOutputJob() {
const id = outputRestoreId.value
if (!id) return
try {
await restoreStudioJob(id)
statusMessage.value = 'Job loaded into Input. Generate when you are ready.'
toast('Inputs restored. Make any changes, then generate.')
} catch (error: any) {
toast(error?.data?.statusMessage || error?.message || 'Could not restore that job')
}
}
function clearActiveJob(kind?: 'video' | 'edit') {
try {
if (!kind || kind === 'video') localStorage.removeItem(JOB_STORE)
if (!kind || kind === 'edit') localStorage.removeItem(EDIT_JOB_STORE)
} catch { /* ignore */ }
}
function readStoredJob(key = JOB_STORE) {
try {
const raw = localStorage.getItem(key)
if (!raw) return null
const parsed = JSON.parse(raw) as { jobId?: string; hideThumbnail?: boolean; folderLocked?: boolean }
return parsed?.jobId ? parsed : null
} catch {
return null
}
}
async function resumeTrack(kind: 'video' | 'edit', id: string, hidden: boolean, locked: boolean) {
try {
const snap = await $fetch<Record<string, any>>(`/api/generate/${id}`)
if (kind === 'edit') {
editJobId.value = id
concealEditOutput.value = hidden
editSettledUi = false
} else {
videoJobId.value = id
concealOutput.value = hidden
videoSettledUi = false
}
applyJobEvent(kind, snap, hidden, locked, id)
const running = snap.status === 'queued' || snap.status === 'uploading' || snap.status === 'running'
const settled = kind === 'edit' ? editSettledUi : videoSettledUi
if (running && !settled) {
if (kind === 'edit') {
editBusy.value = true
editStatusMessage.value = snap.message || 'Reconnecting to edit ComfyUI...'
} else {
videoBusy.value = true
statusMessage.value = snap.message || 'Waiting for ComfyUI to finish this job...'
}
startTimer(kind, Number(snap.elapsedMs || 0))
persistActiveJob(kind, id, hidden, locked)
listen(kind, id, hidden, locked)
} else if (settled) {
clearActiveJob(kind)
}
} catch {
clearActiveJob(kind)
}
}
async function resumeActiveJob() {
const storedVideo = readStoredJob(JOB_STORE)
const storedEdit = readStoredJob(EDIT_JOB_STORE)
const active = await $fetch<{
jobId?: string | null
video?: { jobId?: string; hideThumbnail?: boolean; folderLocked?: boolean }
edit?: { jobId?: string; hideThumbnail?: boolean; folderLocked?: boolean }
hideThumbnail?: boolean
folderLocked?: boolean
} | null>('/api/generate/active').catch(() => null)
const videoId = storedVideo?.jobId || active?.video?.jobId || (!active?.edit?.jobId ? active?.jobId : null) || ''
const editId = storedEdit?.jobId || active?.edit?.jobId || ''
if (videoId) {
await resumeTrack(
'video',
videoId,
Boolean(storedVideo?.hideThumbnail || active?.video?.hideThumbnail || active?.hideThumbnail),
Boolean(storedVideo?.folderLocked || active?.video?.folderLocked || active?.folderLocked)
)
}
if (editId) {
await resumeTrack(
'edit',
editId,
Boolean(storedEdit?.hideThumbnail || active?.edit?.hideThumbnail),
Boolean(storedEdit?.folderLocked || active?.edit?.folderLocked)
)
}
}
function onSitePointerDown(event: PointerEvent) {
const root = outputPanel.value
const target = event.target as Node | null
if (root && target && root.contains(target)) return
if (concealOutput.value && !awaitingReveal.value && !lockedSave.value && videoUrl.value) {
concealAndPause()
}
if (concealEditOutput.value && !editAwaitingReveal.value && !editLockedSave.value && editResultUrl.value) {
editAwaitingReveal.value = true
}
}
function concealAndPause() {
awaitingReveal.value = true
const el = player.value
if (el) {
el.pause()
el.currentTime = 0
}
}
function stopTimer(kind?: 'video' | 'edit') {
if (!kind || kind === 'video') {
if (videoTimer) clearInterval(videoTimer)
videoTimer = null
}
if (!kind || kind === 'edit') {
if (editTimer) clearInterval(editTimer)
editTimer = null
}
}
function clipFileName(name: string) {
return name.replace(/[<>:"/\\|?*'`]/g, '-').replace(/\s+/g, ' ').trim().slice(0, 80) || 'aigen'
}
function nextClipPartName(name: string) {
const trimmed = (name || '').trim()
if (!trimmed) return ''
const match = trimmed.match(/^(.*)\s+p(\d+)$/i)
const base = (match ? match[1] : trimmed).trim() || trimmed
const part = match ? Number(match[2]) + 1 : 2
const suffix = ` p${part}`
return `${base.slice(0, Math.max(1, 80 - suffix.length))}${suffix}`
}
function clipGenerateDuration(item: LibraryClip) {
if (item.parentClipId) {
const parent = clips.value.find(clip => clip.id === item.parentClipId)
if (parent?.duration && item.duration && item.duration > parent.duration + 0.4) {
return clampDuration(item.duration - parent.duration)
}
}
return clampDuration(Number(item.duration) || 5)
}
function familyPartsFor(item: LibraryClip) {
const key = clipFamilyKey(item)
const group = libraryGroups.value.find(entry => entry.key === key)
return chronologicalParts(group?.parts || [item])
}
async function loadRerunImage(item: LibraryClip, parts: LibraryClip[]) {
const index = Math.max(0, parts.findIndex(part => part.id === item.id))
const isExtension = index > 0 || Boolean(item.parentClipId) || (item.chainIndex || 0) > 0
if (isExtension) {
const previous = 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'
outputFocus.value = 'video'
closeExtend()
const parts = familyPartsFor(item)
const target = collection ? parts[0] || item : item
const restoreAll = collection && parts.length > 1
prompt.value = restoreAll ? familyScript(parts) : target.prompt
clipName.value = restoreAll
? (familyBaseName(collectionTitle({ head: target, parts })) || target.name || '')
: (target.name || '')
seedInput.value = String(target.seed)
restoreAdvanced(target)
duration.value = clipGenerateDuration(target)
hideThumbnail.value = showPrivacyToggles.value && target.hideThumbnail
if (showPrivacyToggles.value) hideInputPreview.value = target.hideInput === true
folderId.value = target.folderId
browseFolderId.value = target.folderId
applyVideoWorkflow(target.workflow)
if (typeof target.sound === 'boolean') withSound.value = target.sound
shotScriptMode.value = restoreAll
useIdentityRefs.value = false
identityRefs.value.forEach((_, index) => clearIdentityRef(index))
const ordered = chronologicalParts(parts)
globalLocks.value = ordered.find(part => part.globalLocks)?.globalLocks || target.globalLocks || ''
familyPermanenceRefs.value = ordered[0]?.permanenceRefs || target.permanenceRefs || []
const familyIds = new Set(familyPermanenceRefs.value.map(ref => ref.stillId))
const nextShots: Record<number, PermanenceRef[]> = {}
ordered.forEach((part, index) => {
const extras = (part.permanenceRefs || []).filter(ref => !familyIds.has(ref.stillId))
if (extras.length) nextShots[index + 1] = extras
})
shotPermanenceRefs.value = nextShots
extensionQueue.value = restoreAll
? parts.slice(1).map(part => ({
id: crypto.randomUUID(),
prompt: part.prompt,
duration: clipGenerateDuration(part)
}))
: []
try {
const initialTextToVideo = textToVideo.value && !target.parentClipId && !(target.chainIndex)
if (!(initialTextToVideo && !target.stillId)) await loadRerunImage(target, parts)
statusMessage.value = restoreAll
? 'Collection loaded into the form. Generate when you are ready.'
: 'Clip loaded into the form. Generate when you are ready.'
toast(restoreAll
? 'Shot script and settings restored. Make any changes, then generate.'
: 'Inputs restored. Make any changes, then generate.')
} catch {
statusMessage.value = textToVideo.value
? 'Clip settings loaded. Add a still only if you want image-to-video.'
: 'Clip settings loaded. Re-drop the start still if it is missing.'
toast(textToVideo.value
? 'Settings restored. Text-to-video does not need a start still.'
: 'Settings restored, but the start still could not be loaded. Pick it again.')
}
}
async function loadDraft(draft: RetryDraft) {
activeDraftId = draft.id
prompt.value = draft.prompt
clipName.value = draft.name || ''
seedInput.value = String(draft.seed)
restoreAdvanced(draft)
hideThumbnail.value = showPrivacyToggles.value && draft.hideThumbnail
folderId.value = draft.folderId
withSound.value = draft.sound !== false
if (draft.hideInput && showPrivacyToggles.value) hideInputPreview.value = true
duration.value = clampDuration(Number(draft.duration))
applyVideoWorkflow(draft.workflow)
globalLocks.value = draft.globalLocks || ''
familyPermanenceRefs.value = draft.permanenceRefs || []
const draftShots: Record<number, PermanenceRef[]> = {}
;(draft.shotPermanenceRefs || []).forEach((refs, index) => {
if (refs?.length) draftShots[index + 1] = refs
})
shotPermanenceRefs.value = draftShots
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 {
if (textToVideo.value) {
statusMessage.value = 'Held job restored. Generate when ComfyUI is free.'
} else {
statusMessage.value = 'Held job settings restored. Re-drop the still if the image is missing.'
toast('Settings restored. Unlock the folder or re-drop the still if the image is missing.')
}
}
}
async function dismissDraft(draft: RetryDraft) {
try {
applyLibrary(await $fetch(`/api/library/drafts/${draft.id}`, { method: 'DELETE' }))
if (activeDraftId === draft.id) activeDraftId = ''
} catch (error: any) {
toast(error?.data?.statusMessage || 'Could not dismiss held job')
}
}
async function unlockFolder(id: string) {
try {
const data = await $fetch('/api/library/unlock', { method: 'POST', body: { password: unlockPassword.value, folderId: id } })
unlockPassword.value = ''
applyLibrary(data)
if (lockedSave.value) {
const dest = folders.value.find(folder => folder.id === folderId.value)
if (dest?.unlocked) {
lockedSave.value = false
const newest = clips.value
.filter(clip => clip.folderId === folderId.value)
.sort((a, b) => b.createdAt - a.createdAt)[0]
if (newest) openClip(newest)
}
}
} catch (error: any) {
toast(error?.data?.statusMessage || error?.statusMessage || 'Unlock failed')
}
}
async function lockFolder(id: string) {
try {
applyLibrary(await $fetch('/api/library/lock', { method: 'POST', body: { folderId: id } }))
} catch {
await loadLibrary()
}
const stillVisible = clips.value.some(clip => videoUrl.value.includes(clip.id))
if (videoUrl.value && !stillVisible) {
videoUrl.value = ''
awaitingReveal.value = false
lockedSave.value = false
concealOutput.value = false
statusMessage.value = 'Folder locked'
}
}
function clipIdFromPlayer() {
const match = videoUrl.value.match(/\/api\/library\/clips\/([^/?#]+)\/video/)
return match?.[1] || currentClipId.value
}
function clipVideoUrl(clipId: string) {
return `/api/library/clips/${clipId}/video?t=${Date.now()}`
}
function closeExtend() {
lastFrameGen += 1
extendOpen.value = false
lastFrameLoading.value = false
if (lastFrameObjectUrl) {
URL.revokeObjectURL(lastFrameObjectUrl)
lastFrameObjectUrl = ''
}
lastFrameUrl.value = ''
}
async function openExtend(item?: LibraryClip) {
if (item) openClip(item)
const id = item?.id || clipIdFromPlayer()
if (!id) {
toast('Load a generated clip before extending it.')
return
}
currentClipId.value = id
const clip = clips.value.find(entry => entry.id === id)
extendPrompt.value = clip?.prompt || prompt.value
extendDuration.value = clampDuration(duration.value)
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'
outputFocus.value = 'video'
closeExtend()
watchingLibrary.value = true
currentClipId.value = item.id
lockedSave.value = false
concealOutput.value = item.hideThumbnail
videoUrl.value = clipVideoUrl(item.id)
awaitingReveal.value = item.hideThumbnail
playerSize.value = { width: item.width || 1344, height: item.height || 768 }
browseFolderId.value = item.folderId
videoDownloadName.value = clipFileName(item.name || item.prompt.split(/[.!?\n]/)[0] || 'aigen')
if (!videoBusy.value) {
statusMessage.value = item.hideThumbnail ? 'Hidden clip ready' : 'Loaded from library'
progress.value = 100
}
}
function openStill(still: LibraryStill) {
studioMode.value = 'edit'
outputFocus.value = 'edit'
browseFolderId.value = still.folderId
editResultUrl.value = `/api/library/stills/${still.id}`
concealEditOutput.value = still.hideInput === true
editAwaitingReveal.value = still.hideInput === true
editLockedSave.value = false
editDownloadName.value = clipFileName(stillTitle(still))
if (!editBusy.value) {
editStatusMessage.value = still.hideInput ? 'Hidden still ready' : 'Loaded from library'
editProgress.value = 100
}
}
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
concealEditOutput.value = false
editLockedSave.value = false
}
await loadLibrary()
} catch (error: any) {
toast(error?.data?.statusMessage || 'Could not delete image')
}
}
async function onMoveStill(still: LibraryStill, event: Event, family = false) {
const select = event.target as HTMLSelectElement
const destId = select.value
select.value = ''
if (!destId || destId === still.folderId) return
try {
applyLibrary(await $fetch(`/api/library/stills/${still.id}`, {
method: 'PATCH',
body: { folderId: destId, family: family || undefined }
}))
const dest = folders.value.find(folder => folder.id === destId)
toast(`Moved ${family ? 'edit chain' : 'still'} to ${dest?.name || 'folder'}${dest?.unlocked ? '' : ' (locked)'}`)
} catch (error: any) {
toast(error?.data?.statusMessage || 'Could not move image')
}
}
function startRenameStill(still: LibraryStill, family = false) {
renamingId.value = still.id
renamingFamily.value = family
const source = family
? (familyBaseName(still.name || '') || still.name || '')
: (still.name || stillTitle(still))
renameDraft.value = source.trim() || 'edited'
nextTick(() => {
renameInput.value?.focus()
renameInput.value?.select()
})
}
async function saveRenameStill(still: LibraryStill, family = false) {
if (renamingId.value !== still.id || renamingFamily.value !== family) return
const name = renameDraft.value.trim().slice(0, 80)
if (!name) {
toast('A name is required')
return
}
const current = family ? familyBaseName(still.name || '') : still.name
if (name === current) {
cancelRename()
return
}
try {
applyLibrary(await $fetch(`/api/library/stills/${still.id}`, {
method: 'PATCH',
body: { name, family: family || undefined }
}))
cancelRename()
if (editResultUrl.value.includes(still.id)) {
editDownloadName.value = clipFileName(name)
}
} catch (error: any) {
toast(error?.data?.statusMessage || 'Could not rename image')
}
}
function stillFamilyPartsFor(still: LibraryStill) {
const group = stillGroups.value.find(entry => entry.parts.some(part => part.id === still.id))
return chronologicalStills(group?.parts || [still])
}
function clearPreview() {
closeExtend()
videoUrl.value = ''
awaitingReveal.value = false
concealOutput.value = false
lockedSave.value = false
currentClipId.value = ''
watchingLibrary.value = false
editResultUrl.value = ''
editAwaitingReveal.value = false
concealEditOutput.value = false
editLockedSave.value = false
if (!videoBusy.value) {
statusMessage.value = 'Waiting for a still.'
progress.value = 0
}
if (!editBusy.value) {
editStatusMessage.value = 'Waiting for a still.'
editProgress.value = 0
}
}
function selectedLibraryEntries() {
const chosen = new Set(selectedKeys.value)
return libraryEntries.value.filter(entry => chosen.has(entry.key))
}
function clearSelection() {
selectedKeys.value = []
lastSelectedKey.value = ''
}
function selectAllLibrary() {
selectedKeys.value = libraryEntries.value.map(entry => entry.key)
lastSelectedKey.value = selectedKeys.value.at(-1) || ''
}
function onSelectClick(key: string, event: MouseEvent) {
const keys = libraryEntries.value.map(entry => entry.key)
if (event.shiftKey && lastSelectedKey.value) {
const start = keys.indexOf(lastSelectedKey.value)
const end = keys.indexOf(key)
if (start >= 0 && end >= 0) {
const [from, to] = start < end ? [start, end] : [end, start]
const range = keys.slice(from, to + 1)
selectedKeys.value = [...new Set([...selectedKeys.value, ...range])]
lastSelectedKey.value = key
return
}
}
if (selectedKeys.value.includes(key)) {
selectedKeys.value = selectedKeys.value.filter(item => item !== key)
} else {
selectedKeys.value = [...selectedKeys.value, key]
}
lastSelectedKey.value = key
}
async function onMoveSelected(event: Event) {
const select = event.target as HTMLSelectElement
const destId = select.value
select.value = ''
if (!destId) return
const items = selectedLibraryEntries()
if (!items.length) return
const dest = folders.value.find(folder => folder.id === destId)
const results = await Promise.allSettled(items.map(entry => {
if (entry.kind === 'clip') {
return $fetch(`/api/library/clips/${entry.group.head.id}`, {
method: 'PATCH',
body: { folderId: destId, family: true }
})
}
return $fetch(`/api/library/stills/${entry.stillGroup!.head.id}`, {
method: 'PATCH',
body: { folderId: destId, family: true }
})
}))
await loadLibrary()
const failed = results.filter(result => result.status === 'rejected').length
const moved = items.length - failed
clearSelection()
if (failed) toast(`${moved} moved to ${dest?.name || 'folder'}. ${failed} could not be moved.`)
else toast(`Moved ${moved} to ${dest?.name || 'folder'}${dest?.unlocked ? '' : ' (locked)'}`)
}
async function removeSelected() {
const items = selectedLibraryEntries()
if (!items.length) return
const label = items.length === 1 ? 'this item' : `these ${items.length} items`
if (!confirm(`Remove ${label} from this folder?`)) return
const clipIds = new Set<string>()
const stillIds = new Set<string>()
const results = await Promise.allSettled(items.map(entry => {
if (entry.kind === 'clip') {
for (const part of entry.group.parts) clipIds.add(part.id)
return $fetch(`/api/library/clips/${entry.group.head.id}`, {
method: 'DELETE',
query: { family: '1' }
})
}
for (const part of entry.stillGroup!.parts) stillIds.add(part.id)
return $fetch(`/api/library/stills/${entry.stillGroup!.head.id}`, {
method: 'DELETE',
query: { family: '1' }
})
}))
if ([...clipIds].some(id => videoUrl.value.includes(id) || currentClipId.value === id)) {
videoUrl.value = ''
awaitingReveal.value = false
concealOutput.value = false
lockedSave.value = false
currentClipId.value = ''
closeExtend()
}
if ([...stillIds].some(id => editResultUrl.value.includes(id))) {
editResultUrl.value = ''
editAwaitingReveal.value = false
concealEditOutput.value = false
editLockedSave.value = false
}
await loadLibrary()
const failed = results.filter(result => result.status === 'rejected').length
clearSelection()
if (failed) toast(`${items.length - failed} removed. ${failed} could not be deleted.`)
}
async function removeClip(item: LibraryClip, family = false) {
const watched = family ? familyPartsFor(item) : [item]
try {
await $fetch(`/api/library/clips/${item.id}`, {
method: 'DELETE',
query: family ? { family: '1' } : undefined
})
const ids = new Set(watched.map(part => part.id))
if ([...ids].some(id => videoUrl.value.includes(id) || currentClipId.value === id)) {
videoUrl.value = ''
awaitingReveal.value = false
concealOutput.value = false
lockedSave.value = false
currentClipId.value = ''
closeExtend()
}
await loadLibrary()
} catch (error: any) {
toast(error?.data?.statusMessage || 'Could not delete clip')
}
}
async function onMoveClip(item: LibraryClip, event: Event, family = false) {
const select = event.target as HTMLSelectElement
const destId = select.value
select.value = ''
if (!destId || destId === item.folderId) return
try {
applyLibrary(await $fetch(`/api/library/clips/${item.id}`, {
method: 'PATCH',
body: { folderId: destId, family: family || undefined }
}))
const dest = folders.value.find(folder => folder.id === destId)
toast(`Moved ${family ? 'collection' : 'clip'} to ${dest?.name || 'folder'}${dest?.unlocked ? '' : ' (locked)'}`)
} catch (error: any) {
toast(error?.data?.statusMessage || 'Could not move clip')
}
}
function startRename(item: LibraryClip, family = false) {
renamingId.value = item.id
renamingFamily.value = family
const source = family
? (familyBaseName(item.name || '') || item.name || '')
: (item.name || '')
renameDraft.value = source.trim() || clipFileName(item.prompt.split(/[.!?\n]/)[0] || 'aigen')
nextTick(() => {
renameInput.value?.focus()
renameInput.value?.select()
})
}
function cancelRename() {
renamingId.value = ''
renamingFamily.value = false
renameDraft.value = ''
}
async function saveRename(item: LibraryClip, family = false) {
if (renamingId.value !== item.id || renamingFamily.value !== family) return
const name = renameDraft.value.trim().slice(0, 80)
if (!name) {
toast('A name is required')
nextTick(() => renameInput.value?.focus())
return
}
const current = family ? familyBaseName(item.name || '') : item.name
if (name === current) {
cancelRename()
return
}
try {
applyLibrary(await $fetch(`/api/library/clips/${item.id}`, {
method: 'PATCH',
body: { name, family: family || undefined }
}))
cancelRename()
if (videoUrl.value.includes(item.id)) {
videoDownloadName.value = clipFileName(name)
}
statusMessage.value = family ? `Renamed collection to ${familyBaseName(name) || name}` : `Renamed to ${name}`
} catch (error: any) {
toast(error?.data?.statusMessage || 'Could not rename clip')
}
}
function onPlayed() {
awaitingReveal.value = false
}
function onVideoMeta() {
const el = player.value
if (!el) {
videoSeekable.value = true
return
}
const duration = el.duration
videoSeekable.value = Number.isFinite(duration) && duration > 0
}
function revealAndPlay() {
awaitingReveal.value = false
nextTick(() => {
player.value?.play().catch(() => null)
})
}
function queueExtension() {
extensionQueue.value.push({
id: crypto.randomUUID(),
prompt: prompt.value.trim(),
duration: duration.value
})
}
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
concealEditOutput.value = hideThumbnail.value
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('hideThumbnail', String(showPrivacyToggles.value && hideThumbnail.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 || hideThumbnail.value, started.folderLocked === true)
listen('edit', started.jobId, started.hideThumbnail === true || hideThumbnail.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')
}
}
function applyEngineDefaults(engine: VideoEngineId) {
if (engine === 'ltx') {
samplerName.value = 'euler'
if (!cfgTouched.value) cfg.value = 3.5
return
}
if (samplerName.value === 'euler') samplerName.value = 'res_multistep'
if (!cfgTouched.value) cfg.value = turbo.value ? CFG_TURBO : CFG_QUALITY
}
function selectVideoEngine(engine: VideoEngineId) {
if (videoEngine.value === engine) return
cfgTouched.value = false
videoEngine.value = engine
applyEngineDefaults(engine)
}
function applyVideoWorkflow(raw?: string) {
const parsed = parseVideoWorkflow(raw)
videoEngine.value = videoEngineOf(parsed)
videoStart.value = videoStartOf(parsed)
minimaxGraph.value = minimaxGraphOf(parsed)
}
async function generate() {
if ((!file.value && !textToVideo.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 }))
const hideOut = hideThumbnail.value
const folderLockedPref = false
try {
const body = new FormData()
if (file.value) 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 (globalLocks.value.trim()) body.append('globalLocks', globalLocks.value)
body.append('permanenceRefs', JSON.stringify(familyPermanenceRefs.value))
const shotRefList = shots
? shots.map(shot => shotPermanenceRefs.value[shot.n] || [])
: [shotPermanenceRefs.value[1] || [], ...queued.map((_, index) => shotPermanenceRefs.value[index + 2] || [])]
body.append('shotPermanenceRefs', JSON.stringify(shotRefList))
if (activeDraftId) body.append('draftId', activeDraftId)
if (queued.length) {
body.append('extensions', JSON.stringify(queued.map((item, index) => ({
...item,
permanenceRefs: shotRefList[index + 1] || []
}))))
body.append('queueAutoRun', String(queueAutoRun.value))
}
const downloadName = clipFileName(clipName.value.trim() || initialPrompt.split(/[.!?\n]/)[0] || 'aigen')
const chainPlan = plannedChain.value.map(part => ({ ...part }))
const started = await $fetch<{
jobId: string
studioJobId?: string
queued?: boolean
seed: number
width: number
height: number
steps: number
turbo: boolean
hideThumbnail?: boolean
folderLocked?: boolean
queueId?: string
chainTotal?: number
}>('/api/generate', {
method: 'POST',
body
})
if (started.queued) {
clearVideoForm()
const n = 1 + queued.length
toast(n > 1
? `Queued ${n} shots behind the current job.`
: 'Queued behind the current job.')
await refreshStudioQueue()
return
}
clearVideoForm()
statusBusy.value = false
videoBusy.value = true
stopListen('video')
videoSettledUi = false
progress.value = 1
videoUrl.value = ''
currentClipId.value = ''
closeExtend()
awaitingReveal.value = false
concealOutput.value = started.hideThumbnail === true || hideOut
lockedSave.value = false
watchingLibrary.value = false
playerSize.value = { width: started.width, height: started.height }
statusMessage.value = 'Checking ComfyUI...'
chainStep.value = 1
chainTotal.value = started.chainTotal || (1 + queued.length)
chainLabel.value = queued.length ? 'Initial' : ''
overallProgress.value = 0
completedChainStep.value = 0
activeChainPlan.value = chainPlan
startTimer('video')
videoDownloadName.value = downloadName
videoJobId.value = started.jobId
outputStudioJobId.value = started.studioJobId || ''
maxStep.value = started.steps
persistActiveJob('video', started.jobId, started.hideThumbnail === true || hideOut, started.folderLocked === true || folderLockedPref)
if (started.queueId) {
persistActiveQueue(started.queueId)
void refreshLiveQueue()
}
listen('video', started.jobId, started.hideThumbnail === true || hideOut, started.folderLocked === true)
void pollComfyHealth()
void refreshStudioQueue()
} catch (error: any) {
videoBusy.value = false
statusBusy.value = false
stopTimer('video')
clearActiveJob('video')
const message = error?.data?.statusMessage || error?.statusMessage || error?.message || 'Failed to start generation'
statusMessage.value = message
toast(message)
}
}
function stopListen(kind?: 'video' | 'edit') {
if (!kind || kind === 'video') {
videoSource?.close()
videoSource = null
if (videoPollTimer) {
clearInterval(videoPollTimer)
videoPollTimer = null
}
}
if (!kind || kind === 'edit') {
editSource?.close()
editSource = null
if (editPollTimer) {
clearInterval(editPollTimer)
editPollTimer = null
}
}
}
function applyEditEvent(payload: Record<string, any>, hidden: boolean, folderLocked: boolean) {
if (payload.message) editStatusMessage.value = payload.message
editStatusBusy.value = payload.type === 'busy' || payload.busy === true
if (typeof payload.progress === 'number') editProgress.value = payload.progress
if (typeof payload.step === 'number') editStep.value = payload.step
if (typeof payload.maxStep === 'number') editMaxStep.value = payload.maxStep
if (typeof payload.chainStep === 'number') editChainStep.value = payload.chainStep
if (typeof payload.chainTotal === 'number') editChainTotal.value = payload.chainTotal
if (payload.chainLabel) editChainLabel.value = String(payload.chainLabel)
if (typeof payload.overallProgress === 'number') editOverallProgress.value = payload.overallProgress
const failed = payload.type === 'error' || payload.status === 'error' || payload.status === 'cancelled'
const complete = payload.type === 'complete' || payload.status === 'complete'
if (editSettledUi) return
const dest = folders.value.find(folder => folder.id === folderId.value)
const locked = dest
? Boolean(dest.protected && !dest.unlocked)
: (folderLocked || payload.folderLocked === true)
if (payload.stillId && !locked) {
editResultUrl.value = `/api/library/stills/${payload.stillId}`
editLockedSave.value = false
if (payload.hideThumbnail || hidden) {
concealEditOutput.value = true
if (payload.type !== 'checkpoint') editAwaitingReveal.value = true
}
}
if (payload.type === 'checkpoint' && payload.stillId) {
editCompletedChainStep.value = Math.max(editCompletedChainStep.value, Number(payload.chainStep || editChainStep.value) || 0)
scheduleLibraryRefresh()
return
}
if (complete && payload.stillId) {
const total = Math.max(1, Number(payload.chainTotal || 0) || 0, Number(editChainTotal.value || 0) || 0)
const step = Math.max(1, Number(payload.chainStep || 0) || 0, Number(editChainStep.value || 0) || 0)
if (total > 1 && step < total) {
editCompletedChainStep.value = Math.max(editCompletedChainStep.value, step)
scheduleLibraryRefresh()
if (locked) {
editResultUrl.value = ''
editLockedSave.value = true
}
editStatusMessage.value = `Pass ${step} of ${total} saved, but the remaining passes did not start.`
editProgress.value = Math.min(Number(payload.progress || editProgress.value || 99), 99)
toast(`Pass ${step} of ${total} saved. Remaining passes did not run.`)
editBusy.value = false
editStatusBusy.value = false
stopTimer('edit')
stopListen('edit')
clearActiveJob('edit')
void pollComfyHealth()
editSettledUi = true
return
}
editSettledUi = true
if (locked) {
editResultUrl.value = ''
editLockedSave.value = true
browseFolderId.value = folderId.value
editStatusMessage.value = 'Saved to the locked folder. Unlock it to view.'
} else {
editResultUrl.value = `/api/library/stills/${payload.stillId}`
concealEditOutput.value = Boolean(payload.hideThumbnail || hidden)
editAwaitingReveal.value = concealEditOutput.value
editLockedSave.value = false
editStatusMessage.value = total > 1 ? 'Edit chain finished' : 'Edit finished'
editCompletedChainStep.value = total
}
editBusy.value = false
editStatusBusy.value = false
stopTimer('edit')
stopListen('edit')
clearActiveJob('edit')
scheduleLibraryRefresh()
void pollComfyHealth()
return
}
if (failed) {
editSettledUi = true
editBusy.value = false
editStatusBusy.value = false
stopTimer('edit')
stopListen('edit')
clearActiveJob('edit')
scheduleLibraryRefresh()
void pollComfyHealth()
toast(payload.error || payload.message || 'Image edit failed')
}
}
function applyVideoEvent(payload: Record<string, any>, hidden: boolean, folderLocked: boolean) {
if (payload.queueId) persistActiveQueue(String(payload.queueId))
if (payload.message) statusMessage.value = payload.message
statusBusy.value = payload.type === 'busy' || payload.busy === true
if (typeof payload.progress === 'number') progress.value = payload.progress
if (typeof payload.step === 'number') step.value = payload.step
if (typeof payload.maxStep === 'number') maxStep.value = payload.maxStep
if (typeof payload.chainStep === 'number') chainStep.value = payload.chainStep
if (typeof payload.chainTotal === 'number') chainTotal.value = payload.chainTotal
if (payload.chainLabel) chainLabel.value = String(payload.chainLabel)
if (typeof payload.overallProgress === 'number') overallProgress.value = payload.overallProgress
const failed = payload.type === 'error' || payload.status === 'error' || payload.status === 'cancelled'
const complete = payload.type === 'complete' || payload.status === 'complete'
const deferred = payload.type === 'deferred' || payload.status === 'deferred'
if (videoSettledUi) return
if (payload.stillId) return
if (payload.type === 'checkpoint' && payload.clipId) {
completedChainStep.value = Math.max(completedChainStep.value, Number(payload.chainStep || chainStep.value) || 0)
scheduleLibraryRefresh()
void refreshLiveQueue()
const locked = folderLocked || payload.folderLocked === true
if (!watchingLibrary.value && !locked) {
currentClipId.value = payload.clipId
videoUrl.value = clipVideoUrl(payload.clipId)
concealOutput.value = hidden || payload.hideThumbnail === true
}
return
}
if (deferred) {
videoSettledUi = true
videoBusy.value = false
statusBusy.value = false
stopTimer('video')
stopListen('video')
clearActiveJob('video')
scheduleLibraryRefresh()
void pollComfyHealth()
void refreshStudioQueue()
toast(payload.message || 'Comfy was still busy. This job was saved under Held jobs.')
return
}
if (complete && (payload.clipId || payload.filename)) {
const total = Math.max(1, Number(payload.chainTotal || 0) || 0, Number(chainTotal.value || 0) || 0)
const step = Math.max(1, Number(payload.chainStep || 0) || 0, Number(chainStep.value || 0) || 0)
const remaining = Number(payload.queuedRemaining || 0)
if (remaining > 0 || (total > 1 && step < total)) {
completedChainStep.value = Math.max(completedChainStep.value, step)
scheduleLibraryRefresh()
const dest = folders.value.find(folder => folder.id === folderId.value)
const locked = dest
? Boolean(dest.protected && !dest.unlocked)
: (folderLocked || payload.folderLocked === true)
if (!watchingLibrary.value && !locked && payload.clipId) {
currentClipId.value = payload.clipId
videoUrl.value = clipVideoUrl(payload.clipId)
concealOutput.value = hidden || payload.hideThumbnail === true
}
const left = remaining || Math.max(0, total - step)
statusMessage.value = `Shot ${step} of ${total} saved. ${left} waiting on Queue.`
progress.value = Number(payload.progress || progress.value || 100)
toast(`Shot ${step} of ${total} saved. ${left} shot${left === 1 ? '' : 's'} waiting on Queue — edit prompts or process next.`)
queuePendingCount.value = left
void refreshLiveQueue()
videoBusy.value = false
statusBusy.value = false
stopTimer('video')
stopListen('video')
clearActiveJob('video')
activeDraftId = ''
void pollComfyHealth()
void refreshStudioQueue()
videoSettledUi = true
return
}
videoSettledUi = true
const dest = folders.value.find(folder => folder.id === folderId.value)
const locked = dest
? Boolean(dest.protected && !dest.unlocked)
: (folderLocked || payload.folderLocked === true)
const keepLibraryView = watchingLibrary.value && Boolean(currentClipId.value) && payload.clipId !== currentClipId.value
if (keepLibraryView) {
videoBusy.value = false
statusBusy.value = false
stopTimer('video')
stopListen('video')
clearActiveJob('video')
activeDraftId = ''
scheduleLibraryRefresh()
void pollComfyHealth()
void refreshStudioQueue()
statusMessage.value = currentClip.value?.hideThumbnail ? 'Hidden clip ready' : 'Loaded from library'
toast(locked
? 'Saved to the locked folder. Unlock it to view.'
: 'New clip saved to the library')
return
}
if (locked) {
videoUrl.value = ''
awaitingReveal.value = false
concealOutput.value = false
lockedSave.value = true
browseFolderId.value = folderId.value
statusMessage.value = 'Saved to the locked folder. Unlock it to view.'
currentClipId.value = payload.clipId || currentClipId.value
} else if (payload.clipId) {
currentClipId.value = payload.clipId
videoUrl.value = clipVideoUrl(payload.clipId)
awaitingReveal.value = Boolean(payload.hideThumbnail || hidden)
concealOutput.value = awaitingReveal.value
lockedSave.value = false
} else {
videoUrl.value = `/api/media?filename=${encodeURIComponent(payload.filename)}&subfolder=${encodeURIComponent(payload.subfolder || 'video')}&type=${encodeURIComponent(payload.mediaType || 'output')}&t=${Date.now()}`
awaitingReveal.value = Boolean(payload.hideThumbnail || hidden)
concealOutput.value = awaitingReveal.value
lockedSave.value = false
}
videoBusy.value = false
statusBusy.value = false
stopTimer('video')
stopListen('video')
clearActiveJob('video')
activeDraftId = ''
scheduleLibraryRefresh()
void pollComfyHealth()
void refreshStudioQueue()
return
}
if (failed) {
videoSettledUi = true
videoBusy.value = false
statusBusy.value = false
stopTimer('video')
stopListen('video')
clearActiveJob('video')
scheduleLibraryRefresh()
void pollComfyHealth()
void refreshStudioQueue()
toast(payload.error || payload.message || 'Generation failed')
}
}
function applyJobEvent(kind: 'video' | 'edit', payload: Record<string, any>, hidden: boolean, folderLocked: boolean, expectedJobId: string) {
const currentId = kind === 'edit' ? editJobId.value : videoJobId.value
if (expectedJobId && currentId && expectedJobId !== currentId) return
if (payload.jobId && expectedJobId && payload.jobId !== expectedJobId) return
if (kind === 'edit') applyEditEvent(payload, hidden, folderLocked)
else applyVideoEvent(payload, hidden, folderLocked)
}
function listen(kind: 'video' | 'edit', id: string, hidden: boolean, folderLocked = false) {
stopListen(kind)
const isEdit = kind === 'edit'
const gen = isEdit ? ++editListenGen : ++videoListenGen
const source = new EventSource(`/api/generate/${id}/stream`)
if (isEdit) editSource = source
else videoSource = source
source.onmessage = (event) => {
if (gen !== (isEdit ? editListenGen : videoListenGen)) return
applyJobEvent(kind, JSON.parse(event.data), hidden, folderLocked, id)
}
source.onerror = () => {
if (gen !== (isEdit ? editListenGen : videoListenGen)) return
source.close()
setTimeout(() => {
if (gen !== (isEdit ? editListenGen : videoListenGen)) return
if (isEdit ? editSettledUi : videoSettledUi) return
listen(kind, id, hidden, folderLocked)
}, 2000)
}
let misses = 0
const tick = async () => {
const currentGen = isEdit ? editListenGen : videoListenGen
const settled = isEdit ? editSettledUi : videoSettledUi
if (gen !== currentGen || settled) {
if (gen === currentGen && settled) stopListen(kind)
return
}
try {
const snap = await $fetch<Record<string, any>>(`/api/generate/${id}`)
misses = 0
if (gen !== (isEdit ? editListenGen : videoListenGen)) return
applyJobEvent(kind, snap, hidden, folderLocked, id)
} catch {
if (gen !== (isEdit ? editListenGen : videoListenGen)) return
try {
const recovered = await $fetch<Record<string, any>>('/api/generate/recover', {
method: 'POST',
body: { jobId: id }
})
misses = 0
if (gen !== (isEdit ? editListenGen : videoListenGen)) return
applyJobEvent(kind, recovered, hidden, folderLocked, id)
} catch {
misses += 1
if (misses >= 20) {
applyJobEvent(kind, {
type: 'error',
status: 'error',
error: 'Lost the running job after refresh. Check the library, or generate again.',
message: 'Lost the running job after refresh. Check the library, or generate again.'
}, hidden, folderLocked, id)
}
}
}
}
void tick()
const poll = setInterval(() => {
if (typeof document !== 'undefined' && document.hidden) return
void tick()
}, 4000)
if (isEdit) editPollTimer = poll
else videoPollTimer = poll
}
async function cancelJob() {
const kind = outputProgressKind.value === 'edit' && editBusy.value
? 'edit'
: videoBusy.value
? 'video'
: studioMode.value === 'edit' ? 'edit' : 'video'
const id = kind === 'edit' ? editJobId.value : videoJobId.value
try {
await $fetch('/api/interrupt', { method: 'POST', body: { jobId: id, kind } })
if (kind === 'edit') editStatusMessage.value = 'Edit cancelled. GPU is idle — edit again.'
else statusMessage.value = 'Job cancelled. GPU is idle — generate again.'
} catch (error: any) {
toast(error?.statusMessage || error?.data?.statusMessage || 'Failed to interrupt the job')
if (kind === 'edit') editStatusMessage.value = 'Cancelled locally. Edit again when the GPU is free.'
else statusMessage.value = 'Cancelled locally. Generate again when the GPU is free.'
}
if (kind === 'edit') {
editSettledUi = true
editBusy.value = false
editStatusBusy.value = false
stopTimer('edit')
stopListen('edit')
clearActiveJob('edit')
} else {
videoSettledUi = true
videoBusy.value = false
statusBusy.value = false
stopTimer('video')
stopListen('video')
clearActiveJob('video')
}
}
</script>