Add Nuxt MiniMax H3 relay studio for local ComfyUI.
Headless image-to-video UI queues jobs on the desktop ComfyUI instance with live SSE progress and Authentik SSO.
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
.nuxt
|
||||||
|
.output
|
||||||
|
.data
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
agent-transcripts
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
PORT=3000
|
||||||
|
HOST=0.0.0.0
|
||||||
|
COMFY_HOST=http://192.168.77.7:8188
|
||||||
|
|
||||||
|
NUXT_SESSION_SECRET=change-me
|
||||||
|
NUXT_PUBLIC_AUTH_ENABLED=true
|
||||||
|
NUXT_OIDC_CLIENT_ID=
|
||||||
|
NUXT_OIDC_CLIENT_SECRET=
|
||||||
|
NUXT_OIDC_ISSUER=https://auth.carrgarage.com/application/o/aigen/
|
||||||
|
NUXT_OIDC_AUTHORIZE_URL=https://auth.carrgarage.com/application/o/authorize/
|
||||||
|
NUXT_OIDC_TOKEN_URL=http://192.168.77.2:9000/application/o/token/
|
||||||
|
NUXT_OIDC_USERINFO_URL=http://192.168.77.2:9000/application/o/userinfo/
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
.nuxt
|
||||||
|
.output
|
||||||
|
.data
|
||||||
|
.cache
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
dist
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:22-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV HOST=0.0.0.0
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV NITRO_HOST=0.0.0.0
|
||||||
|
ENV NITRO_PORT=3000
|
||||||
|
COPY --from=build /app/.output ./.output
|
||||||
|
EXPOSE 3000
|
||||||
|
USER node
|
||||||
|
CMD ["node", ".output/server/index.mjs"]
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#__nuxt {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'IBM Plex Sans', ui-sans-serif, system-ui, sans-serif;
|
||||||
|
background:
|
||||||
|
radial-gradient(1200px 600px at 10% -10%, rgba(251, 191, 36, 0.08), transparent 50%),
|
||||||
|
radial-gradient(900px 500px at 100% 0%, rgba(56, 189, 248, 0.06), transparent 45%),
|
||||||
|
#09090b;
|
||||||
|
color: #e4e4e7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-display {
|
||||||
|
font-family: Syne, 'IBM Plex Sans', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass {
|
||||||
|
background: rgba(24, 24, 27, 0.72);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.35);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-glow {
|
||||||
|
box-shadow: 0 0 24px rgba(251, 191, 36, 0.35);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
export default defineNuxtConfig({
|
||||||
|
compatibilityDate: '2025-08-01',
|
||||||
|
modules: ['@nuxtjs/tailwindcss'],
|
||||||
|
css: ['~/assets/css/main.css'],
|
||||||
|
app: {
|
||||||
|
head: {
|
||||||
|
title: 'AIGen — MiniMax H3',
|
||||||
|
htmlAttrs: { lang: 'en', class: 'dark' },
|
||||||
|
meta: [
|
||||||
|
{ charset: 'utf-8' },
|
||||||
|
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
||||||
|
{ name: 'theme-color', content: '#09090b' },
|
||||||
|
{ name: 'description', content: 'Headless MiniMax H3 image-to-video studio relayed to local ComfyUI.' }
|
||||||
|
],
|
||||||
|
link: [
|
||||||
|
{ rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' },
|
||||||
|
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
|
||||||
|
{ rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: '' },
|
||||||
|
{
|
||||||
|
rel: 'stylesheet',
|
||||||
|
href: 'https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=Syne:wght@600;700;800&display=swap'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
runtimeConfig: {
|
||||||
|
comfyHost: process.env.COMFY_HOST || '',
|
||||||
|
sessionSecret: process.env.NUXT_SESSION_SECRET || '',
|
||||||
|
oidcClientId: process.env.NUXT_OIDC_CLIENT_ID || '',
|
||||||
|
oidcClientSecret: process.env.NUXT_OIDC_CLIENT_SECRET || '',
|
||||||
|
oidcIssuer: process.env.NUXT_OIDC_ISSUER || '',
|
||||||
|
oidcAuthorizeUrl: process.env.NUXT_OIDC_AUTHORIZE_URL || '',
|
||||||
|
oidcTokenUrl: process.env.NUXT_OIDC_TOKEN_URL || '',
|
||||||
|
oidcUserinfoUrl: process.env.NUXT_OIDC_USERINFO_URL || '',
|
||||||
|
public: {
|
||||||
|
authEnabled: process.env.NUXT_PUBLIC_AUTH_ENABLED === 'true'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
nitro: {
|
||||||
|
preset: 'node-server'
|
||||||
|
}
|
||||||
|
})
|
||||||
Generated
+11489
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "aigen",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "nuxt build",
|
||||||
|
"dev": "nuxt dev --host 0.0.0.0 --port 3000",
|
||||||
|
"preview": "nuxt preview --host 0.0.0.0 --port 3000",
|
||||||
|
"postinstall": "nuxt prepare"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"nuxt": "^3.17.5",
|
||||||
|
"vue": "^3.5.13",
|
||||||
|
"vue-router": "^4.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
"typescript": "^5.8.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
+404
@@ -0,0 +1,404 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen">
|
||||||
|
<header class="sticky top-0 z-20 border-b border-white/5 bg-zinc-950/70 backdrop-blur-xl">
|
||||||
|
<div class="mx-auto flex max-w-7xl items-center justify-between px-4 py-4 sm:px-6">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-amber-400 text-zinc-950 font-display font-extrabold">A</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="font-display text-xl font-bold leading-none">AIGen</h1>
|
||||||
|
<p class="text-[11px] uppercase tracking-[0.22em] text-zinc-500">MiniMax H3 · ComfyUI relay</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 text-sm">
|
||||||
|
<span
|
||||||
|
class="hidden sm:inline-flex items-center gap-2 rounded-full border px-3 py-1"
|
||||||
|
:class="comfyOk ? 'border-emerald-400/30 text-emerald-300' : 'border-red-400/30 text-red-300'"
|
||||||
|
>
|
||||||
|
<span class="h-2 w-2 rounded-full" :class="comfyOk ? 'bg-emerald-400' : 'bg-red-400'" />
|
||||||
|
{{ comfyOk ? 'ComfyUI online' : 'ComfyUI offline' }}
|
||||||
|
</span>
|
||||||
|
<span class="hidden md:block text-zinc-400">{{ userName }}</span>
|
||||||
|
<a v-if="authEnabled" href="/api/auth/logout" class="text-zinc-400 hover:text-white">Sign out</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="mx-auto grid max-w-7xl gap-6 px-4 py-6 sm:px-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||||
|
<section class="glass rounded-3xl p-5 sm:p-6 space-y-5">
|
||||||
|
<div>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label
|
||||||
|
class="relative block cursor-pointer rounded-2xl border border-dashed border-white/15 bg-zinc-950/50 p-4 transition hover:border-amber-300/50"
|
||||||
|
@dragover.prevent="dragging = true"
|
||||||
|
@dragleave.prevent="dragging = false"
|
||||||
|
@drop.prevent="onDrop"
|
||||||
|
:class="{ 'border-amber-300/70 bg-amber-400/5': dragging }"
|
||||||
|
>
|
||||||
|
<input ref="fileInput" type="file" accept="image/*" class="hidden" @change="onFile">
|
||||||
|
<div v-if="preview" 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.prevent="resetImage">Reset</button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex min-h-44 flex-col items-center justify-center text-center">
|
||||||
|
<p class="font-medium">Drag & drop an image</p>
|
||||||
|
<p class="mt-1 text-sm text-zinc-500">PNG, JPG, or WEBP · used as MiniMax H3 first frame</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium text-zinc-300">Motion & scene prompt</label>
|
||||||
|
<textarea
|
||||||
|
v-model="prompt"
|
||||||
|
rows="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="Camera slowly pushes in. Soft daylight, fabric ripples, dust motes in the air. Audio: quiet room tone, distant birds."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="mb-2 text-sm font-medium text-zinc-300">Aspect ratio / quality</p>
|
||||||
|
<div class="grid grid-cols-3 gap-2">
|
||||||
|
<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.hint }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<div class="mt-4 space-y-4">
|
||||||
|
<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">8 Turbo · LoRA on</button>
|
||||||
|
<button type="button" class="rounded-xl border px-3 py-2 text-sm" :class="!turbo ? 'border-amber-300 bg-amber-400/10' : 'border-white/10'" @click="turbo = false">20 High-Fidelity · LoRA bypass</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<label class="text-sm">
|
||||||
|
<span class="mb-1 block text-zinc-400">Duration</span>
|
||||||
|
<select v-model.number="duration" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2">
|
||||||
|
<option :value="5">5 seconds</option>
|
||||||
|
<option :value="10">10 seconds</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<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>
|
||||||
|
</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()"
|
||||||
|
@click="generate"
|
||||||
|
>
|
||||||
|
{{ busy ? 'Generating…' : 'Generate Video' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="busy"
|
||||||
|
type="button"
|
||||||
|
class="rounded-2xl border border-red-400/40 px-4 py-3 text-red-300"
|
||||||
|
@click="cancelJob"
|
||||||
|
>
|
||||||
|
Cancel / Interrupt
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="space-y-5">
|
||||||
|
<div class="glass rounded-3xl p-5 sm:p-6">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<h2 class="font-display text-2xl font-bold">Output</h2>
|
||||||
|
<p class="text-xs text-zinc-500">{{ elapsedLabel }} · step {{ step }}/{{ maxStep || (turbo ? 8 : 20) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4">
|
||||||
|
<div class="mb-2 flex items-center justify-between text-sm">
|
||||||
|
<span class="text-zinc-300">{{ statusMessage }}</span>
|
||||||
|
<span class="text-amber-200">{{ progress }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-2 overflow-hidden rounded-full bg-zinc-800">
|
||||||
|
<div class="h-full rounded-full bg-amber-400 progress-glow transition-all duration-300" :style="{ width: progress + '%' }" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-5 overflow-hidden rounded-2xl bg-black/50">
|
||||||
|
<video
|
||||||
|
v-if="videoUrl"
|
||||||
|
:src="videoUrl"
|
||||||
|
class="aspect-video w-full bg-black"
|
||||||
|
controls
|
||||||
|
autoplay
|
||||||
|
loop
|
||||||
|
playsinline
|
||||||
|
/>
|
||||||
|
<div v-else class="flex aspect-video items-center justify-center text-sm text-zinc-500">
|
||||||
|
Generated MP4 appears here
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
v-if="videoUrl"
|
||||||
|
:href="videoUrl"
|
||||||
|
download="aigen.mp4"
|
||||||
|
class="mt-4 inline-flex rounded-2xl border border-white/15 px-4 py-2 text-sm hover:border-amber-300/50"
|
||||||
|
>
|
||||||
|
Download MP4
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glass rounded-3xl p-5 sm:p-6">
|
||||||
|
<h2 class="font-display text-xl font-bold">History</h2>
|
||||||
|
<p v-if="!history.length" class="mt-3 text-sm text-zinc-500">Past runs stay in this browser.</p>
|
||||||
|
<ul class="mt-4 space-y-3">
|
||||||
|
<li v-for="item in history" :key="item.id" class="flex gap-3 rounded-2xl border border-white/5 bg-zinc-950/40 p-3">
|
||||||
|
<img :src="item.thumbnail" alt="" class="h-16 w-16 rounded-xl object-cover">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="truncate text-sm">{{ item.prompt }}</p>
|
||||||
|
<p class="text-xs text-zinc-500">{{ item.width }}×{{ item.height }} · {{ item.steps }} steps · seed {{ item.seed }}</p>
|
||||||
|
<div class="mt-2 flex gap-2">
|
||||||
|
<button type="button" class="text-xs text-amber-300" @click="rerun(item)">Rerun</button>
|
||||||
|
<a v-if="item.videoUrl" :href="item.videoUrl" class="text-xs text-sky-300" target="_blank">Open</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div class="pointer-events-none fixed bottom-4 right-4 z-50 space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="toast in toasts"
|
||||||
|
:key="toast.id"
|
||||||
|
class="pointer-events-auto w-80 rounded-2xl border border-red-400/30 bg-zinc-950/90 px-4 py-3 text-sm text-red-100 shadow-2xl"
|
||||||
|
>
|
||||||
|
{{ toast.message }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface HistoryItem {
|
||||||
|
id: string
|
||||||
|
prompt: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
steps: number
|
||||||
|
turbo: boolean
|
||||||
|
seed: number
|
||||||
|
thumbnail: string
|
||||||
|
videoUrl?: string
|
||||||
|
createdAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const aspects = [
|
||||||
|
{ id: '16:9', label: '16:9 Landscape', hint: '1344 × 768 · 0.98 MP' },
|
||||||
|
{ id: '9:16', label: '9:16 Portrait', hint: '768 × 1344 · 0.98 MP' },
|
||||||
|
{ id: '1:1', label: '1:1 Square', hint: '1024 × 1024' }
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const file = ref<File | null>(null)
|
||||||
|
const preview = ref('')
|
||||||
|
const dragging = ref(false)
|
||||||
|
const prompt = ref('')
|
||||||
|
const aspect = ref<'16:9' | '9:16' | '1:1'>('16:9')
|
||||||
|
const turbo = ref(true)
|
||||||
|
const duration = ref(5)
|
||||||
|
const seedInput = ref('random')
|
||||||
|
const busy = ref(false)
|
||||||
|
const progress = ref(0)
|
||||||
|
const statusMessage = ref('Waiting for a still.')
|
||||||
|
const step = ref(0)
|
||||||
|
const maxStep = ref(8)
|
||||||
|
const elapsedMs = ref(0)
|
||||||
|
const videoUrl = ref('')
|
||||||
|
const jobId = ref('')
|
||||||
|
const toasts = ref<{ id: string; message: string }[]>([])
|
||||||
|
const history = ref<HistoryItem[]>([])
|
||||||
|
const comfyOk = ref(false)
|
||||||
|
const userName = ref('')
|
||||||
|
const authEnabled = ref(false)
|
||||||
|
let timer: ReturnType<typeof setInterval> | null = null
|
||||||
|
let source: EventSource | null = null
|
||||||
|
|
||||||
|
const elapsedLabel = computed(() => {
|
||||||
|
const seconds = Math.floor(elapsedMs.value / 1000)
|
||||||
|
const mm = String(Math.floor(seconds / 60)).padStart(2, '0')
|
||||||
|
const ss = String(seconds % 60).padStart(2, '0')
|
||||||
|
return `${mm}:${ss}`
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
history.value = JSON.parse(localStorage.getItem('aigen-history') || '[]')
|
||||||
|
const [health, me] = await Promise.all([
|
||||||
|
$fetch<{ comfy?: { ok?: boolean } }>('/api/health').catch(() => ({ comfy: { ok: false } })),
|
||||||
|
$fetch<{ user?: { name?: string; email?: string }; authEnabled?: boolean }>('/api/auth/me').catch(() => ({ user: null }))
|
||||||
|
])
|
||||||
|
comfyOk.value = Boolean(health.comfy?.ok)
|
||||||
|
userName.value = me.user?.name || me.user?.email || ''
|
||||||
|
authEnabled.value = Boolean(me.authEnabled)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
stopTimer()
|
||||||
|
source?.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
function toast(message: string) {
|
||||||
|
const id = crypto.randomUUID()
|
||||||
|
toasts.value.push({ id, message })
|
||||||
|
setTimeout(() => {
|
||||||
|
toasts.value = toasts.value.filter(item => item.id !== id)
|
||||||
|
}, 7000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetImage() {
|
||||||
|
file.value = null
|
||||||
|
preview.value = ''
|
||||||
|
if (fileInput.value) fileInput.value.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFile(next: File) {
|
||||||
|
file.value = next
|
||||||
|
preview.value = URL.createObjectURL(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFile(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
const next = input.files?.[0]
|
||||||
|
if (next) readFile(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(event: DragEvent) {
|
||||||
|
dragging.value = false
|
||||||
|
const next = event.dataTransfer?.files?.[0]
|
||||||
|
if (next) readFile(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
function startTimer() {
|
||||||
|
elapsedMs.value = 0
|
||||||
|
stopTimer()
|
||||||
|
const started = Date.now()
|
||||||
|
timer = setInterval(() => {
|
||||||
|
elapsedMs.value = Date.now() - started
|
||||||
|
}, 250)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopTimer() {
|
||||||
|
if (timer) clearInterval(timer)
|
||||||
|
timer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistHistory() {
|
||||||
|
localStorage.setItem('aigen-history', JSON.stringify(history.value.slice(0, 12)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function rerun(item: HistoryItem) {
|
||||||
|
prompt.value = item.prompt
|
||||||
|
turbo.value = item.turbo
|
||||||
|
seedInput.value = String(item.seed)
|
||||||
|
if (item.width === 768) aspect.value = '9:16'
|
||||||
|
else if (item.width === 1024) aspect.value = '1:1'
|
||||||
|
else aspect.value = '16:9'
|
||||||
|
toast('Settings restored. Drop the same still if it is not already loaded.')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generate() {
|
||||||
|
if (!file.value || !prompt.value.trim()) return
|
||||||
|
busy.value = true
|
||||||
|
progress.value = 1
|
||||||
|
videoUrl.value = ''
|
||||||
|
statusMessage.value = 'Uploading image...'
|
||||||
|
startTimer()
|
||||||
|
try {
|
||||||
|
const body = new FormData()
|
||||||
|
body.append('image', file.value)
|
||||||
|
body.append('prompt', prompt.value.trim())
|
||||||
|
body.append('aspect', aspect.value)
|
||||||
|
body.append('turbo', String(turbo.value))
|
||||||
|
body.append('steps', String(turbo.value ? 8 : 20))
|
||||||
|
body.append('duration', String(duration.value))
|
||||||
|
body.append('seed', seedInput.value || 'random')
|
||||||
|
const started = await $fetch<{ jobId: string; seed: number; width: number; height: number; steps: number; turbo: boolean }>('/api/generate', {
|
||||||
|
method: 'POST',
|
||||||
|
body
|
||||||
|
})
|
||||||
|
jobId.value = started.jobId
|
||||||
|
maxStep.value = started.steps
|
||||||
|
listen(started.jobId, started)
|
||||||
|
} catch (error: any) {
|
||||||
|
busy.value = false
|
||||||
|
stopTimer()
|
||||||
|
const message = error?.data?.statusMessage || error?.statusMessage || error?.message || 'Failed to start generation'
|
||||||
|
statusMessage.value = message
|
||||||
|
toast(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function listen(id: string, meta: { seed: number; width: number; height: number; steps: number; turbo: boolean }) {
|
||||||
|
source?.close()
|
||||||
|
source = new EventSource(`/api/generate/${id}/stream`)
|
||||||
|
source.onmessage = (event) => {
|
||||||
|
const payload = JSON.parse(event.data)
|
||||||
|
if (payload.message) statusMessage.value = payload.message
|
||||||
|
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 (payload.type === 'complete') {
|
||||||
|
const url = `/api/media?filename=${encodeURIComponent(payload.filename)}&subfolder=${encodeURIComponent(payload.subfolder || 'video')}&type=${encodeURIComponent(payload.mediaType || 'output')}`
|
||||||
|
videoUrl.value = url
|
||||||
|
busy.value = false
|
||||||
|
stopTimer()
|
||||||
|
source?.close()
|
||||||
|
history.value.unshift({
|
||||||
|
id,
|
||||||
|
prompt: prompt.value.trim(),
|
||||||
|
width: meta.width,
|
||||||
|
height: meta.height,
|
||||||
|
steps: meta.steps,
|
||||||
|
turbo: meta.turbo,
|
||||||
|
seed: meta.seed,
|
||||||
|
thumbnail: preview.value,
|
||||||
|
videoUrl: url,
|
||||||
|
createdAt: Date.now()
|
||||||
|
})
|
||||||
|
persistHistory()
|
||||||
|
}
|
||||||
|
if (payload.type === 'error') {
|
||||||
|
busy.value = false
|
||||||
|
stopTimer()
|
||||||
|
source?.close()
|
||||||
|
toast(payload.error || payload.message || 'Generation failed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
source.onerror = () => {
|
||||||
|
if (!busy.value) return
|
||||||
|
toast('Progress stream disconnected. The ComfyUI job may still be running.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelJob() {
|
||||||
|
try {
|
||||||
|
await $fetch('/api/interrupt', { method: 'POST', body: { jobId: jobId.value } })
|
||||||
|
statusMessage.value = 'Interrupt sent to ComfyUI'
|
||||||
|
} catch (error: any) {
|
||||||
|
toast(error?.statusMessage || 'Failed to interrupt ComfyUI')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<template>
|
||||||
|
<main class="min-h-screen flex items-center justify-center px-6">
|
||||||
|
<section class="glass w-full max-w-md rounded-3xl p-10 text-center">
|
||||||
|
<p class="text-amber-300/80 text-xs tracking-[0.3em] uppercase">Carr Garage</p>
|
||||||
|
<h1 class="font-display text-4xl font-extrabold mt-3">AIGen</h1>
|
||||||
|
<p class="mt-3 text-zinc-400">Headless MiniMax H3 image-to-video studio. Sign in with Authentik to reach the ComfyUI relay.</p>
|
||||||
|
<a
|
||||||
|
href="/api/auth/login"
|
||||||
|
class="mt-8 inline-flex w-full items-center justify-center rounded-2xl bg-amber-400 px-5 py-3 font-semibold text-zinc-950 hover:bg-amber-300 transition"
|
||||||
|
>
|
||||||
|
Continue with Authentik
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<rect width="32" height="32" rx="8" fill="#18181b"/>
|
||||||
|
<path d="M8 20l8-12 8 12" fill="none" stroke="#fbbf24" stroke-width="2.4" stroke-linejoin="round"/>
|
||||||
|
<circle cx="16" cy="21" r="3" fill="#38bdf8"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 274 B |
@@ -0,0 +1,48 @@
|
|||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
if (!authEnabled()) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: 'Auth is not enabled' })
|
||||||
|
}
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const query = getQuery(event)
|
||||||
|
const code = String(query.code || '')
|
||||||
|
const state = String(query.state || '')
|
||||||
|
if (!code || !consumeOauthState(event, state)) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'Invalid OAuth callback' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirectUri = `${publicBaseUrl(event)}/api/auth/callback`
|
||||||
|
const tokenUrl = config.oidcTokenUrl || `${config.oidcIssuer}token/`
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
client_id: config.oidcClientId,
|
||||||
|
client_secret: config.oidcClientSecret
|
||||||
|
})
|
||||||
|
|
||||||
|
const tokenRes = await fetch(tokenUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
||||||
|
body
|
||||||
|
})
|
||||||
|
const tokenJson = await tokenRes.json().catch(() => ({})) as { access_token?: string; error?: string }
|
||||||
|
if (!tokenRes.ok || !tokenJson.access_token) {
|
||||||
|
throw createError({ statusCode: 401, statusMessage: 'OIDC token exchange failed' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const userinfoUrl = config.oidcUserinfoUrl || `${config.oidcIssuer}userinfo/`
|
||||||
|
const userRes = await fetch(userinfoUrl, {
|
||||||
|
headers: { Authorization: `Bearer ${tokenJson.access_token}` }
|
||||||
|
})
|
||||||
|
const user = await userRes.json().catch(() => ({})) as { sub?: string; email?: string; name?: string; preferred_username?: string }
|
||||||
|
if (!userRes.ok || !user.sub) {
|
||||||
|
throw createError({ statusCode: 401, statusMessage: 'OIDC userinfo failed' })
|
||||||
|
}
|
||||||
|
|
||||||
|
setSessionUser(event, {
|
||||||
|
sub: user.sub,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name || user.preferred_username || user.email
|
||||||
|
})
|
||||||
|
await sendRedirect(event, '/', 302)
|
||||||
|
})
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
if (!authEnabled()) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: 'Auth is not enabled' })
|
||||||
|
}
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const state = createOauthState(event)
|
||||||
|
const redirectUri = `${publicBaseUrl(event)}/api/auth/callback`
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
response_type: 'code',
|
||||||
|
client_id: config.oidcClientId,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
scope: 'openid email profile',
|
||||||
|
state
|
||||||
|
})
|
||||||
|
const authorize = config.oidcAuthorizeUrl || 'https://auth.carrgarage.com/application/o/authorize/'
|
||||||
|
await sendRedirect(event, `${authorize}?${params.toString()}`, 302)
|
||||||
|
})
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
clearAuthSession(event)
|
||||||
|
await sendRedirect(event, '/login', 302)
|
||||||
|
})
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export default defineEventHandler((event) => {
|
||||||
|
const user = getSessionUser(event)
|
||||||
|
return { user, authEnabled: authEnabled() }
|
||||||
|
})
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
const ASPECTS: Record<string, { width: number; height: number }> = {
|
||||||
|
'16:9': { width: 1344, height: 768 },
|
||||||
|
'9:16': { width: 768, height: 1344 },
|
||||||
|
'1:1': { width: 1024, height: 1024 }
|
||||||
|
}
|
||||||
|
|
||||||
|
function snap32(value: number) {
|
||||||
|
return Math.max(32, Math.round(value / 32) * 32)
|
||||||
|
}
|
||||||
|
|
||||||
|
function frameLength(seconds: number) {
|
||||||
|
const raw = Math.max(5, Math.round(seconds * 24))
|
||||||
|
return raw + (5 - (raw % 17) + 17) % 17
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const form = await readMultipartFormData(event)
|
||||||
|
if (!form?.length) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'Expected multipart form data' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields: Record<string, string> = {}
|
||||||
|
let image: { filename: string; data: Buffer; type?: string } | null = null
|
||||||
|
for (const part of form) {
|
||||||
|
if (part.name === 'image' && part.filename && part.data?.length) {
|
||||||
|
image = { filename: part.filename, data: part.data, type: part.type }
|
||||||
|
} else if (part.name && part.data) {
|
||||||
|
fields[part.name] = part.data.toString('utf8')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!image) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'An initial image is required' })
|
||||||
|
}
|
||||||
|
const prompt = (fields.prompt || '').trim()
|
||||||
|
if (!prompt) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'A motion prompt is required' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const aspect = ASPECTS[fields.aspect] || ASPECTS['16:9']
|
||||||
|
const width = snap32(Number(fields.width || aspect.width))
|
||||||
|
const height = snap32(Number(fields.height || aspect.height))
|
||||||
|
const turbo = fields.turbo !== 'false'
|
||||||
|
const steps = Number(fields.steps || (turbo ? 8 : 20))
|
||||||
|
const seed = fields.seed && fields.seed !== 'random'
|
||||||
|
? Number(fields.seed)
|
||||||
|
: Math.floor(Math.random() * 2_147_483_647)
|
||||||
|
const length = frameLength(Number(fields.duration || 5))
|
||||||
|
|
||||||
|
const job = createJob()
|
||||||
|
job.maxStep = steps
|
||||||
|
emitJob(job, { type: 'status', message: 'Uploading image...', progress: 2 })
|
||||||
|
|
||||||
|
void runGeneration(job, { prompt, image, width, height, steps, seed, turbo, length }).catch((error) => {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
job.status = 'error'
|
||||||
|
job.error = message
|
||||||
|
emitJob(job, { type: 'error', error: message, message })
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
jobId: job.id,
|
||||||
|
clientId: job.clientId,
|
||||||
|
seed,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
steps,
|
||||||
|
turbo
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function runGeneration(
|
||||||
|
job: ReturnType<typeof createJob>,
|
||||||
|
params: {
|
||||||
|
prompt: string
|
||||||
|
image: { filename: string; data: Buffer; type?: string }
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
steps: number
|
||||||
|
seed: number
|
||||||
|
turbo: boolean
|
||||||
|
length: number
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
job.status = 'uploading'
|
||||||
|
const uploaded = await uploadImage(params.image)
|
||||||
|
emitJob(job, { type: 'status', message: 'Queueing MiniMax H3 job...', progress: 6 })
|
||||||
|
|
||||||
|
const graph = buildWorkflow({
|
||||||
|
prompt: params.prompt,
|
||||||
|
imageName: uploaded.name,
|
||||||
|
width: params.width,
|
||||||
|
height: params.height,
|
||||||
|
steps: params.steps,
|
||||||
|
seed: params.seed,
|
||||||
|
turbo: params.turbo,
|
||||||
|
length: params.length
|
||||||
|
})
|
||||||
|
|
||||||
|
const queued = await queuePrompt(graph, job.clientId)
|
||||||
|
job.promptId = queued.prompt_id
|
||||||
|
job.status = 'running'
|
||||||
|
emitJob(job, { type: 'status', message: 'Job queued on ComfyUI', progress: 8 })
|
||||||
|
await watchComfyJob(job)
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const id = getRouterParam(event, 'id')
|
||||||
|
const job = id ? getJob(id) : undefined
|
||||||
|
if (!job) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: 'Job not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = createEventStream(event)
|
||||||
|
const send = async (payload: unknown) => {
|
||||||
|
await stream.push(JSON.stringify(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
await send({
|
||||||
|
type: 'hello',
|
||||||
|
status: job.status,
|
||||||
|
message: job.message,
|
||||||
|
progress: job.progress,
|
||||||
|
step: job.step,
|
||||||
|
maxStep: job.maxStep,
|
||||||
|
promptId: job.promptId,
|
||||||
|
elapsedMs: Date.now() - job.startedAt
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const past of job.events) {
|
||||||
|
await send(past)
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsubscribe = subscribeJob(job, (payload) => {
|
||||||
|
void send(payload).then(() => {
|
||||||
|
if (payload.type === 'complete' || payload.type === 'error') {
|
||||||
|
void stream.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
stream.onClosed(() => {
|
||||||
|
unsubscribe()
|
||||||
|
})
|
||||||
|
|
||||||
|
return stream.send()
|
||||||
|
})
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export default defineEventHandler(async () => {
|
||||||
|
const comfy = await probeComfy().catch(() => ({ ok: false, host: '' }))
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
service: 'aigen',
|
||||||
|
comfy
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const body = await readBody<{ jobId?: string }>(event).catch(() => ({}))
|
||||||
|
const job = body?.jobId ? getJob(body.jobId) : undefined
|
||||||
|
if (job) {
|
||||||
|
job.status = 'cancelled'
|
||||||
|
emitJob(job, { type: 'status', message: 'Cancelling job...' })
|
||||||
|
}
|
||||||
|
const ok = await interruptComfy()
|
||||||
|
if (job) {
|
||||||
|
emitJob(job, { type: 'error', error: 'Job interrupted.', message: 'Job interrupted.' })
|
||||||
|
}
|
||||||
|
return { ok }
|
||||||
|
})
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const query = getQuery(event)
|
||||||
|
const filename = String(query.filename || '')
|
||||||
|
if (!filename) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'filename is required' })
|
||||||
|
}
|
||||||
|
const subfolder = String(query.subfolder || 'video')
|
||||||
|
const type = String(query.type || 'output')
|
||||||
|
const params = new URLSearchParams({ filename, subfolder, type })
|
||||||
|
const res = await comfyFetch(`/view?${params.toString()}`)
|
||||||
|
if (!res.ok) {
|
||||||
|
throw createError({ statusCode: res.status, statusMessage: 'Failed to fetch video from ComfyUI' })
|
||||||
|
}
|
||||||
|
const buf = Buffer.from(await res.arrayBuffer())
|
||||||
|
setHeader(event, 'Content-Type', res.headers.get('content-type') || 'video/mp4')
|
||||||
|
setHeader(event, 'Content-Disposition', `inline; filename="${filename}"`)
|
||||||
|
setHeader(event, 'Cache-Control', 'private, max-age=3600')
|
||||||
|
return buf
|
||||||
|
})
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
{
|
||||||
|
"1": {
|
||||||
|
"class_type": "LoadImage",
|
||||||
|
"inputs": {
|
||||||
|
"image": "input.png"
|
||||||
|
},
|
||||||
|
"_meta": { "title": "Load Image" }
|
||||||
|
},
|
||||||
|
"2": {
|
||||||
|
"class_type": "CLIPLoader",
|
||||||
|
"inputs": {
|
||||||
|
"clip_name": "qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors",
|
||||||
|
"type": "minimax",
|
||||||
|
"device": "default"
|
||||||
|
},
|
||||||
|
"_meta": { "title": "Qwen3-VL CLIP" }
|
||||||
|
},
|
||||||
|
"3": {
|
||||||
|
"class_type": "UNETLoader",
|
||||||
|
"inputs": {
|
||||||
|
"unet_name": "minimax_h3_fl2va_pruned_int8_convrot.safetensors",
|
||||||
|
"weight_dtype": "default"
|
||||||
|
},
|
||||||
|
"_meta": { "title": "MiniMax H3 UNET" }
|
||||||
|
},
|
||||||
|
"4": {
|
||||||
|
"class_type": "VAELoader",
|
||||||
|
"inputs": {
|
||||||
|
"vae_name": "minimax_h3_video_vae_fp16.safetensors"
|
||||||
|
},
|
||||||
|
"_meta": { "title": "Video VAE" }
|
||||||
|
},
|
||||||
|
"5": {
|
||||||
|
"class_type": "VAELoader",
|
||||||
|
"inputs": {
|
||||||
|
"vae_name": "minimax_h3_audio_vae_fp32.safetensors"
|
||||||
|
},
|
||||||
|
"_meta": { "title": "Audio VAE" }
|
||||||
|
},
|
||||||
|
"6": {
|
||||||
|
"class_type": "LoraLoaderModelOnly",
|
||||||
|
"inputs": {
|
||||||
|
"model": ["3", 0],
|
||||||
|
"lora_name": "minimax_h3_fl2v_turbo_8step_v1.0_comfyui_bf16.safetensors",
|
||||||
|
"strength_model": 1
|
||||||
|
},
|
||||||
|
"_meta": { "title": "Turbo LoRA" }
|
||||||
|
},
|
||||||
|
"7": {
|
||||||
|
"class_type": "ComfySwitchNode",
|
||||||
|
"inputs": {
|
||||||
|
"switch": true,
|
||||||
|
"on_false": ["3", 0],
|
||||||
|
"on_true": ["6", 0]
|
||||||
|
},
|
||||||
|
"_meta": { "title": "LoRA Bypass Switch" }
|
||||||
|
},
|
||||||
|
"8": {
|
||||||
|
"class_type": "MiniMaxH3ImageToVideo",
|
||||||
|
"inputs": {
|
||||||
|
"clip": ["2", 0],
|
||||||
|
"vae": ["4", 0],
|
||||||
|
"first_frame": ["1", 0],
|
||||||
|
"prompt": "",
|
||||||
|
"width": 1344,
|
||||||
|
"height": 768,
|
||||||
|
"length": 124
|
||||||
|
},
|
||||||
|
"_meta": { "title": "MiniMax H3 Image to Video" }
|
||||||
|
},
|
||||||
|
"9": {
|
||||||
|
"class_type": "RandomNoise",
|
||||||
|
"inputs": {
|
||||||
|
"noise_seed": 0
|
||||||
|
},
|
||||||
|
"_meta": { "title": "Seed" }
|
||||||
|
},
|
||||||
|
"10": {
|
||||||
|
"class_type": "KSamplerSelect",
|
||||||
|
"inputs": {
|
||||||
|
"sampler_name": "res_multistep"
|
||||||
|
},
|
||||||
|
"_meta": { "title": "Sampler MiniMax" }
|
||||||
|
},
|
||||||
|
"11": {
|
||||||
|
"class_type": "BasicScheduler",
|
||||||
|
"inputs": {
|
||||||
|
"model": ["7", 0],
|
||||||
|
"scheduler": "simple",
|
||||||
|
"steps": 8,
|
||||||
|
"denoise": 1
|
||||||
|
},
|
||||||
|
"_meta": { "title": "Scheduler" }
|
||||||
|
},
|
||||||
|
"12": {
|
||||||
|
"class_type": "BasicGuider",
|
||||||
|
"inputs": {
|
||||||
|
"model": ["7", 0],
|
||||||
|
"conditioning": ["8", 0]
|
||||||
|
},
|
||||||
|
"_meta": { "title": "Guider" }
|
||||||
|
},
|
||||||
|
"13": {
|
||||||
|
"class_type": "SamplerCustomAdvanced",
|
||||||
|
"inputs": {
|
||||||
|
"noise": ["9", 0],
|
||||||
|
"guider": ["12", 0],
|
||||||
|
"sampler": ["10", 0],
|
||||||
|
"sigmas": ["11", 0],
|
||||||
|
"latent_image": ["8", 1]
|
||||||
|
},
|
||||||
|
"_meta": { "title": "KSampler" }
|
||||||
|
},
|
||||||
|
"14": {
|
||||||
|
"class_type": "VAEDecode",
|
||||||
|
"inputs": {
|
||||||
|
"samples": ["13", 0],
|
||||||
|
"vae": ["4", 0]
|
||||||
|
},
|
||||||
|
"_meta": { "title": "Decode Video" }
|
||||||
|
},
|
||||||
|
"15": {
|
||||||
|
"class_type": "VAEDecodeAudio",
|
||||||
|
"inputs": {
|
||||||
|
"samples": ["13", 0],
|
||||||
|
"vae": ["5", 0]
|
||||||
|
},
|
||||||
|
"_meta": { "title": "Decode Audio" }
|
||||||
|
},
|
||||||
|
"16": {
|
||||||
|
"class_type": "CreateVideo",
|
||||||
|
"inputs": {
|
||||||
|
"images": ["14", 0],
|
||||||
|
"audio": ["15", 0],
|
||||||
|
"fps": 24,
|
||||||
|
"bit_depth": 8
|
||||||
|
},
|
||||||
|
"_meta": { "title": "Create Video" }
|
||||||
|
},
|
||||||
|
"17": {
|
||||||
|
"class_type": "SaveVideo",
|
||||||
|
"inputs": {
|
||||||
|
"video": ["16", 0],
|
||||||
|
"filename_prefix": "video/MiniMax_H3",
|
||||||
|
"format": "auto",
|
||||||
|
"codec": "auto"
|
||||||
|
},
|
||||||
|
"_meta": { "title": "Save MP4" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
export default defineEventHandler((event) => {
|
||||||
|
if (!authEnabled()) return
|
||||||
|
const path = event.path || getRequestURL(event).pathname
|
||||||
|
if (
|
||||||
|
path.startsWith('/api/auth') ||
|
||||||
|
path.startsWith('/api/health') ||
|
||||||
|
path.startsWith('/_nuxt') ||
|
||||||
|
path.startsWith('/favicon') ||
|
||||||
|
path === '/login'
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const user = getSessionUser(event)
|
||||||
|
if (user) return
|
||||||
|
if (path.startsWith('/api/')) {
|
||||||
|
throw createError({ statusCode: 401, statusMessage: 'Authentication required' })
|
||||||
|
}
|
||||||
|
return sendRedirect(event, '/login', 302)
|
||||||
|
})
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
function comfyBase() {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const host = (config.comfyHost || process.env.COMFY_HOST || '').replace(/\/$/, '')
|
||||||
|
if (!host) {
|
||||||
|
throw createError({ statusCode: 500, statusMessage: 'COMFY_HOST is not configured' })
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getComfyHost() {
|
||||||
|
return comfyBase()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function comfyWsUrl(clientId: string) {
|
||||||
|
return `${comfyBase().replace(/^http/, 'ws')}/ws?clientId=${encodeURIComponent(clientId)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function comfyFetch(path: string, init?: RequestInit) {
|
||||||
|
const url = `${comfyBase()}${path}`
|
||||||
|
try {
|
||||||
|
return await fetch(url, init)
|
||||||
|
} catch (error) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 502,
|
||||||
|
statusMessage: `ComfyUI host unreachable (${comfyBase()})`,
|
||||||
|
data: { cause: error instanceof Error ? error.message : String(error) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadImage(file: { filename: string; data: Buffer; type?: string }) {
|
||||||
|
const body = new FormData()
|
||||||
|
const blob = new Blob([new Uint8Array(file.data)], { type: file.type || 'application/octet-stream' })
|
||||||
|
body.append('image', blob, file.filename)
|
||||||
|
body.append('overwrite', 'true')
|
||||||
|
body.append('type', 'input')
|
||||||
|
const res = await comfyFetch('/upload/image', { method: 'POST', body })
|
||||||
|
if (!res.ok) {
|
||||||
|
throw createError({ statusCode: 502, statusMessage: `Image upload failed (${res.status})` })
|
||||||
|
}
|
||||||
|
return (await res.json()) as { name: string; subfolder?: string; type?: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queuePrompt(graph: unknown, clientId: string) {
|
||||||
|
const res = await comfyFetch('/prompt', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ prompt: graph, client_id: clientId })
|
||||||
|
})
|
||||||
|
const payload = await res.json().catch(() => ({}))
|
||||||
|
if (!res.ok) {
|
||||||
|
const message = (payload as { error?: { message?: string } }).error?.message
|
||||||
|
|| (payload as { node_errors?: unknown }).node_errors
|
||||||
|
|| `Queue failed (${res.status})`
|
||||||
|
throw createError({ statusCode: 502, statusMessage: String(message), data: payload })
|
||||||
|
}
|
||||||
|
return payload as { prompt_id: string; number?: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function interruptComfy() {
|
||||||
|
const res = await comfyFetch('/interrupt', { method: 'POST' })
|
||||||
|
return res.ok
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchHistory(promptId: string) {
|
||||||
|
const res = await comfyFetch(`/history/${encodeURIComponent(promptId)}`)
|
||||||
|
if (!res.ok) return null
|
||||||
|
return (await res.json()) as Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractVideo(history: Record<string, unknown> | null, promptId: string) {
|
||||||
|
if (!history) return null
|
||||||
|
const entry = (history[promptId] || Object.values(history)[0]) as { outputs?: Record<string, Record<string, unknown>> } | undefined
|
||||||
|
const outputs = entry?.outputs || {}
|
||||||
|
for (const node of Object.values(outputs)) {
|
||||||
|
for (const key of ['videos', 'gifs', 'images', 'files']) {
|
||||||
|
const items = node[key]
|
||||||
|
if (!Array.isArray(items)) continue
|
||||||
|
const match = items.find((item: { filename?: string }) => String(item.filename || '').toLowerCase().endsWith('.mp4'))
|
||||||
|
|| items[0]
|
||||||
|
if (match?.filename) {
|
||||||
|
return {
|
||||||
|
filename: String(match.filename),
|
||||||
|
subfolder: String(match.subfolder || 'video'),
|
||||||
|
type: String(match.type || 'output')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function probeComfy() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${comfyBase()}/system_stats`, { signal: AbortSignal.timeout(2500) })
|
||||||
|
if (!res.ok) return { ok: false, host: comfyBase() }
|
||||||
|
const stats = await res.json()
|
||||||
|
return { ok: true, host: comfyBase(), stats }
|
||||||
|
} catch {
|
||||||
|
return { ok: false, host: comfyBase() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
export type JobStatus = 'queued' | 'uploading' | 'running' | 'complete' | 'error' | 'cancelled'
|
||||||
|
|
||||||
|
export interface JobEvent {
|
||||||
|
type: string
|
||||||
|
message?: string
|
||||||
|
progress?: number
|
||||||
|
step?: number
|
||||||
|
maxStep?: number
|
||||||
|
node?: string | null
|
||||||
|
filename?: string
|
||||||
|
subfolder?: string
|
||||||
|
mediaType?: string
|
||||||
|
error?: string
|
||||||
|
elapsedMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Job {
|
||||||
|
id: string
|
||||||
|
promptId?: string
|
||||||
|
clientId: string
|
||||||
|
status: JobStatus
|
||||||
|
message: string
|
||||||
|
progress: number
|
||||||
|
step: number
|
||||||
|
maxStep: number
|
||||||
|
startedAt: number
|
||||||
|
video?: { filename: string; subfolder: string; type: string }
|
||||||
|
error?: string
|
||||||
|
events: JobEvent[]
|
||||||
|
listeners: Set<(event: JobEvent) => void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobs = new Map<string, Job>()
|
||||||
|
const MAX_JOBS = 40
|
||||||
|
|
||||||
|
export function createJob(): Job {
|
||||||
|
const job: Job = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
clientId: crypto.randomUUID(),
|
||||||
|
status: 'queued',
|
||||||
|
message: 'Queued',
|
||||||
|
progress: 0,
|
||||||
|
step: 0,
|
||||||
|
maxStep: 0,
|
||||||
|
startedAt: Date.now(),
|
||||||
|
events: [],
|
||||||
|
listeners: new Set()
|
||||||
|
}
|
||||||
|
jobs.set(job.id, job)
|
||||||
|
while (jobs.size > MAX_JOBS) {
|
||||||
|
const oldest = jobs.keys().next().value
|
||||||
|
if (oldest) jobs.delete(oldest)
|
||||||
|
}
|
||||||
|
return job
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getJob(id: string) {
|
||||||
|
return jobs.get(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emitJob(job: Job, event: JobEvent) {
|
||||||
|
if (event.message) job.message = event.message
|
||||||
|
if (typeof event.progress === 'number') job.progress = event.progress
|
||||||
|
if (typeof event.step === 'number') job.step = event.step
|
||||||
|
if (typeof event.maxStep === 'number') job.maxStep = event.maxStep
|
||||||
|
event.elapsedMs = Date.now() - job.startedAt
|
||||||
|
job.events.push(event)
|
||||||
|
if (job.events.length > 200) job.events.shift()
|
||||||
|
for (const listener of job.listeners) {
|
||||||
|
try {
|
||||||
|
listener(event)
|
||||||
|
} catch {
|
||||||
|
// ignore broken SSE listeners
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeJob(job: Job, listener: (event: JobEvent) => void) {
|
||||||
|
job.listeners.add(listener)
|
||||||
|
return () => job.listeners.delete(listener)
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
|
||||||
|
import type { H3Event } from 'h3'
|
||||||
|
|
||||||
|
export interface SessionUser {
|
||||||
|
sub: string
|
||||||
|
email?: string
|
||||||
|
name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const COOKIE = 'aigen_session'
|
||||||
|
const STATE_COOKIE = 'aigen_oauth_state'
|
||||||
|
|
||||||
|
function secret() {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
return config.sessionSecret || 'dev-only-change-me'
|
||||||
|
}
|
||||||
|
|
||||||
|
function sign(value: string) {
|
||||||
|
return createHmac('sha256', secret()).update(value).digest('base64url')
|
||||||
|
}
|
||||||
|
|
||||||
|
function seal(payload: unknown) {
|
||||||
|
const data = Buffer.from(JSON.stringify(payload)).toString('base64url')
|
||||||
|
return `${data}.${sign(data)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function unseal<T>(token: string | undefined): T | null {
|
||||||
|
if (!token || !token.includes('.')) return null
|
||||||
|
const [data, sig] = token.split('.')
|
||||||
|
const expected = sign(data)
|
||||||
|
const a = Buffer.from(sig)
|
||||||
|
const b = Buffer.from(expected)
|
||||||
|
if (a.length !== b.length || !timingSafeEqual(a, b)) return null
|
||||||
|
try {
|
||||||
|
return JSON.parse(Buffer.from(data, 'base64url').toString('utf8')) as T
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cookieOpts(event: H3Event) {
|
||||||
|
const proto = getRequestHeader(event, 'x-forwarded-proto') || getRequestProtocol(event)
|
||||||
|
return {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax' as const,
|
||||||
|
path: '/',
|
||||||
|
secure: proto === 'https',
|
||||||
|
maxAge: 60 * 60 * 24 * 14
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSessionUser(event: H3Event): SessionUser | null {
|
||||||
|
return unseal<SessionUser>(getCookie(event, COOKIE))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSessionUser(event: H3Event, user: SessionUser) {
|
||||||
|
setCookie(event, COOKIE, seal(user), cookieOpts(event))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAuthSession(event: H3Event) {
|
||||||
|
deleteCookie(event, COOKIE, { path: '/' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createOauthState(event: H3Event) {
|
||||||
|
const state = randomBytes(24).toString('hex')
|
||||||
|
setCookie(event, STATE_COOKIE, state, { ...cookieOpts(event), maxAge: 600 })
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
export function consumeOauthState(event: H3Event, incoming: string | undefined) {
|
||||||
|
const stored = getCookie(event, STATE_COOKIE)
|
||||||
|
deleteCookie(event, STATE_COOKIE, { path: '/' })
|
||||||
|
return Boolean(stored && incoming && stored === incoming)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authEnabled() {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
return Boolean(config.public.authEnabled && config.oidcClientId && config.oidcClientSecret)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function publicBaseUrl(event: H3Event) {
|
||||||
|
const proto = getRequestHeader(event, 'x-forwarded-proto') || getRequestProtocol(event)
|
||||||
|
const host = getRequestHeader(event, 'x-forwarded-host') || getRequestHost(event)
|
||||||
|
return `${proto}://${host}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { NODE_LABELS } from '~/server/utils/workflow'
|
||||||
|
import type { Job } from '~/server/utils/jobs'
|
||||||
|
|
||||||
|
function classifyError(message: string) {
|
||||||
|
const lower = message.toLowerCase()
|
||||||
|
if (lower.includes('out of memory') || lower.includes('cuda') && lower.includes('alloc') || lower.includes('vram')) {
|
||||||
|
return 'ComfyUI VRAM allocation failed. Try Turbo (8 steps) or a smaller aspect ratio.'
|
||||||
|
}
|
||||||
|
if (lower.includes('econnrefused') || lower.includes('unreachable') || lower.includes('fetch failed')) {
|
||||||
|
return 'ComfyUI host connection dropped. Confirm the desktop instance is running and reachable on the LAN.'
|
||||||
|
}
|
||||||
|
if (lower.includes('timeout')) {
|
||||||
|
return 'Network timeout talking to ComfyUI. The job may still be running on the desktop.'
|
||||||
|
}
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function watchComfyJob(job: Job) {
|
||||||
|
const ws = new WebSocket(comfyWsUrl(job.clientId))
|
||||||
|
let settled = false
|
||||||
|
|
||||||
|
const finish = async (error?: string) => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
try { ws.close() } catch { /* ignore */ }
|
||||||
|
if (error) {
|
||||||
|
job.status = job.status === 'cancelled' ? 'cancelled' : 'error'
|
||||||
|
job.error = classifyError(error)
|
||||||
|
emitJob(job, { type: 'error', error: job.error, message: job.error })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emitJob(job, { type: 'status', message: 'Fetching output...', progress: 96 })
|
||||||
|
const history = await fetchHistory(job.promptId || '')
|
||||||
|
const video = extractVideo(history, job.promptId || '')
|
||||||
|
if (!video) {
|
||||||
|
job.status = 'error'
|
||||||
|
job.error = 'Workflow finished but no MP4 was found in ComfyUI history.'
|
||||||
|
emitJob(job, { type: 'error', error: job.error, message: job.error })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
job.video = video
|
||||||
|
job.status = 'complete'
|
||||||
|
emitJob(job, {
|
||||||
|
type: 'complete',
|
||||||
|
message: 'Video ready',
|
||||||
|
progress: 100,
|
||||||
|
filename: video.filename,
|
||||||
|
subfolder: video.subfolder,
|
||||||
|
mediaType: video.type
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
finish('Timed out waiting for ComfyUI (15 minutes).')
|
||||||
|
}, 15 * 60 * 1000)
|
||||||
|
|
||||||
|
ws.addEventListener('open', () => {
|
||||||
|
emitJob(job, { type: 'status', message: 'Connected to ComfyUI', progress: 8 })
|
||||||
|
})
|
||||||
|
|
||||||
|
ws.addEventListener('error', () => {
|
||||||
|
if (!settled) finish('ComfyUI WebSocket connection dropped.')
|
||||||
|
})
|
||||||
|
|
||||||
|
ws.addEventListener('close', () => {
|
||||||
|
if (!settled) {
|
||||||
|
// Fall back to history polling in case completion arrived as HTTP only.
|
||||||
|
setTimeout(async () => {
|
||||||
|
if (settled) return
|
||||||
|
const history = await fetchHistory(job.promptId || '')
|
||||||
|
if (extractVideo(history, job.promptId || '')) {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
await finish()
|
||||||
|
}
|
||||||
|
}, 1500)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ws.addEventListener('message', async (event) => {
|
||||||
|
let payload: { type?: string; data?: Record<string, unknown> }
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(String(event.data))
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const data = payload.data || {}
|
||||||
|
if (job.promptId && data.prompt_id && data.prompt_id !== job.promptId) return
|
||||||
|
|
||||||
|
if (payload.type === 'progress') {
|
||||||
|
const value = Number(data.value || 0)
|
||||||
|
const max = Number(data.max || 1)
|
||||||
|
const pct = 12 + Math.round((value / Math.max(max, 1)) * 70)
|
||||||
|
job.status = 'running'
|
||||||
|
emitJob(job, {
|
||||||
|
type: 'progress',
|
||||||
|
step: value,
|
||||||
|
maxStep: max,
|
||||||
|
progress: pct,
|
||||||
|
node: data.node ? String(data.node) : null,
|
||||||
|
message: `Sampling step ${value}/${max} (${Math.round((value / Math.max(max, 1)) * 100)}%)`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.type === 'executing') {
|
||||||
|
const node = data.node === null || data.node === undefined ? null : String(data.node)
|
||||||
|
if (node === null) {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
await finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const label = NODE_LABELS[node] || `Running node ${node}`
|
||||||
|
const encoding = node === '16' || node === '17'
|
||||||
|
emitJob(job, {
|
||||||
|
type: 'executing',
|
||||||
|
node,
|
||||||
|
progress: encoding ? 90 : Math.max(job.progress, 10),
|
||||||
|
message: encoding ? 'Encoding video...' : `${label}...`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.type === 'execution_error') {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
const message = String(data.exception_message || data.message || 'ComfyUI node execution failed')
|
||||||
|
await finish(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.type === 'execution_interrupted') {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
job.status = 'cancelled'
|
||||||
|
await finish('Job interrupted.')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
try { ws.close() } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// Nitro bundles this JSON into the production server output.
|
||||||
|
import workflowTemplate from '../assets/workflow_api.json'
|
||||||
|
|
||||||
|
export interface GenerateParams {
|
||||||
|
prompt: string
|
||||||
|
imageName: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
steps: number
|
||||||
|
seed: number
|
||||||
|
turbo: boolean
|
||||||
|
length: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkflowGraph = Record<string, { class_type: string; inputs: Record<string, unknown>; _meta?: { title?: string } }>
|
||||||
|
|
||||||
|
export function buildWorkflow(params: GenerateParams) {
|
||||||
|
const graph = structuredClone(workflowTemplate) as WorkflowGraph
|
||||||
|
graph['1'].inputs.image = params.imageName
|
||||||
|
graph['8'].inputs.prompt = params.prompt
|
||||||
|
graph['8'].inputs.width = params.width
|
||||||
|
graph['8'].inputs.height = params.height
|
||||||
|
graph['8'].inputs.length = params.length
|
||||||
|
graph['9'].inputs.noise_seed = params.seed
|
||||||
|
graph['11'].inputs.steps = params.steps
|
||||||
|
graph['7'].inputs.switch = params.turbo
|
||||||
|
graph['6'].inputs.strength_model = params.turbo ? 1 : 0
|
||||||
|
return graph
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NODE_LABELS: Record<string, string> = {
|
||||||
|
'1': 'Loading image',
|
||||||
|
'2': 'Loading Qwen3-VL encoder',
|
||||||
|
'3': 'Loading MiniMax H3 model',
|
||||||
|
'4': 'Loading video VAE',
|
||||||
|
'5': 'Loading audio VAE',
|
||||||
|
'6': 'Applying turbo LoRA',
|
||||||
|
'7': 'Selecting model path',
|
||||||
|
'8': 'Encoding prompt & latent',
|
||||||
|
'9': 'Preparing seed',
|
||||||
|
'10': 'Selecting sampler',
|
||||||
|
'11': 'Building schedule',
|
||||||
|
'12': 'Preparing guider',
|
||||||
|
'13': 'Sampling',
|
||||||
|
'14': 'Decoding video frames',
|
||||||
|
'15': 'Decoding audio',
|
||||||
|
'16': 'Encoding video',
|
||||||
|
'17': 'Saving MP4'
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { Config } from 'tailwindcss'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
'./app.vue',
|
||||||
|
'./pages/**/*.vue',
|
||||||
|
'./components/**/*.vue',
|
||||||
|
'./composables/**/*.ts'
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {}
|
||||||
|
}
|
||||||
|
} satisfies Config
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "./.nuxt/tsconfig.json"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user