Ship MiniMax v2 identity refs and Qwen sidecar edits.
Keep v1 as the default video graph, relay image edits to the Qwen Lightning box, and keep /api/health up if one Comfy host is unset. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+387
-22
@@ -6,7 +6,7 @@
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-amber-400 text-zinc-950 font-display font-extrabold">A</div>
|
||||
<div>
|
||||
<h1 class="font-display text-xl font-bold leading-none">{{ instanceName }}</h1>
|
||||
<p class="text-[11px] uppercase tracking-[0.22em] text-zinc-500">MiniMax H3 · ComfyUI relay</p>
|
||||
<p class="text-[11px] uppercase tracking-[0.22em] text-zinc-500">MiniMax H3 · Qwen Edit · ComfyUI</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
@@ -17,6 +17,14 @@
|
||||
<span class="h-2 w-2 rounded-full" :class="comfyBadge.dot" />
|
||||
{{ comfyBadge.label }}
|
||||
</span>
|
||||
<span
|
||||
class="hidden lg:inline-flex items-center gap-2 rounded-full border px-3 py-1"
|
||||
:class="imageComfyBadge.className"
|
||||
:title="imageComfyHost || 'Image edit sidecar'"
|
||||
>
|
||||
<span class="h-2 w-2 rounded-full" :class="imageComfyBadge.dot" />
|
||||
{{ imageComfyBadge.label }}
|
||||
</span>
|
||||
<span class="hidden md:block text-zinc-400">{{ userName }}</span>
|
||||
<button type="button" class="rounded-full border border-white/10 px-3 py-1 text-zinc-300 hover:border-amber-300/50 hover:text-white" @click="settingsOpen = true">
|
||||
Settings
|
||||
@@ -29,8 +37,28 @@
|
||||
<main class="mx-auto grid max-w-7xl gap-6 px-4 py-6 sm:px-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||
<section class="glass rounded-3xl p-5 sm:p-6 space-y-5">
|
||||
<div>
|
||||
<div class="mb-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border px-3 py-1 text-xs font-medium"
|
||||
:class="studioMode === 'video' ? 'border-amber-300/70 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400 hover:text-white'"
|
||||
@click="studioMode = 'video'"
|
||||
>
|
||||
Video
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border px-3 py-1 text-xs font-medium"
|
||||
:class="studioMode === 'edit' ? 'border-amber-300/70 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400 hover:text-white'"
|
||||
@click="studioMode = 'edit'"
|
||||
>
|
||||
Image edit
|
||||
</button>
|
||||
</div>
|
||||
<h2 class="font-display text-2xl font-bold">Input</h2>
|
||||
<p class="text-sm text-zinc-400">Drop a still, describe the motion, then relay the job to the local desktop.</p>
|
||||
<p class="text-sm text-zinc-400">{{ studioMode === 'edit'
|
||||
? 'Drop a still and describe the edit. This relays to the sidecar Qwen Comfy box once the workflow is mapped.'
|
||||
: 'Drop a still, describe the motion, then relay the job to the local desktop.' }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -39,7 +67,7 @@
|
||||
@dragover.prevent="dragging = true"
|
||||
@dragleave.prevent="dragging = false"
|
||||
@drop.prevent="onDrop"
|
||||
@click="pickerOpen = true"
|
||||
@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">
|
||||
@@ -59,8 +87,8 @@
|
||||
|
||||
<div>
|
||||
<div class="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<label class="text-sm font-medium text-zinc-300">Motion & scene prompt</label>
|
||||
<label class="flex cursor-pointer items-center gap-2 text-xs text-zinc-400">
|
||||
<label class="text-sm font-medium text-zinc-300">{{ studioMode === 'edit' ? 'Edit prompt' : 'Motion & scene prompt' }}</label>
|
||||
<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" />
|
||||
@@ -69,18 +97,20 @@
|
||||
Shot script
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="shotScriptMode" class="mb-2 text-xs text-zinc-500">
|
||||
<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. Write <span class="font-medium text-zinc-300">shot 2</span>, <span class="font-medium text-zinc-300">shot 3</span>, and so on to queue extensions. Every shot uses the duration slider.
|
||||
</p>
|
||||
<textarea
|
||||
v-model="prompt"
|
||||
:rows="shotScriptMode ? 12 : 6"
|
||||
class="w-full rounded-2xl border border-white/10 bg-zinc-950/70 px-4 py-3 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
||||
:placeholder="shotScriptMode
|
||||
:placeholder="studioMode === 'edit'
|
||||
? 'Make the lighting warmer. Replace the background with a rainy street at night. Keep the face unchanged.'
|
||||
: (shotScriptMode
|
||||
? '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.'
|
||||
: 'Camera slowly pushes in. Soft daylight, fabric ripples, dust motes in the air. Audio: quiet room tone, distant birds.'"
|
||||
: 'Camera slowly pushes in. Soft daylight, fabric ripples, dust motes in the air. Audio: quiet room tone, distant birds.')"
|
||||
/>
|
||||
<div v-if="shotScriptMode" class="mt-3 space-y-2">
|
||||
<div v-if="shotScriptMode && studioMode === 'video'" class="mt-3 space-y-2">
|
||||
<p v-if="!parsedShots.length" class="text-xs text-zinc-500">Add a first shot, then <span class="text-zinc-300">shot 2</span> for an extension.</p>
|
||||
<div
|
||||
v-for="(shot, index) in parsedShots"
|
||||
@@ -96,7 +126,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!shotScriptMode" class="space-y-3">
|
||||
<div v-if="studioMode === 'video' && !shotScriptMode" class="space-y-3">
|
||||
<div
|
||||
v-for="(item, index) in extensionQueue"
|
||||
:key="item.id"
|
||||
@@ -171,7 +201,7 @@
|
||||
<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 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">
|
||||
<label v-if="studioMode === 'video'" class="flex cursor-pointer items-center justify-between gap-3 rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3 text-sm">
|
||||
<span>
|
||||
<span class="block font-medium text-zinc-200">Generate sound</span>
|
||||
<span class="mt-0.5 block text-xs text-zinc-500">Off skips the audio VAE so Comfy encodes a silent clip.</span>
|
||||
@@ -182,7 +212,7 @@
|
||||
<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 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">
|
||||
<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>
|
||||
@@ -197,7 +227,79 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details class="rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3">
|
||||
<div v-if="studioMode === 'video'" class="space-y-3">
|
||||
<div>
|
||||
<p class="mb-2 text-xs uppercase tracking-wider text-zinc-500">MiniMax graph</p>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-xl border px-3 py-2 text-left text-sm"
|
||||
:class="videoWorkflow === 'v1' ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 hover:border-white/20'"
|
||||
@click="videoWorkflow = 'v1'"
|
||||
>
|
||||
<span class="block font-semibold">v1 · validated</span>
|
||||
<span class="text-xs text-zinc-400">First-frame image to video</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-xl border px-3 py-2 text-left text-sm"
|
||||
:class="videoWorkflow === 'v2' ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 hover:border-white/20'"
|
||||
@click="videoWorkflow = 'v2'"
|
||||
>
|
||||
<span class="block font-semibold">v2 · identity refs</span>
|
||||
<span class="text-xs text-zinc-400">Optional lock with up to 4 stills</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="videoWorkflow === 'v2'" class="rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3 space-y-3">
|
||||
<label class="flex cursor-pointer items-center justify-between gap-3 text-sm">
|
||||
<span>
|
||||
<span class="block font-medium text-zinc-200">Use identity references</span>
|
||||
<span class="mt-0.5 block text-xs text-zinc-500">Off uses first-frame only. On sends extra stills into Ref2VA.</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="block w-full text-left"
|
||||
@click="openPicker(slot - 1)"
|
||||
>
|
||||
<img
|
||||
v-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">
|
||||
Ref {{ slot }}
|
||||
</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">Extra stills lock people and objects. The first-frame still above stays the opening frame. Identity refs are not reused on queued extensions.</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>
|
||||
@@ -296,14 +398,47 @@
|
||||
</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">
|
||||
Sidecar: <span class="text-zinc-200">{{ imageComfyHost || 'not configured' }}</span>
|
||||
· Qwen Image Edit Lightning (4-step LoRA)
|
||||
</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="1" max="8" 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">
|
||||
<span class="mb-1 block text-zinc-400">Denoise · {{ editDenoise }}</span>
|
||||
<input v-model.number="editDenoise" type="range" min="0.05" max="1" step="0.05" class="w-full">
|
||||
</label>
|
||||
<label class="block text-sm">
|
||||
<span class="mb-1 block text-zinc-400">Seed</span>
|
||||
<input v-model="seedInput" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm" placeholder="random">
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-xs text-zinc-500">Lower denoise keeps more of the original still. Lightning is tuned for 4 steps, CFG 1, denoise around 0.75.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 rounded-2xl bg-amber-400 px-5 py-3 font-semibold text-zinc-950 transition hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="busy || !file || !prompt.trim() || !folderId || !queueReady"
|
||||
@click="generate"
|
||||
:disabled="busy || !file || !prompt.trim() || !folderId || (studioMode !== 'edit' && !queueReady)"
|
||||
@click="studioMode === 'edit' ? editImage() : generate()"
|
||||
>
|
||||
{{ generateLabel }}
|
||||
{{ studioMode === 'edit' ? (busy ? 'Editing…' : 'Edit image') : generateLabel }}
|
||||
</button>
|
||||
<button
|
||||
v-if="busy"
|
||||
@@ -375,8 +510,15 @@
|
||||
:class="{ 'sensitive-glow': awaitingReveal || lockedSave }"
|
||||
:style="{ aspectRatio: `${playerSize.width} / ${playerSize.height}` }"
|
||||
>
|
||||
<img
|
||||
v-if="editResultUrl && !lockedSave"
|
||||
:src="editResultUrl"
|
||||
alt="Edited still"
|
||||
class="h-full w-full bg-black object-contain"
|
||||
:class="{ 'opacity-0': awaitingReveal }"
|
||||
>
|
||||
<video
|
||||
v-if="videoUrl && !lockedSave"
|
||||
v-else-if="videoUrl && !lockedSave"
|
||||
ref="player"
|
||||
:src="videoUrl"
|
||||
class="h-full w-full bg-black"
|
||||
@@ -404,12 +546,28 @@
|
||||
<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"
|
||||
v-else-if="!videoUrl && !editResultUrl"
|
||||
class="flex h-full min-h-[12rem] items-center justify-center text-sm text-zinc-500"
|
||||
>
|
||||
Generated MP4 appears here
|
||||
{{ studioMode === 'edit' ? 'Edited still appears here' : 'Generated MP4 appears here' }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="editResultUrl && !lockedSave && !awaitingReveal" class="mt-4 flex flex-wrap gap-3">
|
||||
<a
|
||||
:href="editResultUrl"
|
||||
:download="`${outputDownloadName || '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 v-if="videoUrl && !lockedSave" class="mt-4 flex flex-wrap gap-3">
|
||||
<a
|
||||
v-if="!awaitingReveal"
|
||||
@@ -646,6 +804,7 @@
|
||||
:stills="stills"
|
||||
:folder-id="folderId"
|
||||
:hide-input-preview="hideInputPreview"
|
||||
:heading="pickerHeading"
|
||||
@close="pickerOpen = false"
|
||||
@updated="applyLibrary"
|
||||
@error="toast"
|
||||
@@ -712,6 +871,7 @@ interface LibraryClip {
|
||||
familyId?: string
|
||||
parentClipId?: string
|
||||
chainIndex?: number
|
||||
workflow?: 'v1' | 'v2'
|
||||
}
|
||||
|
||||
interface RetryDraft {
|
||||
@@ -737,6 +897,7 @@ interface RetryDraft {
|
||||
samplerName?: string
|
||||
scheduler?: string
|
||||
extensions?: { prompt: string; duration: number }[]
|
||||
workflow?: 'v1' | 'v2'
|
||||
}
|
||||
|
||||
interface QueuedExtension {
|
||||
@@ -790,6 +951,11 @@ const hideInputPreview = ref(false)
|
||||
const autoplayEnabled = ref(false)
|
||||
const JOB_STORE = 'aigen-active-job'
|
||||
const busy = ref(false)
|
||||
const studioMode = ref('video')
|
||||
const imageComfyOk = ref(false)
|
||||
const imageComfyBusy = ref(false)
|
||||
const imageComfyConfigured = ref(false)
|
||||
const imageComfyHost = ref('')
|
||||
const progress = ref(0)
|
||||
const statusMessage = ref('Waiting for a still.')
|
||||
const statusBusy = ref(false)
|
||||
@@ -804,6 +970,11 @@ 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 editSteps = ref(4)
|
||||
const editCfg = ref(1)
|
||||
const editDenoise = ref(0.75)
|
||||
const editNegative = ref('blurry, low quality, deformed, bad anatomy, watermark, text, extra fingers')
|
||||
const jobId = ref('')
|
||||
const currentClipId = ref('')
|
||||
const extendOpen = ref(false)
|
||||
@@ -838,6 +1009,15 @@ const renameInput = ref<HTMLInputElement | null>(null)
|
||||
const unlockPassword = ref('')
|
||||
const settingsOpen = ref(false)
|
||||
const pickerOpen = ref(false)
|
||||
const pickerSlot = ref<'main' | number>('main')
|
||||
const videoWorkflow = ref('v1')
|
||||
const useIdentityRefs = ref(false)
|
||||
const identityRefs = ref<(File | null)[]>([null, null, null, null])
|
||||
const identityPreviews = ref(['', '', '', ''])
|
||||
const pickerHeading = computed(() => {
|
||||
if (pickerSlot.value === 'main') return 'Choose an image'
|
||||
return `Choose identity ref ${Number(pickerSlot.value) + 1}`
|
||||
})
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
let source: EventSource | null = null
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
@@ -875,6 +1055,35 @@ const comfyBadge = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const imageComfyBadge = computed(() => {
|
||||
if (!imageComfyConfigured.value) {
|
||||
return {
|
||||
label: 'Edit sidecar unset',
|
||||
className: 'border-white/10 text-zinc-500',
|
||||
dot: 'bg-zinc-500'
|
||||
}
|
||||
}
|
||||
if (!imageComfyOk.value) {
|
||||
return {
|
||||
label: 'Edit Comfy offline',
|
||||
className: 'border-red-400/30 text-red-300',
|
||||
dot: 'bg-red-400'
|
||||
}
|
||||
}
|
||||
if (imageComfyBusy.value) {
|
||||
return {
|
||||
label: 'Edit Comfy busy',
|
||||
className: 'border-amber-400/40 text-amber-200',
|
||||
dot: 'bg-amber-400'
|
||||
}
|
||||
}
|
||||
return {
|
||||
label: 'Edit Comfy online',
|
||||
className: 'border-emerald-400/30 text-emerald-300',
|
||||
dot: 'bg-emerald-400'
|
||||
}
|
||||
})
|
||||
|
||||
function snap32(value: number) {
|
||||
return Math.max(32, Math.round(value / 32) * 32)
|
||||
}
|
||||
@@ -1060,9 +1269,16 @@ async function loadLibrary() {
|
||||
applyLibrary(data)
|
||||
}
|
||||
|
||||
function applyComfyHealth(health: { comfy?: { ok?: boolean; busy?: boolean } } | null | undefined) {
|
||||
function applyComfyHealth(health: {
|
||||
comfy?: { ok?: boolean; busy?: boolean }
|
||||
imageComfy?: { ok?: boolean; busy?: boolean; configured?: boolean; host?: string }
|
||||
} | 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 || '')
|
||||
}
|
||||
|
||||
async function pollComfyHealth() {
|
||||
@@ -1075,6 +1291,8 @@ onMounted(async () => {
|
||||
hideInputPreview.value = localStorage.getItem('aigen-hide-input-preview') === 'true'
|
||||
withSound.value = localStorage.getItem('aigen-generate-sound') !== 'false'
|
||||
autoplayEnabled.value = localStorage.getItem('aigen-autoplay') === 'true'
|
||||
videoWorkflow.value = localStorage.getItem('aigen-video-workflow') === 'v2' ? 'v2' : 'v1'
|
||||
useIdentityRefs.value = localStorage.getItem('aigen-use-identity-refs') === 'true'
|
||||
} catch { /* ignore */ }
|
||||
applyInstancePrivacy()
|
||||
window.addEventListener('pointerdown', onSitePointerDown, true)
|
||||
@@ -1129,6 +1347,18 @@ watch(autoplayEnabled, (value) => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(videoWorkflow, (value) => {
|
||||
try {
|
||||
localStorage.setItem('aigen-video-workflow', value)
|
||||
} catch { /* ignore */ }
|
||||
})
|
||||
|
||||
watch(useIdentityRefs, (value) => {
|
||||
try {
|
||||
localStorage.setItem('aigen-use-identity-refs', String(value))
|
||||
} catch { /* ignore */ }
|
||||
})
|
||||
|
||||
watch(turbo, (on) => {
|
||||
if (!cfgTouched.value) cfg.value = on ? CFG_TURBO : CFG_QUALITY
|
||||
})
|
||||
@@ -1221,10 +1451,42 @@ function readFile(next: File) {
|
||||
img.src = preview.value
|
||||
}
|
||||
|
||||
function openPicker(slot: 'main' | number = 'main') {
|
||||
pickerSlot.value = slot
|
||||
pickerOpen.value = true
|
||||
}
|
||||
|
||||
function setIdentityRef(index: number, next: File) {
|
||||
const prev = identityPreviews.value[index]
|
||||
if (prev) URL.revokeObjectURL(prev)
|
||||
const copies = identityRefs.value.slice()
|
||||
copies[index] = next
|
||||
identityRefs.value = copies
|
||||
const previews = identityPreviews.value.slice()
|
||||
previews[index] = URL.createObjectURL(next)
|
||||
identityPreviews.value = previews
|
||||
}
|
||||
|
||||
function clearIdentityRef(index: number) {
|
||||
const prev = identityPreviews.value[index]
|
||||
if (prev) URL.revokeObjectURL(prev)
|
||||
const copies = identityRefs.value.slice()
|
||||
copies[index] = null
|
||||
identityRefs.value = copies
|
||||
const previews = identityPreviews.value.slice()
|
||||
previews[index] = ''
|
||||
identityPreviews.value = previews
|
||||
}
|
||||
|
||||
function onPickedImage(next: File, hidden = false) {
|
||||
const slot = pickerSlot.value
|
||||
pickerOpen.value = false
|
||||
if (slot !== 'main') {
|
||||
setIdentityRef(Number(slot), next)
|
||||
return
|
||||
}
|
||||
if (hidden && showPrivacyToggles.value) hideInputPreview.value = true
|
||||
readFile(next)
|
||||
pickerOpen.value = false
|
||||
}
|
||||
|
||||
async function persistDroppedStill(next: File) {
|
||||
@@ -1364,6 +1626,7 @@ async function loadDraft(draft: RetryDraft) {
|
||||
withSound.value = draft.sound !== false
|
||||
if (draft.hideInput && showPrivacyToggles.value) hideInputPreview.value = true
|
||||
duration.value = clampDuration(Number(draft.duration))
|
||||
if (draft.workflow === 'v2') videoWorkflow.value = 'v2'
|
||||
extensionQueue.value = (draft.extensions || []).map(item => ({
|
||||
id: crypto.randomUUID(),
|
||||
prompt: String(item.prompt || ''),
|
||||
@@ -1643,6 +1906,73 @@ function removeQueuedExtension(id: string) {
|
||||
extensionQueue.value = extensionQueue.value.filter(item => item.id !== id)
|
||||
}
|
||||
|
||||
async function editImage() {
|
||||
if (!file.value || !prompt.value.trim()) return
|
||||
if (!folderId.value) {
|
||||
toast('Choose a library folder before editing.')
|
||||
return
|
||||
}
|
||||
statusBusy.value = false
|
||||
busy.value = true
|
||||
listenGen += 1
|
||||
stopListen()
|
||||
settledUi = false
|
||||
progress.value = 2
|
||||
videoUrl.value = ''
|
||||
editResultUrl.value = ''
|
||||
currentClipId.value = ''
|
||||
closeExtend()
|
||||
awaitingReveal.value = false
|
||||
concealOutput.value = hideInputPreview.value
|
||||
lockedSave.value = false
|
||||
watchingLibrary.value = false
|
||||
statusMessage.value = 'Checking sidecar ComfyUI...'
|
||||
maxStep.value = editSteps.value
|
||||
startTimer()
|
||||
try {
|
||||
const body = new FormData()
|
||||
body.append('image', file.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('denoise', String(editDenoise.value))
|
||||
body.append('seed', seedInput.value || 'random')
|
||||
body.append('folderId', folderId.value)
|
||||
body.append('hideInput', String(showPrivacyToggles.value && hideInputPreview.value))
|
||||
body.append('name', clipName.value.trim())
|
||||
outputDownloadName.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
|
||||
})
|
||||
jobId.value = started.jobId
|
||||
maxStep.value = started.steps
|
||||
persistActiveJob(started.jobId, started.hideThumbnail === true || hideInputPreview.value, started.folderLocked === true)
|
||||
listen(started.jobId, started.hideThumbnail === true || hideInputPreview.value, started.folderLocked === true)
|
||||
} catch (error: any) {
|
||||
settledUi = true
|
||||
const message = error?.data?.statusMessage || error?.statusMessage || error?.message || 'Image edit failed'
|
||||
statusMessage.value = message
|
||||
toast(message)
|
||||
busy.value = false
|
||||
statusBusy.value = false
|
||||
stopTimer()
|
||||
void pollComfyHealth()
|
||||
}
|
||||
}
|
||||
|
||||
async function useEditAsInput() {
|
||||
if (!editResultUrl.value) return
|
||||
try {
|
||||
const blob = await $fetch<Blob>(editResultUrl.value, { responseType: 'blob' })
|
||||
readFile(new File([blob], `${outputDownloadName.value || 'edited'}.png`, { type: blob.type || 'image/png' }))
|
||||
toast('Edited still is now the input')
|
||||
} catch {
|
||||
toast('Could not load that still as input')
|
||||
}
|
||||
}
|
||||
|
||||
async function generate() {
|
||||
if (!file.value || !prompt.value.trim()) return
|
||||
if (!queueReady.value) {
|
||||
@@ -1653,6 +1983,11 @@ async function generate() {
|
||||
toast('Choose a library folder before generating.')
|
||||
return
|
||||
}
|
||||
const identityFiles = identityRefs.value.filter((item): item is File => Boolean(item))
|
||||
if (videoWorkflow.value === 'v2' && useIdentityRefs.value && !identityFiles.length) {
|
||||
toast('Add at least one identity reference still, or turn references off.')
|
||||
return
|
||||
}
|
||||
const shots = shotScriptMode.value ? parsedShots.value : null
|
||||
const initialPrompt = (shots?.[0]?.prompt || prompt.value).trim()
|
||||
const queued = shots
|
||||
@@ -1665,6 +2000,7 @@ async function generate() {
|
||||
settledUi = false
|
||||
progress.value = 1
|
||||
videoUrl.value = ''
|
||||
editResultUrl.value = ''
|
||||
currentClipId.value = ''
|
||||
closeExtend()
|
||||
awaitingReveal.value = false
|
||||
@@ -1700,6 +2036,13 @@ async function generate() {
|
||||
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) {
|
||||
for (const ref of identityFiles) {
|
||||
body.append('refImage', ref)
|
||||
}
|
||||
}
|
||||
if (activeDraftId) body.append('draftId', activeDraftId)
|
||||
if (queued.length) {
|
||||
body.append('extensions', JSON.stringify(queued))
|
||||
@@ -1780,12 +2123,34 @@ function applyJobEvent(payload: Record<string, any>, hidden: boolean, folderLock
|
||||
return
|
||||
}
|
||||
|
||||
if (complete && (payload.clipId || payload.filename)) {
|
||||
if (complete && (payload.clipId || payload.filename || payload.stillId)) {
|
||||
settledUi = true
|
||||
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) {
|
||||
if (locked) {
|
||||
editResultUrl.value = ''
|
||||
lockedSave.value = true
|
||||
browseFolderId.value = folderId.value
|
||||
statusMessage.value = 'Saved to the locked folder. Unlock it to view.'
|
||||
} else {
|
||||
editResultUrl.value = `/api/library/stills/${payload.stillId}`
|
||||
awaitingReveal.value = Boolean(payload.hideThumbnail || hidden)
|
||||
concealOutput.value = awaitingReveal.value
|
||||
lockedSave.value = false
|
||||
statusMessage.value = 'Edit finished'
|
||||
}
|
||||
busy.value = false
|
||||
statusBusy.value = false
|
||||
stopTimer()
|
||||
stopListen()
|
||||
clearActiveJob()
|
||||
loadLibrary().catch(() => null)
|
||||
void pollComfyHealth()
|
||||
return
|
||||
}
|
||||
const keepLibraryView = watchingLibrary.value && Boolean(currentClipId.value) && payload.clipId !== currentClipId.value
|
||||
if (keepLibraryView) {
|
||||
busy.value = false
|
||||
|
||||
Reference in New Issue
Block a user