Add generation quality controls and bind them to the MiniMax workflow.
Expose aspect, CFG, FPS, sampler, and scheduler in Generation settings, persist them on clips and held jobs, and inject those values into the Comfy graph. Keep the image picker stills-only and allow renaming library clips. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -65,8 +65,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-if="current && (!current.protected || current.unlocked)">
|
<template v-if="current && (!current.protected || current.unlocked)">
|
||||||
<p v-if="!visibleStills.length && !visibleClipThumbs.length" class="mt-6 text-sm text-zinc-500">No uploaded stills in this folder yet.</p>
|
<p v-if="!visibleStills.length" class="mt-6 text-sm text-zinc-500">No uploaded stills in this folder yet.</p>
|
||||||
<div v-if="visibleStills.length || visibleClipThumbs.length" class="mt-5 grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
|
<div v-if="visibleStills.length" class="mt-5 grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
|
||||||
<article
|
<article
|
||||||
v-for="still in visibleStills"
|
v-for="still in visibleStills"
|
||||||
:key="still.id"
|
:key="still.id"
|
||||||
@@ -116,63 +116,6 @@
|
|||||||
<button type="button" class="text-[10px] text-red-300 hover:text-red-200" @click="removeStill(still)">Delete</button>
|
<button type="button" class="text-[10px] text-red-300 hover:text-red-200" @click="removeStill(still)">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
<article
|
|
||||||
v-for="clip in visibleClipThumbs"
|
|
||||||
:key="'clip-' + clip.id"
|
|
||||||
class="overflow-hidden rounded-2xl border border-white/10 bg-zinc-950"
|
|
||||||
>
|
|
||||||
<div class="relative h-28 overflow-hidden bg-zinc-900">
|
|
||||||
<img
|
|
||||||
v-if="clip.hasThumbnail || clip.hideThumbnail"
|
|
||||||
:src="`/api/library/clips/${clip.id}/thumb`"
|
|
||||||
alt=""
|
|
||||||
class="h-full w-full object-cover"
|
|
||||||
:class="clip.hideThumbnail && !isRevealed('clip-' + clip.id) ? 'scale-110 blur-xl' : ''"
|
|
||||||
>
|
|
||||||
<video
|
|
||||||
v-else
|
|
||||||
:src="`/api/library/clips/${clip.id}/video#t=0.1`"
|
|
||||||
class="h-full w-full object-cover"
|
|
||||||
muted
|
|
||||||
playsinline
|
|
||||||
preload="metadata"
|
|
||||||
/>
|
|
||||||
<span v-if="clip.hideThumbnail && !isRevealed('clip-' + clip.id)" class="sensitive-glow pointer-events-none absolute inset-0 opacity-80" />
|
|
||||||
<div class="absolute inset-x-0 top-0 flex items-start justify-between p-2">
|
|
||||||
<button
|
|
||||||
v-if="clip.hideThumbnail"
|
|
||||||
type="button"
|
|
||||||
class="rounded-full bg-zinc-950/80 p-1.5 text-amber-100 hover:bg-zinc-900"
|
|
||||||
:title="isRevealed('clip-' + clip.id) ? 'Hide thumbnail' : 'Reveal thumbnail'"
|
|
||||||
@click="toggleReveal('clip-' + clip.id)"
|
|
||||||
>
|
|
||||||
<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">
|
|
||||||
<template v-if="isRevealed('clip-' + clip.id)">
|
|
||||||
<path stroke-linecap="round" d="M3 3l18 18M10.6 10.6a2 2 0 102.8 2.8M9.9 5.1A9.8 9.8 0 0121 12a9.8 9.8 0 01-1.7 2.9M6.1 6.1A9.8 9.8 0 003 12a9.8 9.8 0 0012.9 6.1" />
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z" />
|
|
||||||
<circle cx="12" cy="12" r="3" />
|
|
||||||
</template>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<span v-else />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-full bg-amber-400 p-1.5 text-zinc-950 hover:bg-amber-300"
|
|
||||||
title="Use as input"
|
|
||||||
@click="pickClip(clip)"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="h-4 w-4">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="px-3 py-2">
|
|
||||||
<span class="truncate text-xs text-zinc-400">{{ clip.name || 'From a clip' }}</span>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -199,19 +142,9 @@ export interface PickerStill {
|
|||||||
hideInput?: boolean
|
hideInput?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PickerClip {
|
|
||||||
id: string
|
|
||||||
folderId: string
|
|
||||||
hideThumbnail: boolean
|
|
||||||
hasThumbnail?: boolean
|
|
||||||
name?: string
|
|
||||||
prompt: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
folders: PickerFolder[]
|
folders: PickerFolder[]
|
||||||
stills: PickerStill[]
|
stills: PickerStill[]
|
||||||
clips: PickerClip[]
|
|
||||||
folderId: string
|
folderId: string
|
||||||
hideInputPreview?: boolean
|
hideInputPreview?: boolean
|
||||||
}>()
|
}>()
|
||||||
@@ -245,10 +178,7 @@ watch(() => props.folderId, (id) => {
|
|||||||
|
|
||||||
const current = computed(() => props.folders.find(folder => folder.id === activeFolderId.value) || props.folders[0])
|
const current = computed(() => props.folders.find(folder => folder.id === activeFolderId.value) || props.folders[0])
|
||||||
const visibleStills = computed(() => props.stills.filter(still => still.folderId === (current.value?.id || activeFolderId.value)))
|
const visibleStills = computed(() => props.stills.filter(still => still.folderId === (current.value?.id || activeFolderId.value)))
|
||||||
const visibleClipThumbs = computed(() => props.clips.filter(clip => clip.folderId === (current.value?.id || activeFolderId.value)))
|
const hasHiddenItems = computed(() => visibleStills.value.some(still => stillHidden(still)))
|
||||||
const hasHiddenItems = computed(() => {
|
|
||||||
return visibleStills.value.some(still => stillHidden(still)) || visibleClipThumbs.value.some(clip => clip.hideThumbnail)
|
|
||||||
})
|
|
||||||
|
|
||||||
function stillHidden(still: PickerStill) {
|
function stillHidden(still: PickerStill) {
|
||||||
return still.hideInput === true || props.hideInputPreview === true
|
return still.hideInput === true || props.hideInputPreview === true
|
||||||
@@ -272,10 +202,7 @@ function toggleReveal(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function visibleHiddenIds() {
|
function visibleHiddenIds() {
|
||||||
return [
|
return visibleStills.value.filter(still => stillHidden(still)).map(still => still.id)
|
||||||
...visibleStills.value.filter(still => stillHidden(still)).map(still => still.id),
|
|
||||||
...visibleClipThumbs.value.filter(clip => clip.hideThumbnail).map(clip => 'clip-' + clip.id)
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleRevealAll() {
|
function toggleRevealAll() {
|
||||||
@@ -321,16 +248,6 @@ function onDrop(event: DragEvent) {
|
|||||||
if (next) void ingest(next)
|
if (next) void ingest(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pickClip(clip: PickerClip) {
|
|
||||||
try {
|
|
||||||
const blob = await $fetch<Blob>(`/api/library/clips/${clip.id}/thumb`, { responseType: 'blob' })
|
|
||||||
const file = new File([blob], `${clip.id}.png`, { type: blob.type || 'image/png' })
|
|
||||||
emit('select', file, clip.hideThumbnail === true)
|
|
||||||
} catch (error: any) {
|
|
||||||
emit('error', error?.data?.statusMessage || 'Could not load that still')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pickStill(still: PickerStill) {
|
async function pickStill(still: PickerStill) {
|
||||||
try {
|
try {
|
||||||
const blob = await $fetch<Blob>(`/api/library/stills/${still.id}`, { responseType: 'blob' })
|
const blob = await $fetch<Blob>(`/api/library/stills/${still.id}`, { responseType: 'blob' })
|
||||||
|
|||||||
+216
-59
@@ -67,24 +67,6 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
|
||||||
<p class="mb-2 text-sm font-medium text-zinc-300">Aspect ratio / quality</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-2xl border px-3 py-3 text-left text-sm transition"
|
|
||||||
:class="aspect === option.id ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 hover:border-white/20'"
|
|
||||||
@click="aspect = option.id"
|
|
||||||
>
|
|
||||||
<span class="block font-semibold">{{ option.label }}</span>
|
|
||||||
<span class="text-xs text-zinc-400">{{ option.id === 'auto' ? autoHint : option.hint }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p class="mt-2 text-xs text-zinc-500">Output {{ outputSize.width }} × {{ outputSize.height }}{{ aspect === 'auto' ? ' · matching the still' : ' · override' }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p class="mb-2 text-sm font-medium text-zinc-300">Save to library</p>
|
<p class="mb-2 text-sm font-medium text-zinc-300">Save to library</p>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
@@ -143,6 +125,23 @@
|
|||||||
<details class="rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3">
|
<details 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>
|
<summary class="cursor-pointer text-sm font-medium">Generation settings</summary>
|
||||||
<div class="mt-4 space-y-4">
|
<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-1 gap-2 sm:grid-cols-3">
|
||||||
|
<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.hint }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-xs text-zinc-500">Output {{ outputSize.width }} × {{ outputSize.height }}</p>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="mb-2 text-xs uppercase tracking-wider text-zinc-500">Steps</p>
|
<p class="mb-2 text-xs uppercase tracking-wider text-zinc-500">Steps</p>
|
||||||
<div class="grid grid-cols-2 gap-2">
|
<div class="grid grid-cols-2 gap-2">
|
||||||
@@ -150,7 +149,42 @@
|
|||||||
<button type="button" class="rounded-xl border px-3 py-2 text-sm" :class="!turbo ? 'border-amber-300 bg-amber-400/10' : 'border-white/10'" @click="turbo = false">20 High-Fidelity · LoRA bypass</button>
|
<button type="button" class="rounded-xl border px-3 py-2 text-sm" :class="!turbo ? 'border-amber-300 bg-amber-400/10' : 'border-white/10'" @click="turbo = false">20 High-Fidelity · LoRA bypass</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="text-sm">
|
||||||
|
<span class="mb-1 block text-zinc-400">CFG / Guidance scale</span>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
class="aigen-slider min-w-0 flex-1"
|
||||||
|
type="range"
|
||||||
|
:min="CFG_MIN"
|
||||||
|
:max="CFG_MAX"
|
||||||
|
:step="CFG_STEP"
|
||||||
|
:value="cfg"
|
||||||
|
:style="{ background: `linear-gradient(to right, #fbbf24 ${cfgPct}%, #27272a ${cfgPct}%)` }"
|
||||||
|
aria-label="CFG guidance scale"
|
||||||
|
@input="onCfgSlider"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model.number="cfg"
|
||||||
|
type="number"
|
||||||
|
:min="CFG_MIN"
|
||||||
|
:max="CFG_MAX"
|
||||||
|
:step="CFG_STEP"
|
||||||
|
class="w-20 rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 focus:ring-2"
|
||||||
|
@change="onCfgNumber"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-[11px] text-zinc-500">{{ turbo ? 'Default 1.5 with Turbo LoRA' : 'Default 4.0 with High-Fidelity' }}</p>
|
||||||
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-3">
|
<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>
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
<span class="mb-1 block text-zinc-400">Duration</span>
|
<span class="mb-1 block text-zinc-400">Duration</span>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
@@ -182,10 +216,31 @@
|
|||||||
>
|
>
|
||||||
<p class="mt-1 text-[11px] text-zinc-500">Type any length, use the slider, or drag up/down in the box.</p>
|
<p class="mt-1 text-[11px] text-zinc-500">Type any length, use the slider, or drag up/down in the box.</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<label class="text-sm">
|
<label class="text-sm">
|
||||||
<span class="mb-1 block text-zinc-400">Seed</span>
|
<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">
|
<input v-model="seedInput" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2" placeholder="random">
|
||||||
</label>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
@@ -342,7 +397,17 @@
|
|||||||
</button>
|
</button>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex items-start gap-2">
|
<div class="flex items-start gap-2">
|
||||||
<p class="min-w-0 flex-1 truncate text-sm font-medium">{{ item.name || item.prompt }}</p>
|
<input
|
||||||
|
v-if="renamingId === item.id"
|
||||||
|
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(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 class="group relative shrink-0">
|
<div class="group relative shrink-0">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -362,6 +427,7 @@
|
|||||||
<p class="text-xs text-zinc-500">{{ item.width && item.height ? `${item.width}×${item.height}` : 'size unknown' }} · {{ item.steps || '—' }} steps · seed {{ item.seed || '—' }}</p>
|
<p class="text-xs text-zinc-500">{{ item.width && item.height ? `${item.width}×${item.height}` : 'size unknown' }} · {{ item.steps || '—' }} steps · seed {{ item.seed || '—' }}</p>
|
||||||
<div class="mt-2 flex flex-wrap items-center gap-3">
|
<div class="mt-2 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="openClip(item)">Open</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>
|
<button type="button" class="text-xs text-amber-300" @click="rerun(item)">Rerun</button>
|
||||||
<select
|
<select
|
||||||
v-if="folders.length > 1"
|
v-if="folders.length > 1"
|
||||||
@@ -402,7 +468,6 @@
|
|||||||
v-if="pickerOpen"
|
v-if="pickerOpen"
|
||||||
:folders="folders"
|
:folders="folders"
|
||||||
:stills="stills"
|
:stills="stills"
|
||||||
:clips="clips"
|
|
||||||
:folder-id="folderId"
|
:folder-id="folderId"
|
||||||
:hide-input-preview="hideInputPreview"
|
:hide-input-preview="hideInputPreview"
|
||||||
@close="pickerOpen = false"
|
@close="pickerOpen = false"
|
||||||
@@ -424,7 +489,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
type AspectId = 'auto' | '16:9' | '9:16' | '1:1'
|
type AspectId = '16:9' | '9:16' | '1:1'
|
||||||
|
|
||||||
interface LibraryFolder {
|
interface LibraryFolder {
|
||||||
id: string
|
id: string
|
||||||
@@ -460,6 +525,10 @@ interface LibraryClip {
|
|||||||
hideThumbnail: boolean
|
hideThumbnail: boolean
|
||||||
hasThumbnail?: boolean
|
hasThumbnail?: boolean
|
||||||
createdAt: number
|
createdAt: number
|
||||||
|
cfg?: number
|
||||||
|
fps?: number
|
||||||
|
samplerName?: string
|
||||||
|
scheduler?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RetryDraft {
|
interface RetryDraft {
|
||||||
@@ -480,19 +549,22 @@ interface RetryDraft {
|
|||||||
hideThumbnail: boolean
|
hideThumbnail: boolean
|
||||||
sound: boolean
|
sound: boolean
|
||||||
hideInput?: boolean
|
hideInput?: boolean
|
||||||
|
cfg?: number
|
||||||
|
fps?: number
|
||||||
|
samplerName?: string
|
||||||
|
scheduler?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const PRESETS: Record<Exclude<AspectId, 'auto'>, { width: number; height: number }> = {
|
const PRESETS: Record<AspectId, { width: number; height: number }> = {
|
||||||
'16:9': { width: 1344, height: 768 },
|
'16:9': { width: 1344, height: 768 },
|
||||||
'9:16': { width: 768, height: 1344 },
|
'9:16': { width: 768, height: 1344 },
|
||||||
'1:1': { width: 1024, height: 1024 }
|
'1:1': { width: 960, height: 960 }
|
||||||
}
|
}
|
||||||
|
|
||||||
const aspects = [
|
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: '16:9' as const, label: '16:9 Landscape', hint: '1344 × 768 · 0.98 MP' },
|
{ id: '9:16' as const, label: '9:16 Portrait / Shorts', hint: '768 × 1344' },
|
||||||
{ id: '9:16' as const, label: '9:16 Portrait', hint: '768 × 1344 · 0.98 MP' },
|
{ id: '1:1' as const, label: '1:1 Square', hint: '960 × 960' }
|
||||||
{ id: '1:1' as const, label: '1:1 Square', hint: '1024 × 1024' }
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const player = ref<HTMLVideoElement | null>(null)
|
const player = ref<HTMLVideoElement | null>(null)
|
||||||
@@ -503,10 +575,20 @@ const dragging = ref(false)
|
|||||||
const prompt = ref('')
|
const prompt = ref('')
|
||||||
const clipName = ref('')
|
const clipName = ref('')
|
||||||
const outputDownloadName = ref('aigen')
|
const outputDownloadName = ref('aigen')
|
||||||
const aspect = ref<AspectId>('auto')
|
const aspect = ref<AspectId>('16:9')
|
||||||
const imageWidth = ref(0)
|
const imageWidth = ref(0)
|
||||||
const imageHeight = ref(0)
|
const imageHeight = ref(0)
|
||||||
const turbo = ref(true)
|
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_MIN = 0.5
|
const DURATION_MIN = 0.5
|
||||||
const DURATION_MAX = 120
|
const DURATION_MAX = 120
|
||||||
const DURATION_SLIDER_MIN = 1
|
const DURATION_SLIDER_MIN = 1
|
||||||
@@ -549,6 +631,9 @@ const stills = ref<LibraryStill[]>([])
|
|||||||
const drafts = ref<RetryDraft[]>([])
|
const drafts = ref<RetryDraft[]>([])
|
||||||
const folderId = ref('')
|
const folderId = ref('')
|
||||||
const browseFolderId = ref('')
|
const browseFolderId = ref('')
|
||||||
|
const renamingId = ref('')
|
||||||
|
const renameDraft = ref('')
|
||||||
|
const renameInput = ref<HTMLInputElement | null>(null)
|
||||||
const unlockPassword = ref('')
|
const unlockPassword = ref('')
|
||||||
const settingsOpen = ref(false)
|
const settingsOpen = ref(false)
|
||||||
const pickerOpen = ref(false)
|
const pickerOpen = ref(false)
|
||||||
@@ -589,35 +674,18 @@ const comfyBadge = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function snap32(value: number) {
|
function normalizeAspect(raw?: string, width?: number, height?: number): AspectId {
|
||||||
return Math.max(32, Math.round(value / 32) * 32)
|
if (raw === '16:9' || raw === '9:16' || raw === '1:1') return raw
|
||||||
|
if (width && height) {
|
||||||
|
if (width === height) return '1:1'
|
||||||
|
if (height > width) return '9:16'
|
||||||
|
}
|
||||||
|
return '16:9'
|
||||||
}
|
}
|
||||||
|
|
||||||
function autoResolution(srcWidth: number, srcHeight: number) {
|
const outputSize = computed(() => PRESETS[aspect.value])
|
||||||
const ratio = srcWidth / Math.max(srcHeight, 1)
|
const frameCount = computed(() => Math.max(5, Math.floor(duration.value * fps.value)))
|
||||||
let width: number
|
const cfgPct = computed(() => ((cfg.value - CFG_MIN) / (CFG_MAX - CFG_MIN)) * 100)
|
||||||
let height: number
|
|
||||||
if (ratio >= 1) {
|
|
||||||
height = 768
|
|
||||||
width = Math.min(1344, height * ratio)
|
|
||||||
} else {
|
|
||||||
width = 768
|
|
||||||
height = Math.min(1344, width / ratio)
|
|
||||||
}
|
|
||||||
return { width: snap32(width), height: snap32(height) }
|
|
||||||
}
|
|
||||||
|
|
||||||
const outputSize = computed(() => {
|
|
||||||
if (aspect.value !== 'auto') return PRESETS[aspect.value]
|
|
||||||
if (imageWidth.value && imageHeight.value) return autoResolution(imageWidth.value, imageHeight.value)
|
|
||||||
return { width: 1344, height: 768 }
|
|
||||||
})
|
|
||||||
|
|
||||||
const autoHint = computed(() => {
|
|
||||||
if (!imageWidth.value || !imageHeight.value) return 'Match the still'
|
|
||||||
const size = autoResolution(imageWidth.value, imageHeight.value)
|
|
||||||
return `${size.width} × ${size.height} · from still`
|
|
||||||
})
|
|
||||||
|
|
||||||
const visibleClips = computed(() => clips.value.filter(clip => clip.folderId === browseFolderId.value))
|
const visibleClips = computed(() => clips.value.filter(clip => clip.folderId === browseFolderId.value))
|
||||||
const currentFolder = computed(() => folders.value.find(folder => folder.id === folderId.value))
|
const currentFolder = computed(() => folders.value.find(folder => folder.id === folderId.value))
|
||||||
@@ -710,6 +778,10 @@ watch(duration, (value) => {
|
|||||||
if (!durationScrubbing.value) durationDraft.value = formatDuration(value)
|
if (!durationScrubbing.value) durationDraft.value = formatDuration(value)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(turbo, (on) => {
|
||||||
|
if (!cfgTouched.value) cfg.value = on ? CFG_TURBO : CFG_QUALITY
|
||||||
|
})
|
||||||
|
|
||||||
function clampDuration(value: number) {
|
function clampDuration(value: number) {
|
||||||
if (!Number.isFinite(value)) return 5
|
if (!Number.isFinite(value)) return 5
|
||||||
return Math.min(DURATION_MAX, Math.max(DURATION_MIN, Math.round(value * 10) / 10))
|
return Math.min(DURATION_MAX, Math.max(DURATION_MIN, Math.round(value * 10) / 10))
|
||||||
@@ -730,6 +802,48 @@ function onDurationSlider(event: Event) {
|
|||||||
durationDraft.value = formatDuration(duration.value)
|
durationDraft.value = formatDuration(duration.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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, item.width, item.height)
|
||||||
|
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 onDurationPointerDown(event: PointerEvent) {
|
function onDurationPointerDown(event: PointerEvent) {
|
||||||
if (event.button !== 0) return
|
if (event.button !== 0) return
|
||||||
const input = event.currentTarget as HTMLInputElement
|
const input = event.currentTarget as HTMLInputElement
|
||||||
@@ -903,9 +1017,8 @@ function clipFileName(name: string) {
|
|||||||
function rerun(item: LibraryClip) {
|
function rerun(item: LibraryClip) {
|
||||||
prompt.value = item.prompt
|
prompt.value = item.prompt
|
||||||
clipName.value = item.name || ''
|
clipName.value = item.name || ''
|
||||||
turbo.value = item.turbo
|
|
||||||
seedInput.value = String(item.seed)
|
seedInput.value = String(item.seed)
|
||||||
aspect.value = (item.aspect as AspectId) || 'auto'
|
restoreAdvanced(item)
|
||||||
hideThumbnail.value = item.hideThumbnail
|
hideThumbnail.value = item.hideThumbnail
|
||||||
folderId.value = item.folderId
|
folderId.value = item.folderId
|
||||||
toast('Settings restored. Choose the same still if it is not already loaded.')
|
toast('Settings restored. Choose the same still if it is not already loaded.')
|
||||||
@@ -915,9 +1028,8 @@ async function loadDraft(draft: RetryDraft) {
|
|||||||
activeDraftId = draft.id
|
activeDraftId = draft.id
|
||||||
prompt.value = draft.prompt
|
prompt.value = draft.prompt
|
||||||
clipName.value = draft.name || ''
|
clipName.value = draft.name || ''
|
||||||
turbo.value = draft.turbo
|
|
||||||
seedInput.value = String(draft.seed)
|
seedInput.value = String(draft.seed)
|
||||||
aspect.value = (draft.aspect as AspectId) || 'auto'
|
restoreAdvanced(draft)
|
||||||
hideThumbnail.value = draft.hideThumbnail
|
hideThumbnail.value = draft.hideThumbnail
|
||||||
folderId.value = draft.folderId
|
folderId.value = draft.folderId
|
||||||
withSound.value = draft.sound !== false
|
withSound.value = draft.sound !== false
|
||||||
@@ -1031,6 +1143,47 @@ async function onMoveClip(item: LibraryClip, event: Event) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startRename(item: LibraryClip) {
|
||||||
|
renamingId.value = item.id
|
||||||
|
renameDraft.value = (item.name || '').trim() || clipFileName(item.prompt.split(/[.!?\n]/)[0] || 'aigen')
|
||||||
|
nextTick(() => {
|
||||||
|
renameInput.value?.focus()
|
||||||
|
renameInput.value?.select()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelRename() {
|
||||||
|
renamingId.value = ''
|
||||||
|
renameDraft.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRename(item: LibraryClip) {
|
||||||
|
if (renamingId.value !== item.id) return
|
||||||
|
const name = renameDraft.value.trim().slice(0, 80)
|
||||||
|
if (!name) {
|
||||||
|
toast('A name is required')
|
||||||
|
nextTick(() => renameInput.value?.focus())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (name === item.name) {
|
||||||
|
cancelRename()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
applyLibrary(await $fetch(`/api/library/clips/${item.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { name }
|
||||||
|
}))
|
||||||
|
cancelRename()
|
||||||
|
if (videoUrl.value.includes(item.id)) {
|
||||||
|
outputDownloadName.value = clipFileName(name)
|
||||||
|
}
|
||||||
|
statusMessage.value = `Renamed to ${name}`
|
||||||
|
} catch (error: any) {
|
||||||
|
toast(error?.data?.statusMessage || 'Could not rename clip')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function onPlayed() {
|
function onPlayed() {
|
||||||
awaitingReveal.value = false
|
awaitingReveal.value = false
|
||||||
}
|
}
|
||||||
@@ -1071,6 +1224,10 @@ async function generate() {
|
|||||||
body.append('turbo', String(turbo.value))
|
body.append('turbo', String(turbo.value))
|
||||||
body.append('steps', String(turbo.value ? 8 : 20))
|
body.append('steps', String(turbo.value ? 8 : 20))
|
||||||
body.append('duration', String(duration.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('seed', seedInput.value || 'random')
|
||||||
body.append('folderId', folderId.value)
|
body.append('folderId', folderId.value)
|
||||||
body.append('hideThumbnail', String(hideThumbnail.value))
|
body.append('hideThumbnail', String(hideThumbnail.value))
|
||||||
|
|||||||
@@ -11,9 +11,32 @@ function parseDuration(raw: string) {
|
|||||||
return Math.min(120, Math.max(0.5, seconds))
|
return Math.min(120, Math.max(0.5, seconds))
|
||||||
}
|
}
|
||||||
|
|
||||||
function frameLength(seconds: number) {
|
const SAMPLERS = new Set(['res_multistep', 'euler', 'dpmpp_2m'])
|
||||||
const raw = Math.max(5, Math.round(seconds * 24))
|
const SCHEDULERS = new Set(['simple', 'ddim_uniform', 'sgm_uniform'])
|
||||||
return raw + (5 - (raw % 17) + 17) % 17
|
|
||||||
|
function frameLength(seconds: number, fps: number) {
|
||||||
|
return Math.max(5, Math.floor(seconds * fps))
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFps(raw: string | undefined) {
|
||||||
|
const fps = Number(raw)
|
||||||
|
return fps === 12 || fps === 30 || fps === 24 ? fps : 24
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCfg(raw: string | undefined, turbo: boolean) {
|
||||||
|
const fallback = turbo ? 1.5 : 4
|
||||||
|
const value = Number(raw)
|
||||||
|
if (!Number.isFinite(value)) return fallback
|
||||||
|
const snapped = Math.round(value * 2) / 2
|
||||||
|
return Math.min(10, Math.max(1, snapped))
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSampler(raw: string | undefined) {
|
||||||
|
return SAMPLERS.has(raw || '') ? raw! : 'res_multistep'
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseScheduler(raw: string | undefined) {
|
||||||
|
return SCHEDULERS.has(raw || '') ? raw! : 'simple'
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
@@ -47,7 +70,11 @@ export default defineEventHandler(async (event) => {
|
|||||||
? Number(fields.seed)
|
? Number(fields.seed)
|
||||||
: Math.floor(Math.random() * 2_147_483_647)
|
: Math.floor(Math.random() * 2_147_483_647)
|
||||||
const durationSeconds = parseDuration(fields.duration || '5')
|
const durationSeconds = parseDuration(fields.duration || '5')
|
||||||
const length = frameLength(durationSeconds)
|
const fps = parseFps(fields.fps)
|
||||||
|
const cfg = parseCfg(fields.cfg, turbo)
|
||||||
|
const samplerName = parseSampler(fields.sampler_name)
|
||||||
|
const scheduler = parseScheduler(fields.scheduler)
|
||||||
|
const length = frameLength(durationSeconds, fps)
|
||||||
const hideThumbnail = fields.hideThumbnail === 'true'
|
const hideThumbnail = fields.hideThumbnail === 'true'
|
||||||
const hideInput = fields.hideInput === 'true'
|
const hideInput = fields.hideInput === 'true'
|
||||||
const sound = fields.sound !== 'false'
|
const sound = fields.sound !== 'false'
|
||||||
@@ -84,12 +111,16 @@ export default defineEventHandler(async (event) => {
|
|||||||
folderLocked,
|
folderLocked,
|
||||||
name: clipName,
|
name: clipName,
|
||||||
prompt,
|
prompt,
|
||||||
aspect: fields.aspect || 'auto',
|
aspect: fields.aspect || '16:9',
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
steps,
|
steps,
|
||||||
turbo,
|
turbo,
|
||||||
seed,
|
seed,
|
||||||
|
cfg,
|
||||||
|
fps,
|
||||||
|
samplerName,
|
||||||
|
scheduler,
|
||||||
thumb: image.data,
|
thumb: image.data,
|
||||||
stillId: still.id,
|
stillId: still.id,
|
||||||
stillFilename: still.filename,
|
stillFilename: still.filename,
|
||||||
@@ -99,7 +130,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
emitJob(job, { type: 'status', message: 'Checking ComfyUI...', progress: 1 })
|
emitJob(job, { type: 'status', message: 'Checking ComfyUI...', progress: 1 })
|
||||||
|
|
||||||
void runGeneration(job, { prompt, image, width, height, steps, seed, turbo, length, sound }).catch(async (error) => {
|
void runGeneration(job, { prompt, image, width, height, steps, seed, turbo, length, sound, cfg, fps, samplerName, scheduler }).catch(async (error) => {
|
||||||
if (isComfyBusyTimeout(error) && job.library) {
|
if (isComfyBusyTimeout(error) && job.library) {
|
||||||
try {
|
try {
|
||||||
const draft = await saveRetryDraft({
|
const draft = await saveRetryDraft({
|
||||||
@@ -119,7 +150,11 @@ export default defineEventHandler(async (event) => {
|
|||||||
duration: job.library.duration || durationSeconds,
|
duration: job.library.duration || durationSeconds,
|
||||||
hideThumbnail: job.library.hideThumbnail,
|
hideThumbnail: job.library.hideThumbnail,
|
||||||
hideInput: job.library.hideInput === true,
|
hideInput: job.library.hideInput === true,
|
||||||
sound: job.library.sound !== false
|
sound: job.library.sound !== false,
|
||||||
|
cfg: job.library.cfg,
|
||||||
|
fps: job.library.fps,
|
||||||
|
samplerName: job.library.samplerName,
|
||||||
|
scheduler: job.library.scheduler
|
||||||
})
|
})
|
||||||
job.library.draftId = draft.id
|
job.library.draftId = draft.id
|
||||||
job.status = 'deferred'
|
job.status = 'deferred'
|
||||||
@@ -142,7 +177,11 @@ export default defineEventHandler(async (event) => {
|
|||||||
seed,
|
seed,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
aspect: fields.aspect || 'auto',
|
aspect: fields.aspect || '16:9',
|
||||||
|
cfg,
|
||||||
|
fps,
|
||||||
|
sampler_name: samplerName,
|
||||||
|
scheduler,
|
||||||
steps,
|
steps,
|
||||||
turbo,
|
turbo,
|
||||||
folderId,
|
folderId,
|
||||||
@@ -163,6 +202,10 @@ async function runGeneration(
|
|||||||
turbo: boolean
|
turbo: boolean
|
||||||
length: number
|
length: number
|
||||||
sound: boolean
|
sound: boolean
|
||||||
|
cfg: number
|
||||||
|
fps: number
|
||||||
|
samplerName: string
|
||||||
|
scheduler: string
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
await ensureComfyReady((status) => {
|
await ensureComfyReady((status) => {
|
||||||
@@ -196,6 +239,10 @@ async function runGeneration(
|
|||||||
seed: params.seed,
|
seed: params.seed,
|
||||||
turbo: params.turbo,
|
turbo: params.turbo,
|
||||||
length: params.length,
|
length: params.length,
|
||||||
|
cfg: params.cfg,
|
||||||
|
fps: params.fps,
|
||||||
|
samplerName: params.samplerName,
|
||||||
|
scheduler: params.scheduler,
|
||||||
filenamePrefix: comfyFilenamePrefix(),
|
filenamePrefix: comfyFilenamePrefix(),
|
||||||
sound: params.sound
|
sound: params.sound
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const { owner } = assertLibraryOwner(event)
|
const { owner } = assertLibraryOwner(event)
|
||||||
const id = String(getRouterParam(event, 'id') || '')
|
const id = String(getRouterParam(event, 'id') || '')
|
||||||
const body = await readBody<{ folderId?: string }>(event).catch(() => ({} as { folderId?: string }))
|
const body = await readBody<{ folderId?: string; name?: string }>(event).catch(() => ({} as { folderId?: string; name?: string }))
|
||||||
const folderId = String(body?.folderId || '')
|
const folderId = String(body?.folderId || '')
|
||||||
if (!folderId) {
|
const hasName = typeof body?.name === 'string'
|
||||||
throw createError({ statusCode: 400, statusMessage: 'Choose a library folder' })
|
if (!folderId && !hasName) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'Choose a library folder or a new name' })
|
||||||
}
|
}
|
||||||
const clip = getClip(owner, id)
|
const clip = getClip(owner, id)
|
||||||
assertFolderAccess(event, clip.folderId)
|
assertFolderAccess(event, clip.folderId)
|
||||||
|
if (folderId) {
|
||||||
assertFolderExists(event, folderId)
|
assertFolderExists(event, folderId)
|
||||||
await moveClip(owner, id, folderId)
|
await moveClip(owner, id, folderId)
|
||||||
|
}
|
||||||
|
if (hasName) await renameClip(owner, id, body.name || '')
|
||||||
return publicLibrary(event)
|
return publicLibrary(event)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ export interface Job {
|
|||||||
steps: number
|
steps: number
|
||||||
turbo: boolean
|
turbo: boolean
|
||||||
seed: number
|
seed: number
|
||||||
|
cfg?: number
|
||||||
|
fps?: number
|
||||||
|
samplerName?: string
|
||||||
|
scheduler?: string
|
||||||
thumb?: Buffer
|
thumb?: Buffer
|
||||||
imageName?: string
|
imageName?: string
|
||||||
imageSubfolder?: string
|
imageSubfolder?: string
|
||||||
|
|||||||
+37
-2
@@ -30,6 +30,10 @@ export interface LibraryClip {
|
|||||||
hideThumbnail: boolean
|
hideThumbnail: boolean
|
||||||
createdAt: number
|
createdAt: number
|
||||||
comfyFilename?: string
|
comfyFilename?: string
|
||||||
|
cfg?: number
|
||||||
|
fps?: number
|
||||||
|
samplerName?: string
|
||||||
|
scheduler?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LibraryStill {
|
export interface LibraryStill {
|
||||||
@@ -71,6 +75,10 @@ export interface RetryDraft {
|
|||||||
sound: boolean
|
sound: boolean
|
||||||
hideInput?: boolean
|
hideInput?: boolean
|
||||||
stillFilename?: string
|
stillFilename?: string
|
||||||
|
cfg?: number
|
||||||
|
fps?: number
|
||||||
|
samplerName?: string
|
||||||
|
scheduler?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Catalog {
|
interface Catalog {
|
||||||
@@ -662,6 +670,10 @@ export async function saveRetryDraft(params: {
|
|||||||
hideThumbnail: boolean
|
hideThumbnail: boolean
|
||||||
sound: boolean
|
sound: boolean
|
||||||
hideInput?: boolean
|
hideInput?: boolean
|
||||||
|
cfg?: number
|
||||||
|
fps?: number
|
||||||
|
samplerName?: string
|
||||||
|
scheduler?: string
|
||||||
}) {
|
}) {
|
||||||
return mutate(params.ownerKey, (catalog) => {
|
return mutate(params.ownerKey, (catalog) => {
|
||||||
const existing = params.id ? catalog.drafts.find(item => item.id === params.id) : undefined
|
const existing = params.id ? catalog.drafts.find(item => item.id === params.id) : undefined
|
||||||
@@ -682,7 +694,11 @@ export async function saveRetryDraft(params: {
|
|||||||
duration: params.duration,
|
duration: params.duration,
|
||||||
hideThumbnail: params.hideThumbnail,
|
hideThumbnail: params.hideThumbnail,
|
||||||
sound: params.sound,
|
sound: params.sound,
|
||||||
hideInput: params.hideInput === true
|
hideInput: params.hideInput === true,
|
||||||
|
cfg: params.cfg,
|
||||||
|
fps: params.fps,
|
||||||
|
samplerName: params.samplerName,
|
||||||
|
scheduler: params.scheduler
|
||||||
}
|
}
|
||||||
catalog.drafts = [draft, ...catalog.drafts.filter(item => item.id !== draft.id)].slice(0, 20)
|
catalog.drafts = [draft, ...catalog.drafts.filter(item => item.id !== draft.id)].slice(0, 20)
|
||||||
return draft
|
return draft
|
||||||
@@ -743,6 +759,10 @@ export async function saveClip(params: {
|
|||||||
video: Buffer
|
video: Buffer
|
||||||
thumb?: Buffer | null
|
thumb?: Buffer | null
|
||||||
comfyFilename?: string
|
comfyFilename?: string
|
||||||
|
cfg?: number
|
||||||
|
fps?: number
|
||||||
|
samplerName?: string
|
||||||
|
scheduler?: string
|
||||||
}) {
|
}) {
|
||||||
const catalog = readCatalog(params.ownerKey)
|
const catalog = readCatalog(params.ownerKey)
|
||||||
const folder = catalog.folders.find(item => item.id === params.folderId) || catalog.folders[0]
|
const folder = catalog.folders.find(item => item.id === params.folderId) || catalog.folders[0]
|
||||||
@@ -760,7 +780,11 @@ export async function saveClip(params: {
|
|||||||
seed: params.seed,
|
seed: params.seed,
|
||||||
hideThumbnail: params.hideThumbnail,
|
hideThumbnail: params.hideThumbnail,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
comfyFilename: params.comfyFilename
|
comfyFilename: params.comfyFilename,
|
||||||
|
cfg: params.cfg,
|
||||||
|
fps: params.fps,
|
||||||
|
samplerName: params.samplerName,
|
||||||
|
scheduler: params.scheduler
|
||||||
}
|
}
|
||||||
mkdirSync(clipDir(params.ownerKey, clip.id), { recursive: true })
|
mkdirSync(clipDir(params.ownerKey, clip.id), { recursive: true })
|
||||||
writeFileSync(clipVideoPath(params.ownerKey, clip.id), params.video)
|
writeFileSync(clipVideoPath(params.ownerKey, clip.id), params.video)
|
||||||
@@ -813,6 +837,17 @@ export function moveClip(owner: string, id: string, folderId: string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function renameClip(owner: string, id: string, name: string) {
|
||||||
|
const trimmed = name.trim().slice(0, 80)
|
||||||
|
if (!trimmed) throw createError({ statusCode: 400, statusMessage: 'A name is required' })
|
||||||
|
return mutate(owner, (catalog) => {
|
||||||
|
const clip = catalog.clips.find(item => item.id === id)
|
||||||
|
if (!clip) throw createError({ statusCode: 404, statusMessage: 'Clip not found' })
|
||||||
|
clip.name = trimmed
|
||||||
|
return clip
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export async function downloadComfyVideo(video: { filename: string; subfolder: string; type: string }) {
|
export async function downloadComfyVideo(video: { filename: string; subfolder: string; type: string }) {
|
||||||
const subfolders = [...new Set([video.subfolder, 'video', ''])]
|
const subfolders = [...new Set([video.subfolder, 'video', ''])]
|
||||||
let lastStatus = 0
|
let lastStatus = 0
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const LONG_EDGE = 1344
|
|||||||
export const ASPECT_PRESETS: Record<string, { width: number; height: number }> = {
|
export const ASPECT_PRESETS: Record<string, { width: number; height: number }> = {
|
||||||
'16:9': { width: 1344, height: 768 },
|
'16:9': { width: 1344, height: 768 },
|
||||||
'9:16': { width: 768, height: 1344 },
|
'9:16': { width: 768, height: 1344 },
|
||||||
'1:1': { width: 1024, height: 1024 }
|
'1:1': { width: 960, height: 960 }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function snap32(value: number) {
|
export function snap32(value: number) {
|
||||||
@@ -85,16 +85,12 @@ function jpegSize(buf: Buffer) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveOutputSize(aspect: string | undefined, image: Buffer, fallbackWidth?: number, fallbackHeight?: number) {
|
export function resolveOutputSize(aspect: string | undefined, _image: Buffer, fallbackWidth?: number, fallbackHeight?: number) {
|
||||||
if (aspect && ASPECT_PRESETS[aspect]) {
|
if (aspect && ASPECT_PRESETS[aspect]) {
|
||||||
return ASPECT_PRESETS[aspect]
|
return ASPECT_PRESETS[aspect]
|
||||||
}
|
}
|
||||||
const fromFields = Number(fallbackWidth) && Number(fallbackHeight)
|
if (Number(fallbackWidth) && Number(fallbackHeight)) {
|
||||||
? { width: snap32(Number(fallbackWidth)), height: snap32(Number(fallbackHeight)) }
|
return { width: snap32(Number(fallbackWidth)), height: snap32(Number(fallbackHeight)) }
|
||||||
: null
|
|
||||||
const size = imageDimensions(image)
|
|
||||||
if (size?.width && size?.height) {
|
|
||||||
return autoResolution(size.width, size.height)
|
|
||||||
}
|
}
|
||||||
return fromFields || ASPECT_PRESETS['16:9']
|
return ASPECT_PRESETS['16:9']
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,7 +72,11 @@ export function watchComfyJob(job: Job): Promise<void> {
|
|||||||
hideInput: job.library.hideInput,
|
hideInput: job.library.hideInput,
|
||||||
video: buffer,
|
video: buffer,
|
||||||
thumb: job.library.thumb,
|
thumb: job.library.thumb,
|
||||||
comfyFilename: video.filename
|
comfyFilename: video.filename,
|
||||||
|
cfg: job.library.cfg,
|
||||||
|
fps: job.library.fps,
|
||||||
|
samplerName: job.library.samplerName,
|
||||||
|
scheduler: job.library.scheduler
|
||||||
})
|
})
|
||||||
job.clipId = clip.id
|
job.clipId = clip.id
|
||||||
job.hideThumbnail = clip.hideThumbnail
|
job.hideThumbnail = clip.hideThumbnail
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ export interface GenerateParams {
|
|||||||
seed: number
|
seed: number
|
||||||
turbo: boolean
|
turbo: boolean
|
||||||
length: number
|
length: number
|
||||||
|
cfg: number
|
||||||
|
fps: number
|
||||||
|
samplerName: string
|
||||||
|
scheduler: string
|
||||||
filenamePrefix?: string
|
filenamePrefix?: string
|
||||||
sound?: boolean
|
sound?: boolean
|
||||||
}
|
}
|
||||||
@@ -65,6 +69,25 @@ export function buildWorkflow(params: GenerateParams) {
|
|||||||
node.inputs.length = params.length
|
node.inputs.length = params.length
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (node.class_type === 'CreateVideo' && 'fps' in node.inputs) {
|
||||||
|
node.inputs.fps = params.fps
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.class_type === 'KSamplerSelect' && 'sampler_name' in node.inputs) {
|
||||||
|
node.inputs.sampler_name = params.samplerName
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.class_type === 'BasicScheduler' && 'scheduler' in node.inputs) {
|
||||||
|
node.inputs.scheduler = params.scheduler
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('cfg' in node.inputs && typeof node.inputs.cfg === 'number') {
|
||||||
|
node.inputs.cfg = params.cfg
|
||||||
|
}
|
||||||
|
if ('guidance' in node.inputs && typeof node.inputs.guidance === 'number') {
|
||||||
|
node.inputs.guidance = params.cfg
|
||||||
|
}
|
||||||
|
|
||||||
if (SEED_CLASSES.has(node.class_type)) {
|
if (SEED_CLASSES.has(node.class_type)) {
|
||||||
if ('noise_seed' in node.inputs) node.inputs.noise_seed = params.seed
|
if ('noise_seed' in node.inputs) node.inputs.noise_seed = params.seed
|
||||||
if ('seed' in node.inputs) node.inputs.seed = params.seed
|
if ('seed' in node.inputs) node.inputs.seed = params.seed
|
||||||
@@ -91,6 +114,32 @@ export function buildWorkflow(params: GenerateParams) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (graph['128']) {
|
||||||
|
graph['128'] = {
|
||||||
|
class_type: 'ImageScale',
|
||||||
|
inputs: {
|
||||||
|
upscale_method: 'nearest-exact',
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
crop: 'center',
|
||||||
|
image: ['114', 0]
|
||||||
|
},
|
||||||
|
_meta: { title: 'Scaling image' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
graph['105:cfg'] = {
|
||||||
|
class_type: 'FluxGuidance',
|
||||||
|
inputs: {
|
||||||
|
guidance: params.cfg,
|
||||||
|
conditioning: ['105:104', 0]
|
||||||
|
},
|
||||||
|
_meta: { title: 'Guidance scale' }
|
||||||
|
}
|
||||||
|
if (graph['105:16']?.inputs) {
|
||||||
|
graph['105:16'].inputs.conditioning = ['105:cfg', 0]
|
||||||
|
}
|
||||||
|
|
||||||
for (const [, node] of entriesOf(graph, 'ComfySwitchNode')) {
|
for (const [, node] of entriesOf(graph, 'ComfySwitchNode')) {
|
||||||
if (!/step/i.test(titleOf(node))) continue
|
if (!/step/i.test(titleOf(node))) continue
|
||||||
const qualityId = linkId(node.inputs.on_false)
|
const qualityId = linkId(node.inputs.on_false)
|
||||||
@@ -127,6 +176,7 @@ const LABEL_OVERRIDES: Record<string, string> = {
|
|||||||
'105:10': 'Decoding video frames',
|
'105:10': 'Decoding video frames',
|
||||||
'105:14': 'Sampling',
|
'105:14': 'Sampling',
|
||||||
'105:16': 'Preparing guider',
|
'105:16': 'Preparing guider',
|
||||||
|
'105:cfg': 'Applying guidance',
|
||||||
'105:17': 'Selecting sampler',
|
'105:17': 'Selecting sampler',
|
||||||
'105:23': 'Decoding audio',
|
'105:23': 'Decoding audio',
|
||||||
'105:24': 'Loading audio VAE',
|
'105:24': 'Loading audio VAE',
|
||||||
|
|||||||
Reference in New Issue
Block a user