4515 lines
191 KiB
Vue
4515 lines
191 KiB
Vue
<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"><Picture 1></span> identity prefix in front of it and send the result to <span class="font-medium text-zinc-300">Reference to Video</span>, not Image to Video.
|
||
</p>
|
||
<textarea
|
||
v-model="prompt"
|
||
:rows="shotScriptMode || identityPrompting ? 12 : 6"
|
||
class="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
|
||
@play="onPlayed"
|
||
/>
|
||
<button
|
||
v-if="awaitingReveal && !lockedSave"
|
||
type="button"
|
||
class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-amber-100"
|
||
@click="revealAndPlay"
|
||
>
|
||
<span class="font-display text-lg font-bold">Ready</span>
|
||
<span class="text-sm text-amber-100/80">Click to play · preview hidden</span>
|
||
</button>
|
||
<div
|
||
v-else-if="lockedSave"
|
||
class="absolute inset-0 flex flex-col items-center justify-center gap-2 px-6 text-center text-amber-100"
|
||
>
|
||
<span class="font-display text-lg font-bold">Saved to a locked folder</span>
|
||
<span class="text-sm text-amber-100/80">Unlock that folder in the library to view this clip</span>
|
||
</div>
|
||
<div
|
||
v-else-if="!videoUrl"
|
||
class="flex h-full min-h-[12rem] items-center justify-center text-sm text-zinc-500"
|
||
>
|
||
Generated MP4 appears here
|
||
</div>
|
||
</div>
|
||
<div v-if="videoUrl && !lockedSave" class="mt-4 flex flex-wrap gap-3">
|
||
<a
|
||
v-if="!awaitingReveal"
|
||
:href="videoUrl"
|
||
:download="`${videoDownloadName}.mp4`"
|
||
class="inline-flex rounded-2xl border border-white/15 px-4 py-2 text-sm hover:border-amber-300/50"
|
||
>
|
||
Download MP4
|
||
</a>
|
||
<button
|
||
v-if="canExtend"
|
||
type="button"
|
||
class="inline-flex rounded-2xl border border-amber-300/40 px-4 py-2 text-sm text-amber-100 hover:border-amber-300/70"
|
||
@click="openExtend()"
|
||
>
|
||
Extend Video
|
||
</button>
|
||
</div>
|
||
<div v-if="extendOpen" class="mt-4 space-y-4 rounded-2xl border border-white/10 bg-zinc-950/50 p-4">
|
||
<div class="flex items-start justify-between gap-3">
|
||
<div>
|
||
<h3 class="text-sm font-semibold text-amber-100">Extend this clip</h3>
|
||
<p class="mt-1 text-xs text-zinc-500">Continue from the last frame with a new motion prompt, then stitch the segments together.{{ extendSaveName ? ` Saves as “${extendSaveName}”.` : '' }}</p>
|
||
</div>
|
||
<button type="button" class="text-xs text-zinc-400 hover:text-white" @click="closeExtend">Close</button>
|
||
</div>
|
||
<div class="overflow-hidden rounded-xl bg-black/40">
|
||
<img v-if="lastFrameUrl" :src="lastFrameUrl" alt="Last frame" class="max-h-48 w-full object-contain">
|
||
<p v-else class="px-3 py-8 text-center text-xs text-zinc-500">
|
||
{{ lastFrameLoading ? 'Reading last frame…' : 'Last frame preview unavailable' }}
|
||
</p>
|
||
</div>
|
||
<label class="block text-sm">
|
||
<span class="mb-1 block text-zinc-400">Extension prompt</span>
|
||
<textarea
|
||
v-model="extendPrompt"
|
||
rows="4"
|
||
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
||
placeholder="Continue the motion from this frame…"
|
||
/>
|
||
</label>
|
||
<DurationField
|
||
v-model="extendDuration"
|
||
label="Extension duration"
|
||
hint="Type any length, use the slider, or drag up/down in the box."
|
||
/>
|
||
<button
|
||
type="button"
|
||
class="w-full rounded-2xl bg-amber-400 px-4 py-2.5 text-sm font-semibold text-zinc-950 transition hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
|
||
:disabled="!extendPrompt.trim() || lastFrameLoading || videoBusy"
|
||
@click="generateExtension"
|
||
>
|
||
Generate Extension
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-show="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 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 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
|
||
if (!videoBusy.value && !editBusy.value) 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(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
|
||
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 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>
|