mirror of
https://github.com/Anil-matcha/Open-Generative-AI.git
synced 2026-05-07 01:17:18 +00:00
Compare commits
119 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
927e8600d8 |
||
|
|
aa4a4202b5 | ||
|
|
46abef7bb8 | ||
|
|
5e01f60758 | ||
|
|
b2d331e7d8 | ||
|
|
8cbaf7fc3f | ||
|
|
4fc6606d14 | ||
|
|
46f78f633b | ||
|
|
1fffdae326 |
||
|
|
f97bb8708d |
||
|
|
94b71fddf7 | ||
|
|
a12fd4e858 | ||
|
|
b3b5744758 | ||
|
|
5b860e512b | ||
|
|
894b520516 | ||
|
|
6bf34147c1 | ||
|
|
be60dfdd13 |
||
|
|
51f37b9e2e |
||
|
|
fe87f4ad6a | ||
|
|
9a742f6645 | ||
|
|
d4e645defc | ||
|
|
b871842096 | ||
|
|
35b7103c26 | ||
|
|
a72857cb04 |
||
|
|
061c1c8213 | ||
|
|
84ca594b92 | ||
|
|
6e70b876f6 | ||
|
|
ba60960949 | ||
|
|
c5a3b55c95 | ||
|
|
6214f865a3 | ||
|
|
032ab0c693 | ||
|
|
9444d27969 |
||
|
|
b5ba70409e | ||
|
|
291201f455 | ||
|
|
0493e6244b | ||
|
|
8dd0a6ebc1 | ||
|
|
f0ac343ee4 | ||
|
|
abfcd2ce49 | ||
|
|
8c50910863 |
||
|
|
7d2a025b4d | ||
|
|
e5531d1f1e |
||
|
|
0ed86f2d77 | ||
|
|
11679c387d |
||
|
|
16369973b0 | ||
|
|
2b70cbad73 |
||
|
|
e56d0041e9 |
||
|
|
f2b7661059 |
||
|
|
0f4748829a | ||
|
|
6bc0a355fb |
||
|
|
ebacfd4748 | ||
|
|
e9f6d3458a | ||
|
|
1afefdad0c | ||
|
|
e832e9d389 | ||
|
|
a45b7a53b6 |
||
|
|
bf32a393cc | ||
|
|
6011fcb0fd | ||
|
|
8cf83b1b45 | ||
|
|
36d392ab78 | ||
|
|
5cbcd88733 |
||
|
|
4efb8593a4 | ||
|
|
911bcdd558 | ||
|
|
fddc2ff69f | ||
|
|
62be9ace66 | ||
|
|
9de0de3430 | ||
|
|
aa6917e339 | ||
|
|
4c48d58159 | ||
|
|
7582b89aea |
||
|
|
efa772e772 | ||
|
|
9f858c0ba8 | ||
|
|
6f9cdeeb47 |
||
|
|
b92bdc5326 | ||
|
|
57cb24bcbc |
||
|
|
0f4ae55b37 | ||
|
|
17adf7eca7 | ||
|
|
1c33c1be7b | ||
|
|
6c47f0dca3 |
||
|
|
963cc7d2e5 |
||
|
|
171f02c05f |
||
|
|
ebcddfa187 | ||
|
|
5925256c0a |
||
|
|
38d8ea3228 | ||
|
|
1c6a0c863e | ||
|
|
cf0d84d26e |
||
|
|
20bf1bce42 | ||
|
|
046f4ac071 | ||
|
|
21a09ebea2 |
||
|
|
91ce11df6f | ||
|
|
b578108936 |
||
|
|
b924f0caf8 | ||
|
|
776a325e77 | ||
|
|
2c95a86af8 | ||
|
|
fe2d8dd0ac | ||
|
|
0c29954889 | ||
|
|
11f850abfc | ||
|
|
7ba63548d7 | ||
|
|
fc49614a87 | ||
|
|
f4c8b3aae3 | ||
|
|
29cff36eef | ||
|
|
268102ab46 | ||
|
|
87f18dd088 |
||
|
|
41dc0c2810 |
||
|
|
150cd639cb |
||
|
|
38ce0ecbf1 |
||
|
|
6877dbefe4 |
||
|
|
b84e38ef25 |
||
|
|
9e50297717 |
||
|
|
d7ec9f239a |
||
|
|
be5c759e2a |
||
|
|
d6aafa18e1 |
||
|
|
692cef85a7 |
||
|
|
07f3eb2179 |
||
|
|
d9f7151996 |
||
|
|
cf5ca7ce69 |
||
|
|
5b1cfcf347 |
||
|
|
3364a34111 |
||
|
|
4b48d1c976 |
||
|
|
119589ef5d |
||
|
|
06229973f4 |
||
|
|
c9ae73077d |
78 changed files with 17145 additions and 12557 deletions
14
.gitignore
vendored
14
.gitignore
vendored
|
|
@ -22,3 +22,17 @@ dist-ssr
|
||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
# Electron build output
|
||||||
|
release/
|
||||||
|
.webpack/
|
||||||
|
|
||||||
|
# Next.js
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
*.pem
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
|
||||||
6
.gitmodules
vendored
Normal file
6
.gitmodules
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
[submodule "packages/Vibe-Workflow"]
|
||||||
|
path = packages/Vibe-Workflow
|
||||||
|
url = https://github.com/SamurAIGPT/Vibe-Workflow.git
|
||||||
|
[submodule "packages/Open-Poe-AI"]
|
||||||
|
path = packages/Open-Poe-AI
|
||||||
|
url = https://github.com/Anil-matcha/Open-Poe-AI.git
|
||||||
27
Dockerfile
Normal file
27
Dockerfile
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
FROM node:20-alpine AS base
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
FROM base AS deps
|
||||||
|
COPY package*.json ./
|
||||||
|
COPY packages/Vibe-Workflow/packages/workflow-builder/package*.json ./packages/Vibe-Workflow/packages/workflow-builder/
|
||||||
|
COPY packages/Open-Poe-AI/packages/agents/package*.json ./packages/Open-Poe-AI/packages/agents/
|
||||||
|
COPY packages/studio/package*.json ./packages/studio/
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# Build sub-packages
|
||||||
|
FROM deps AS builder
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build:packages
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production runner
|
||||||
|
FROM base AS runner
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
COPY --from=builder /app/.next ./.next
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/package.json ./package.json
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["npm", "start"]
|
||||||
264
README.md
264
README.md
|
|
@ -1,6 +1,28 @@
|
||||||
# Open Higgsfield AI — Open-Source Alternative to Higgsfield AI
|
# Open Generative AI — Uncensored Open-Source Alternative to Higgsfield AI, Freepik AI, Krea AI, Openart AI
|
||||||
|
|
||||||
> **The free, open-source alternative to Higgsfield AI.** Generate AI images and videos using 200+ state-of-the-art models — without the closed ecosystem or subscription fees.
|
> **The free, open-source, unrestricted alternative to Higgsfield AI, Freepik, Krea, Openart AI.** Generate AI images and videos using 200+ state-of-the-art models — no content filters, no closed ecosystem, no subscription fees.
|
||||||
|
|
||||||
|
**Community:** Join [Reddit](reddit.com/r/muapi) & [Discord](https://discord.gg/s7KW4fsqXK) for discussions and support
|
||||||
|
|
||||||
|
> 🤖 **Automate Higgsfield, Freepik, Krea, Openart & more with AI coding agents:** [Generative-Media-Skills](https://github.com/SamurAIGPT/Generative-Media-Skills) — a library of skills that let agents like **Claude Code**, **Codex**, and other coding assistants drive 200+ image/video models end-to-end (prompt → generate → edit → stitch) directly from your terminal. Perfect for building automated media pipelines without touching a UI.
|
||||||
|
|
||||||
|
### Related projects
|
||||||
|
|
||||||
|
> **Open-source Weavy, Flora Fauna Freepik Spaces, Krea nodes alternative** -> https://github.com/SamurAIGPT/Vibe-Workflow
|
||||||
|
|
||||||
|
> **Open-source Opus Clip alternative — turn any long-form YouTube video into viral-ready vertical shorts** -> https://github.com/SamurAIGPT/AI-Youtube-Shorts-Generator
|
||||||
|
|
||||||
|
> **Open-source Lovart AI alternative, an AI design agent** -> https://github.com/Anil-matcha/Open-Lovart
|
||||||
|
|
||||||
|
## 🌐 Try it Online — No Install Required
|
||||||
|
|
||||||
|
**Hosted version:** [https://dev.muapi.ai/open-generative-ai](https://dev.muapi.ai/open-generative-ai)
|
||||||
|
|
||||||
|
Use all four studios (Image, Video, Lip Sync, Cinema) directly in your browser — no Node.js, no setup. Sign up for a free account to start generating. The hosted version is always up to date with the latest models.
|
||||||
|
|
||||||
|
**Follow** the [creator](https://x.com/matchaman11) for updates
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## ⬇️ Download Desktop App
|
## ⬇️ Download Desktop App
|
||||||
|
|
||||||
|
|
@ -8,11 +30,12 @@ One-click installers — no Node.js or terminal required.
|
||||||
|
|
||||||
| Platform | Download |
|
| Platform | Download |
|
||||||
|---|---|
|
|---|---|
|
||||||
| macOS Apple Silicon (M1/M2/M3/M4) | [Open Higgsfield AI-1.0.0-arm64.dmg](https://github.com/Anil-matcha/Open-Higgsfield-AI/releases/download/v1.0.0/Open.Higgsfield.AI-1.0.0-arm64.dmg) |
|
| macOS Apple Silicon (M1/M2/M3/M4) | [Open Generative AI-1.0.9-arm64.dmg](https://github.com/Anil-matcha/Open-Generative-AI/releases/download/v1.0.9/Open.Generative.AI-1.0.9-arm64.dmg) |
|
||||||
| macOS Intel (x64) | [Open Higgsfield AI-1.0.0.dmg](https://github.com/Anil-matcha/Open-Higgsfield-AI/releases/download/v1.0.0/Open.Higgsfield.AI-1.0.0.dmg) |
|
| macOS Intel (x64) | [Open Generative AI-1.0.9.dmg](https://github.com/Anil-matcha/Open-Generative-AI/releases/download/v1.0.9/Open.Generative.AI-1.0.9.dmg) |
|
||||||
| Windows (x64 + ARM64) | [Open Higgsfield AI Setup 1.0.0.exe](https://github.com/Anil-matcha/Open-Higgsfield-AI/releases/download/v1.0.0/Open.Higgsfield.AI.Setup.1.0.0.exe) |
|
| Windows (x64) | [Open Generative AI Setup 1.0.9.exe](https://github.com/Anil-matcha/Open-Generative-AI/releases/download/v1.0.9/Open.Generative.AI.Setup.1.0.9.exe) |
|
||||||
|
| Linux (Ubuntu x64) | [v1.0.9 release](https://github.com/Anil-matcha/Open-Generative-AI/releases/tag/v1.0.9) (`.AppImage` / `.deb`), or build locally with `npm run electron:build:linux`. |
|
||||||
|
|
||||||
All releases: [github.com/Anil-matcha/Open-Higgsfield-AI/releases](https://github.com/Anil-matcha/Open-Higgsfield-AI/releases)
|
All releases: [github.com/Anil-matcha/Open-Generative-AI/releases](https://github.com/Anil-matcha/Open-Generative-AI/releases)
|
||||||
|
|
||||||
### macOS Installation Guide
|
### macOS Installation Guide
|
||||||
|
|
||||||
|
|
@ -22,7 +45,7 @@ Because the app is not notarized by Apple, macOS Gatekeeper will block it on fir
|
||||||
|
|
||||||
**Step 2** — Open Terminal and run:
|
**Step 2** — Open Terminal and run:
|
||||||
```bash
|
```bash
|
||||||
xattr -cr "/Applications/Open Higgsfield AI.app"
|
xattr -cr "/Applications/Open Generative AI.app"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Step 3** — Right-click the app in `/Applications` → click **Open** → click **Open** again on the dialog
|
**Step 3** — Right-click the app in `/Applications` → click **Open** → click **Open** again on the dialog
|
||||||
|
|
@ -32,7 +55,7 @@ xattr -cr "/Applications/Open Higgsfield AI.app"
|
||||||
**Alternative (no Terminal):**
|
**Alternative (no Terminal):**
|
||||||
1. Try to open the app — macOS will block it
|
1. Try to open the app — macOS will block it
|
||||||
2. Go to **System Settings → Privacy & Security**
|
2. Go to **System Settings → Privacy & Security**
|
||||||
3. Scroll down to find _"Open Higgsfield AI was blocked"_
|
3. Scroll down to find _"Open Generative AI was blocked"_
|
||||||
4. Click **Open Anyway** → **Open**
|
4. Click **Open Anyway** → **Open**
|
||||||
|
|
||||||
### Windows Installation — SmartScreen warning fix
|
### Windows Installation — SmartScreen warning fix
|
||||||
|
|
@ -44,29 +67,170 @@ Windows SmartScreen may show a warning because the installer is not code-signed:
|
||||||
|
|
||||||
The app will install silently to `%LocalAppData%` with a Start Menu shortcut.
|
The app will install silently to `%LocalAppData%` with a Start Menu shortcut.
|
||||||
|
|
||||||
|
### Ubuntu / Linux Installation
|
||||||
|
|
||||||
|
Linux artifacts are available when building with Electron Builder:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build Linux installers (AppImage + .deb)
|
||||||
|
npm run electron:build:linux
|
||||||
|
```
|
||||||
|
|
||||||
|
Generated files are written to the `release/` folder:
|
||||||
|
- **AppImage** — portable, run directly after making executable:
|
||||||
|
```bash
|
||||||
|
chmod +x "release/Open Generative AI-*.AppImage"
|
||||||
|
./release/Open\ Generative\ AI-*.AppImage
|
||||||
|
```
|
||||||
|
- **.deb** — install on Debian/Ubuntu:
|
||||||
|
```bash
|
||||||
|
sudo apt install ./release/open-generative-ai_*_amd64.deb
|
||||||
|
```
|
||||||
|
|
||||||
|
If AppImage fails to start on older systems, install `libfuse2`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install libfuse2
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Ubuntu 24.04+ / AppArmor sandbox restriction
|
||||||
|
|
||||||
|
Ubuntu 24.04 and later enable a kernel security policy (`apparmor_restrict_unprivileged_userns`) that blocks Chromium's user-namespace sandbox. If the app fails to start silently or crashes immediately, you have two options:
|
||||||
|
|
||||||
|
**Option A — Recommended: install the `.deb` instead.**
|
||||||
|
The `.deb` package ships an AppArmor profile that grants the required permission automatically on install with no system-wide changes.
|
||||||
|
|
||||||
|
**Option B — Temporary system fix (AppImage users):**
|
||||||
|
```bash
|
||||||
|
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||||
|
```
|
||||||
|
This lasts until next reboot. To make it permanent:
|
||||||
|
```bash
|
||||||
|
echo 'kernel.apparmor_restrict_unprivileged_userns=0' | sudo tee /etc/sysctl.d/99-userns.conf
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Open Higgsfield AI is an open-source AI image, video, cinema, and lip sync studio that brings Higgsfield-style creative workflows to everyone. Powered by [Muapi.ai](https://muapi.ai), it supports text-to-image, image-to-image, text-to-video, image-to-video, and audio-driven lip sync generation across models like Flux, Nano Banana, Midjourney, Kling, Sora, Veo, Seedream, Infinite Talk, LTX Lipsync, Wan 2.2, and more — all from a sleek, modern interface you can self-host and customize.
|
Open Generative AI is a free, uncensored, open-source AI image, video, cinema, and lip sync studio that brings unrestricted creative workflows to everyone. No content filters, no prompt rejections, no guardrails — just full creative freedom. Powered by [Muapi.ai](https://muapi.ai), it supports text-to-image, image-to-image, text-to-video, image-to-video, and audio-driven lip sync generation across models like Flux, Nano Banana, Midjourney, Kling, Sora, Veo, Seedream, Infinite Talk, LTX Lipsync, Wan 2.2, and more — all from a sleek, modern interface you can self-host and customize.
|
||||||
|
|
||||||
**Why Open Higgsfield AI instead of Higgsfield AI?**
|
**Why Open Generative AI instead of Higgsfield AI, Freepik, Krea AI, Openart AI?**
|
||||||
|
- **Uncensored & unrestricted** — no content filters, no nanny guardrails, no prompt rejections
|
||||||
- **Free & open-source** — no subscription, no vendor lock-in
|
- **Free & open-source** — no subscription, no vendor lock-in
|
||||||
- **Self-hosted** — your data stays on your machine
|
- **Self-hosted** — your data stays on your machine, full creative control
|
||||||
- **200+ models** — text-to-image, image-to-image, text-to-video, image-to-video, lip sync
|
- **200+ models** — text-to-image, image-to-image, text-to-video, image-to-video, lip sync
|
||||||
- **Multi-image input** — feed up to 14 reference images into compatible models
|
- **Multi-image input** — feed up to 14 reference images into compatible models
|
||||||
- **Lip Sync Studio** — animate portraits or sync lips to any audio with 9 dedicated models
|
- **Lip Sync Studio** — animate portraits or sync lips to any audio with 9 dedicated models
|
||||||
- **Extensible** — add your own models, modify the UI, build on top of it
|
- **Extensible** — add your own models, modify the UI, build on top of it
|
||||||
|
|
||||||
For a deep dive into the technical architecture and the philosophy behind the "Infinite Budget" cinema workflow, see our [comprehensive guide and roadmap](https://medium.com/@anilmatcha/building-open-higgsfield-ai-an-open-source-ai-cinema-studio-83c1e0a2a5f1).
|
For a deep dive into the technical architecture and the philosophy behind the "Infinite Budget" cinema workflow, see our [comprehensive guide and roadmap](https://medium.com/@anilmatcha/).
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
## ⚡ Local Model Inference (Desktop App Only)
|
||||||
|
|
||||||
|
The desktop app supports **two independent local engines**. Pick whichever fits the machine you actually run on:
|
||||||
|
|
||||||
|
| Engine | What it is | Best for |
|
||||||
|
|---|---|---|
|
||||||
|
| **sd.cpp** (bundled) | C++ engine from [stable-diffusion.cpp](https://github.com/leejet/stable-diffusion.cpp), runs on the same machine as the app. Metal GPU on Apple Silicon, CUDA/Vulkan/ROCm on Linux/Windows. | Image-only models. Works on Mac M-series. |
|
||||||
|
| **Wan2GP** (BYO server) | HTTP client to a user-run [Wan2GP](https://github.com/deepbeepmeep/Wan2GP) server. The server runs Python + PyTorch on a CUDA/ROCm GPU; the desktop app only sends prompts and receives results. | Video models (Wan 2.2, Hunyuan, LTX) and large image models (Flux, Qwen-Image). NVIDIA/AMD GPU required on the *server*; the desktop app itself can run on a Mac. |
|
||||||
|
|
||||||
|
Both engines share the same UI: open **Settings → Local Models** to configure each.
|
||||||
|
|
||||||
|
### Engine 1 — sd.cpp (bundled)
|
||||||
|
|
||||||
|
| Model | Type | Size | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Z-Image Turbo** ⚡ | Diffusion Transformer | 2.5 GB + 2.7 GB aux | 8-step turbo. Heavy on memory. |
|
||||||
|
| **Z-Image Base** ⚡ | Diffusion Transformer | 3.5 GB + 2.7 GB aux | 50-step high-quality. Heavy on memory. |
|
||||||
|
| **Dreamshaper 8** | SD 1.5 | 2.1 GB | 20-step versatile. Lightest tested option on Mac. |
|
||||||
|
| **Realistic Vision v5.1** | SD 1.5 | 2.1 GB | 25-step photorealistic |
|
||||||
|
| **Anything v5** | SD 1.5 | 2.1 GB | 20-step anime/illustration |
|
||||||
|
| **SDXL Base 1.0** | SDXL | 6.9 GB | 30-step high-res |
|
||||||
|
|
||||||
|
> **Z-Image models** require two shared auxiliary files (downloaded once, shared across both models):
|
||||||
|
> - **Qwen3-4B Text Encoder** — 2.4 GB
|
||||||
|
> - **FLUX VAE** — 335 MB
|
||||||
|
|
||||||
|
**How to use:**
|
||||||
|
1. Open **Settings → Local Models** in the desktop app
|
||||||
|
2. Install the **sd.cpp inference engine** (one click — auto-downloaded)
|
||||||
|
3. Download your chosen model (and auxiliary files for Z-Image)
|
||||||
|
4. In **Image Studio**, click the **⚡ Local** toggle next to the model selector
|
||||||
|
5. Select your local model and generate — no API key needed
|
||||||
|
|
||||||
|
All downloads happen inside the app. Nothing is installed system-wide.
|
||||||
|
|
||||||
|
### Engine 2 — Wan2GP (remote Gradio server)
|
||||||
|
|
||||||
|
The app does **not** bundle Python or model weights for Wan2GP. You run Wan2GP yourself on a machine with a CUDA or ROCm GPU and point the desktop app at its URL.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# On your GPU machine
|
||||||
|
git clone https://github.com/deepbeepmeep/Wan2GP
|
||||||
|
cd Wan2GP
|
||||||
|
./install.sh # or install.bat on Windows
|
||||||
|
python wgp.py --listen --server-name 0.0.0.0 # binds to all interfaces
|
||||||
|
```
|
||||||
|
|
||||||
|
Then in the desktop app: **Settings → Local Models → Wan2GP server**, paste the URL (e.g. `http://192.168.1.42:7860`), click **Test**, then **Save**. The Wan2GP models become available — image models in **Image Studio**, video models reachable via the same generation API (Image Studio rejects video output explicitly; full Video Studio wiring is on the roadmap).
|
||||||
|
|
||||||
|
| Model | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| **Flux.1 Dev** | Image | 1024px, 28 steps |
|
||||||
|
| **Qwen Image** | Image | 1024px, 30 steps |
|
||||||
|
| **Wan 2.2 (T2V / I2V)** | Video | Slow on consumer GPUs |
|
||||||
|
| **Hunyuan Video** | Video | High-quality T2V |
|
||||||
|
| **LTX Video** | Video | Fastest video option |
|
||||||
|
|
||||||
|
> **Why a separate server?** Wan2GP's runtime (Sage attention, flash-attn, AWQ/GGUF kernels) is CUDA-only — there is no MPS / Apple Silicon path. Treating it as a remote server lets a Mac-only user keep the desktop app while offloading inference to a Linux/Windows GPU box, a gaming PC on the LAN, or a rented RunPod/vast.ai instance.
|
||||||
|
|
||||||
|
> **Local inference is only available in the desktop app.** The hosted web version always uses cloud APIs.
|
||||||
|
|
||||||
|
### Hardware Notes
|
||||||
|
|
||||||
|
- **sd.cpp** runs on CPU (all platforms) and **Metal GPU** on Apple Silicon (M1/M2/M3/M4); CUDA/Vulkan/ROCm on Linux/Windows.
|
||||||
|
- Metal GPU acceleration is built into the macOS desktop binary — significantly faster than CPU-only.
|
||||||
|
- Recommended for sd.cpp Z-Image: 16 GB RAM (7.4 GB weights + 2.4 GB compute buffer). On a base 8 GB M-series Mac, **Z-Image is known to hang the system** — stick to SD 1.5 there.
|
||||||
|
- For SD 1.5 on M2: expect ~1–2 s/step with the Metal dylib active. If you see ~10 s/step instead, the binary may have fallen back to CPU — see verification below.
|
||||||
|
|
||||||
|
### Verifying the SD 1.5 path (the fastest sanity test on Mac)
|
||||||
|
|
||||||
|
If you want to confirm sd.cpp is installed correctly without going through the UI, you can drive `sd-cli` directly. This is the same binary the app uses.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. App data layout (created on first app launch)
|
||||||
|
APP_DATA="$HOME/Library/Application Support/open-generative-ai/local-ai"
|
||||||
|
ls "$APP_DATA/bin" # sd-cli, libstable-diffusion.dylib
|
||||||
|
ls "$APP_DATA/models" # whatever you've downloaded
|
||||||
|
|
||||||
|
# 2. Grab a small SD 1.5 model directly (Dreamshaper 8, ~2 GB)
|
||||||
|
curl -L --fail --progress-bar \
|
||||||
|
-o "$APP_DATA/models/DreamShaper_8_pruned.safetensors" \
|
||||||
|
"https://huggingface.co/Lykon/DreamShaper/resolve/main/DreamShaper_8_pruned.safetensors"
|
||||||
|
|
||||||
|
# 3. Run a single 512x512 / 12-step inference
|
||||||
|
DYLD_LIBRARY_PATH="$APP_DATA/bin" "$APP_DATA/bin/sd-cli" \
|
||||||
|
-m "$APP_DATA/models/DreamShaper_8_pruned.safetensors" \
|
||||||
|
-p "a serene mountain lake at sunrise, oil painting" \
|
||||||
|
-o /tmp/sd15-test.png \
|
||||||
|
--steps 12 -H 512 -W 512 --cfg-scale 7.5 --seed 42 \
|
||||||
|
--sampling-method euler_a
|
||||||
|
```
|
||||||
|
|
||||||
|
A healthy run on Apple Silicon prints `total params memory size = 1969.78MB (VRAM 1969.78MB, RAM 0.00MB)` (Metal-backed) and produces a coherent 512×512 PNG. If `VRAM` is `0.00MB` instead, the dylib is CPU-only — check `otool -L "$APP_DATA/bin/libstable-diffusion.dylib" | grep -i metal` and reinstall the engine from **Settings → Local Models** if Metal is missing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
- **Image Studio** — Generate images from text prompts (50+ text-to-image models) or transform existing images (55+ image-to-image models). Switches model set automatically based on whether a reference image is provided. Quality and resolution controls visible for models that support them.
|
- **Image Studio** — Generate images from text prompts (50+ text-to-image models) or transform existing images (55+ image-to-image models). Switches model set automatically based on whether a reference image is provided. Quality and resolution controls visible for models that support them.
|
||||||
|
- **Local Inference** — Two engines: **sd.cpp** (bundled, runs on Mac/Win/Linux with Metal/CUDA/Vulkan/ROCm) for SD 1.5, SDXL, and Z-Image; and **Wan2GP** (BYO Gradio server) for Flux, Qwen-Image, and video models (Wan 2.2, Hunyuan, LTX). Configure both in Settings → Local Models.
|
||||||
- **Multi-Image Input** — Upload up to 14 reference images for compatible edit models (Nano Banana 2 Edit, Flux Kontext Dev, GPT-4o Edit, and more). Multi-select picker with order badges, batch upload, and a "Use Selected" confirmation flow.
|
- **Multi-Image Input** — Upload up to 14 reference images for compatible edit models (Nano Banana 2 Edit, Flux Kontext Dev, GPT-4o Edit, and more). Multi-select picker with order badges, batch upload, and a "Use Selected" confirmation flow.
|
||||||
- **Video Studio** — Generate videos from text prompts (40+ text-to-video models) or animate a start-frame image (60+ image-to-video models). Same intelligent mode switching as Image Studio.
|
- **Video Studio** — Generate videos from text prompts (40+ text-to-video models) or animate a start-frame image (60+ image-to-video models). Same intelligent mode switching as Image Studio.
|
||||||
- **Lip Sync Studio** — Animate portrait images or sync lips on existing videos using audio. 9 dedicated models across two modes: portrait image + audio → talking video, and video + audio → lipsync video.
|
- **Lip Sync Studio** — Animate portrait images or sync lips on existing videos using audio. 9 dedicated models across two modes: portrait image + audio → talking video, and video + audio → lipsync video.
|
||||||
- **Cinema Studio** — Higgsfield AI-style interface for photorealistic cinematic shots with pro camera controls (Lens, Focal Length, Aperture)
|
- **Cinema Studio** — Interface for photorealistic cinematic shots with pro camera controls (Lens, Focal Length, Aperture)
|
||||||
|
- **Workflow Studio** — Build and run multi-step AI pipelines visually. Chain image, video, and audio models into automated flows. Browse community templates, create your own with a node-based editor, and run them via an interactive playground.
|
||||||
- **Upload History** — Reference images are uploaded once and stored locally. A picker panel lets you reuse any previously uploaded image across sessions — no re-uploading.
|
- **Upload History** — Reference images are uploaded once and stored locally. A picker panel lets you reuse any previously uploaded image across sessions — no re-uploading.
|
||||||
- **Smart Controls** — Dynamic aspect ratio, resolution/quality, and duration pickers that adapt to each model's capabilities (including t2i models with resolution or quality options)
|
- **Smart Controls** — Dynamic aspect ratio, resolution/quality, and duration pickers that adapt to each model's capabilities (including t2i models with resolution or quality options)
|
||||||
- **Generation History** — Browse, revisit, and download all past generations (persisted in browser storage)
|
- **Generation History** — Browse, revisit, and download all past generations (persisted in browser storage)
|
||||||
|
|
@ -91,6 +255,7 @@ The Image Studio automatically switches between two model sets:
|
||||||
| **Nano Banana 2 Edit** | Image-to-Image | Up to **14 reference images** · Resolution 1K/2K/4K · Google Search enhancement |
|
| **Nano Banana 2 Edit** | Image-to-Image | Up to **14 reference images** · Resolution 1K/2K/4K · Google Search enhancement |
|
||||||
| **Seedream 5.0** | Text-to-Image | ByteDance · Quality basic/high · 8 aspect ratios · up to 4K |
|
| **Seedream 5.0** | Text-to-Image | ByteDance · Quality basic/high · 8 aspect ratios · up to 4K |
|
||||||
| **Seedream 5.0 Edit** | Image-to-Image | ByteDance · Natural language style transfer · Quality basic/high |
|
| **Seedream 5.0 Edit** | Image-to-Image | ByteDance · Natural language style transfer · Quality basic/high |
|
||||||
|
| **MiniMax Image 01** | Text-to-Image | MiniMax · 8 aspect ratios · up to 4 images per request · 1500 char prompt |
|
||||||
|
|
||||||
#### Multi-Image Input
|
#### Multi-Image Input
|
||||||
|
|
||||||
|
|
@ -137,6 +302,7 @@ The Video Studio follows the same pattern:
|
||||||
| **Seedance 2.0 Extend** | Video Extension | ByteDance · Seamlessly continue any Seedance 2.0 generation · Preserves style, motion & audio · Optional continuation prompt · Duration 5 / 10 / 15s · Quality basic/high |
|
| **Seedance 2.0 Extend** | Video Extension | ByteDance · Seamlessly continue any Seedance 2.0 generation · Preserves style, motion & audio · Optional continuation prompt · Duration 5 / 10 / 15s · Quality basic/high |
|
||||||
| **Grok Imagine T2V** | Text-to-Video | xAI · Duration 6 / 10 / **15s** · Modes: fun / normal / spicy · Aspect ratios 9:16 / 16:9 / 2:3 / 3:2 / 1:1 |
|
| **Grok Imagine T2V** | Text-to-Video | xAI · Duration 6 / 10 / **15s** · Modes: fun / normal / spicy · Aspect ratios 9:16 / 16:9 / 2:3 / 3:2 / 1:1 |
|
||||||
| **Grok Imagine I2V** | Image-to-Video | xAI · Duration 6 / 10 / **15s** · Modes: fun / normal / spicy · Cinematic motion from still images |
|
| **Grok Imagine I2V** | Image-to-Video | xAI · Duration 6 / 10 / **15s** · Modes: fun / normal / spicy · Cinematic motion from still images |
|
||||||
|
| **MiniMax Hailuo 02 / 2.3 Standard & Pro** | Text-to-Video / Image-to-Video | MiniMax · Full HD video · Multiple aspect ratios · Fast variant included |
|
||||||
|
|
||||||
### 🎙️ Lip Sync Studio
|
### 🎙️ Lip Sync Studio
|
||||||
|
|
||||||
|
|
@ -175,6 +341,20 @@ The **Lip Sync Studio** generates audio-driven talking videos using 9 models acr
|
||||||
|
|
||||||
Generation history is saved separately in `lipsync_history` and pending jobs resume automatically on page reload.
|
Generation history is saved separately in `lipsync_history` and pending jobs resume automatically on page reload.
|
||||||
|
|
||||||
|
### 🔀 Workflow Studio
|
||||||
|
|
||||||
|
The **Workflow Studio** lets you build and run multi-step AI pipelines without writing code.
|
||||||
|
|
||||||
|
**Key capabilities:**
|
||||||
|
- **Templates** — Start from pre-built workflows (image chains, video pipelines, and more)
|
||||||
|
- **My Workflows** — Save and manage your own custom pipelines
|
||||||
|
- **Community** — Browse and run workflows published by other users
|
||||||
|
- **Node-based Builder** — Drag-and-drop visual editor to connect models and route outputs between steps
|
||||||
|
- **Playground** — Run any workflow interactively with a form UI; results render inline
|
||||||
|
- **API execution** — Every workflow is also callable via the Muapi API
|
||||||
|
|
||||||
|
> 💡 **Want to add workflows to your own app?** Check out **[Vibe Workflow](https://github.com/SamurAIGPT/Vibe-Workflow)** — the open-source workflow engine powering this feature. Drop it into any project.
|
||||||
|
|
||||||
### 🎥 Cinema Studio Controls
|
### 🎥 Cinema Studio Controls
|
||||||
|
|
||||||
The **Cinema Studio** offers precise control over the virtual camera, translating your choices into optimized prompt modifiers:
|
The **Cinema Studio** offers precise control over the virtual camera, translating your choices into optimized prompt modifiers:
|
||||||
|
|
@ -207,19 +387,34 @@ Every image you upload is saved locally (URL + thumbnail) so you never upload th
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
|
> **Most users want the desktop app, not this dev path.** If you just want to run Open Generative AI on your machine, [download a prebuilt installer](#-download-desktop-app) instead — no Node.js required. The instructions below are for contributors building from source.
|
||||||
|
|
||||||
|
Pick the entry point that matches your goal:
|
||||||
|
|
||||||
|
- **Desktop app (Electron)** → `npm run electron:dev`
|
||||||
|
- **Hosted web version (Next.js)** → `npm run dev`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone the repository
|
# Clone the repository (with submodules — required for the workflow + agent packages)
|
||||||
git clone https://github.com/Anil-matcha/Open-Higgsfield-AI.git
|
git clone --recurse-submodules https://github.com/Anil-matcha/Open-Generative-AI.git
|
||||||
cd Open-Higgsfield-AI
|
cd Open-Generative-AI
|
||||||
|
|
||||||
# Install dependencies (installs root + packages/studio workspace)
|
# If you already cloned without --recurse-submodules, run this once:
|
||||||
npm install
|
# git submodule update --init --recursive
|
||||||
|
|
||||||
# Start the development server
|
# Install dependencies + build workspace packages (studio, workflow, agents).
|
||||||
npm run dev
|
# This step is REQUIRED — `npm install` alone is not enough; the workspaces
|
||||||
|
# need to be built before either dev script will work.
|
||||||
|
npm run setup
|
||||||
|
|
||||||
|
# Then start ONE of:
|
||||||
|
npm run electron:dev # Desktop app (Electron + Vite) — recommended
|
||||||
|
npm run dev # Hosted web version (Next.js) → http://localhost:3000
|
||||||
```
|
```
|
||||||
|
|
||||||
Open `http://localhost:3000` in your browser. You'll be prompted to enter your Muapi API key on first use.
|
You'll be prompted to enter your Muapi API key on first use (skip the key if you only plan to use local models).
|
||||||
|
|
||||||
|
> **Troubleshooting — `Couldn't find a 'pages' directory`**: this means Next.js can't see the `app/` folder. Confirm you're running `npm run dev` from the repo root (the directory that contains `app/`, `package.json`, and `next.config.mjs`), and that you cloned with submodules. Re-run `npm run setup` if `packages/Vibe-Workflow` or `packages/Open-Poe-AI` are empty.
|
||||||
|
|
||||||
### Production Build
|
### Production Build
|
||||||
|
|
||||||
|
|
@ -239,18 +434,21 @@ npm run electron:build
|
||||||
# Windows (NSIS installer — x64 + ARM64)
|
# Windows (NSIS installer — x64 + ARM64)
|
||||||
npm run electron:build:win
|
npm run electron:build:win
|
||||||
|
|
||||||
|
# Linux (AppImage + DEB — x64)
|
||||||
|
npm run electron:build:linux
|
||||||
|
|
||||||
# Both platforms in one pass
|
# Both platforms in one pass
|
||||||
npm run electron:build:all
|
npm run electron:build:all
|
||||||
```
|
```
|
||||||
|
|
||||||
Installers are output to the `release/` folder. Pre-built binaries are also available on the [Releases page](https://github.com/Anil-matcha/Open-Higgsfield-AI/releases).
|
Installers are output to the `release/` folder. Pre-built binaries are also available on the [Releases page](https://github.com/Anil-matcha/Open-Generative-AI/releases).
|
||||||
|
|
||||||
## 🏗️ Architecture
|
## 🏗️ Architecture
|
||||||
|
|
||||||
The app is a **Next.js monorepo** with a shared `packages/studio` component library.
|
The app is a **Next.js monorepo** with a shared `packages/studio` component library.
|
||||||
|
|
||||||
```
|
```
|
||||||
Open-Higgsfield-AI/
|
Open-Generative-AI/
|
||||||
├── app/ # Next.js App Router
|
├── app/ # Next.js App Router
|
||||||
│ ├── layout.js # Root layout (Tailwind, fonts)
|
│ ├── layout.js # Root layout (Tailwind, fonts)
|
||||||
│ ├── page.js # Redirects → /studio
|
│ ├── page.js # Redirects → /studio
|
||||||
|
|
@ -262,14 +460,15 @@ Open-Higgsfield-AI/
|
||||||
├── packages/
|
├── packages/
|
||||||
│ └── studio/ # Shared React component library
|
│ └── studio/ # Shared React component library
|
||||||
│ └── src/
|
│ └── src/
|
||||||
│ ├── index.js # Exports: ImageStudio, VideoStudio, LipSyncStudio, CinemaStudio
|
│ ├── index.js # Exports: ImageStudio, VideoStudio, LipSyncStudio, CinemaStudio, WorkflowStudio
|
||||||
│ ├── models.js # 200+ model definitions (single source of truth)
|
│ ├── models.js # 200+ model definitions (single source of truth)
|
||||||
│ ├── muapi.js # API client (named exports, apiKey as first param)
|
│ ├── muapi.js # API client (named exports, apiKey as first param)
|
||||||
│ └── components/
|
│ └── components/
|
||||||
│ ├── ImageStudio.jsx # Dual-mode t2i/i2i studio
|
│ ├── ImageStudio.jsx # Dual-mode t2i/i2i studio
|
||||||
│ ├── VideoStudio.jsx # Dual-mode t2v/i2v studio
|
│ ├── VideoStudio.jsx # Dual-mode t2v/i2v studio
|
||||||
│ ├── LipSyncStudio.jsx # Portrait/video + audio → talking video
|
│ ├── LipSyncStudio.jsx # Portrait/video + audio → talking video
|
||||||
│ └── CinemaStudio.jsx # Pro studio with camera controls
|
│ ├── CinemaStudio.jsx # Pro studio with camera controls
|
||||||
|
│ └── WorkflowStudio.jsx # Multi-step pipeline builder & playground
|
||||||
├── next.config.mjs # transpilePackages: ['studio']
|
├── next.config.mjs # transpilePackages: ['studio']
|
||||||
├── tailwind.config.js
|
├── tailwind.config.js
|
||||||
└── package.json # workspaces: ["packages/studio"]
|
└── package.json # workspaces: ["packages/studio"]
|
||||||
|
|
@ -308,16 +507,19 @@ Lip sync jobs use the same two-step pattern: a dedicated `processLipSync()` meth
|
||||||
- **npm workspaces** — Monorepo with shared `packages/studio` library
|
- **npm workspaces** — Monorepo with shared `packages/studio` library
|
||||||
- **Muapi.ai** — AI model API gateway
|
- **Muapi.ai** — AI model API gateway
|
||||||
|
|
||||||
## 🤔 How is this different from Higgsfield AI?
|
## 🤔 How is this different from Higgsfield AI, Freepik, Krea, Openart AI?
|
||||||
|
|
||||||
Higgsfield AI is a proprietary AI video and image generation platform. **Open Higgsfield AI** is a community-driven, open-source alternative that provides similar creative capabilities without the closed ecosystem:
|
**Open Generative AI** is a community-driven, open-source alternative that provides similar creative capabilities without the closed ecosystem:
|
||||||
|
|
||||||
| | Higgsfield AI | Open Higgsfield AI |
|
| | Other providers | Open Generative AI |
|
||||||
| :--- | :--- | :--- |
|
| :--- | :--- | :--- |
|
||||||
| **Cost** | Subscription-based | Free (open-source) |
|
| **Cost** | Subscription-based | Free (open-source) |
|
||||||
|
| **Content filters** | Yes — prompts blocked or altered | None — fully uncensored |
|
||||||
|
| **Restrictions** | Platform guardrails enforced | Unrestricted creative freedom |
|
||||||
| **Models** | Proprietary | 200+ open & commercial models |
|
| **Models** | Proprietary | 200+ open & commercial models |
|
||||||
| **Multi-image input** | Limited | Up to 14 images per request |
|
| **Multi-image input** | Limited | Up to 14 images per request |
|
||||||
| **Lip sync** | No | 9 models, image & video modes |
|
| **Lip sync** | No | 9 models, image & video modes |
|
||||||
|
| **Hosted version** | Subscription | Free at [muapi.ai/open-generative-ai](https://muapi.ai/open-generative-ai) |
|
||||||
| **Self-hosting** | No | Yes |
|
| **Self-hosting** | No | Yes |
|
||||||
| **Customizable** | No | Fully hackable |
|
| **Customizable** | No | Fully hackable |
|
||||||
| **Data privacy** | Cloud-based | Your data stays local |
|
| **Data privacy** | Cloud-based | Your data stays local |
|
||||||
|
|
@ -332,7 +534,9 @@ MIT
|
||||||
Built with [Muapi.ai](https://muapi.ai) — the unified API for AI image and video generation models.
|
Built with [Muapi.ai](https://muapi.ai) — the unified API for AI image and video generation models.
|
||||||
|
|
||||||
---
|
---
|
||||||
**Deep Dive**: For more details on the "AI Influencer" engine, upcoming "Popcorn" storyboarding features, and the future of this project, read the [full technical overview](https://medium.com/@anilmatcha/building-open-higgsfield-ai-an-open-source-ai-cinema-studio-83c1e0a2a5f1).
|
**Deep Dive**: For more details on the "AI Influencer" engine, upcoming "Popcorn" storyboarding features, and the future of this project, read the [full technical overview](https://medium.com/@anilmatcha/).
|
||||||
|
|
||||||
---
|
---
|
||||||
*Looking for a free Higgsfield AI alternative? Open Higgsfield AI is an open-source AI image and video generation studio and Higgsfield AI replacement that you can self-host, customize, and extend.*
|
*Looking for a free, uncensored Higgsfield AI, Freepik, Krea, Openart AI alternative? Open Generative AI is an open-source, unrestricted AI image and video generation studio — a Higgsfield AI, Freepik, Krea, Openart AI replacement with no content filters that you can self-host, customize, and extend.*
|
||||||
|
|
||||||
|
This project is an independent, experimental, and open-source initiative and is not affiliated with, endorsed by, or associated with Higgsfield Inc., Freepik, Krea AI, OpenArt AI, or any of their respective companies, products, or services. Any references to third-party platforms, models, or technologies are made solely for interoperability, benchmarking, research, or educational purposes. All trademarks, logos, and brand names are the property of their respective owners. If any content in this repository creates confusion or raises concerns, please contact us and we will promptly review and address it.
|
||||||
|
|
|
||||||
29
afterPack.js
29
afterPack.js
|
|
@ -1,8 +1,35 @@
|
||||||
import { execSync } from 'child_process';
|
import { execSync } from 'child_process';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
export default async function afterPack({ appOutDir, packager }) {
|
export default async function afterPack({ appOutDir, packager }) {
|
||||||
if (packager.platform.name !== 'mac') return;
|
const platformName = packager.platform.name;
|
||||||
|
|
||||||
|
// Remove Next.js SWC native binaries that don't belong on this target platform.
|
||||||
|
// They are bundled because `next` is in dependencies, but only the host-platform
|
||||||
|
// binary is ever used at runtime in the Electron app.
|
||||||
|
const nextDir = path.join(appOutDir,
|
||||||
|
platformName === 'mac'
|
||||||
|
? `${packager.appInfo.productName}.app/Contents/Resources`
|
||||||
|
: 'resources',
|
||||||
|
'app.asar.unpacked/node_modules/@next'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (fs.existsSync(nextDir)) {
|
||||||
|
const keepPrefix = platformName === 'mac' ? 'swc-darwin'
|
||||||
|
: platformName === 'windows' ? 'swc-win32'
|
||||||
|
: 'swc-linux';
|
||||||
|
|
||||||
|
for (const entry of fs.readdirSync(nextDir)) {
|
||||||
|
if (entry.startsWith('swc-') && !entry.startsWith(keepPrefix)) {
|
||||||
|
const fullPath = path.join(nextDir, entry);
|
||||||
|
fs.rmSync(fullPath, { recursive: true, force: true });
|
||||||
|
console.log(` • removed foreign SWC binary path=${fullPath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platformName !== 'mac') return;
|
||||||
|
|
||||||
const appPath = path.join(appOutDir, `${packager.appInfo.productName}.app`);
|
const appPath = path.join(appOutDir, `${packager.appInfo.productName}.app`);
|
||||||
console.log(` • ad-hoc signing path=${appPath}`);
|
console.log(` • ad-hoc signing path=${appPath}`);
|
||||||
|
|
|
||||||
83
app/agents/[agent_id]/AgentChatClient.js
Normal file
83
app/agents/[agent_id]/AgentChatClient.js
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AiAgent } from "ai-agent";
|
||||||
|
import "ai-agent/dist/tailwind.css";
|
||||||
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "muapi_key";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AgentChatClient — mirrors muapiapp's AgentClient.js.
|
||||||
|
* Renders the AiAgent library component with server-fetched agent details
|
||||||
|
* and optional initial history.
|
||||||
|
*
|
||||||
|
* IMPORTANT: StandaloneShell is NOT in the tree on /agents/* pages, so we
|
||||||
|
* must set up our own axios interceptor here to inject the API key into
|
||||||
|
* all requests made by the AiAgent library.
|
||||||
|
*/
|
||||||
|
export default function AgentChatClient({ agentDetails, initialHistory, userData }) {
|
||||||
|
const interceptorRef = useRef(null);
|
||||||
|
|
||||||
|
console.log("[AgentChatClient] Rendering", {
|
||||||
|
hasAgentDetails: !!agentDetails,
|
||||||
|
hasHistory: !!initialHistory,
|
||||||
|
hasUserData: !!userData
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const getKey = () => {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
const fromStorage = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (fromStorage) return fromStorage;
|
||||||
|
const match = document.cookie.match(/muapi_key=([^;]+)/);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const apiKey = getKey();
|
||||||
|
if (!apiKey) return;
|
||||||
|
|
||||||
|
interceptorRef.current = axios.interceptors.request.use((config) => {
|
||||||
|
const isRelative =
|
||||||
|
config.url.startsWith("/") || !config.url.startsWith("http");
|
||||||
|
// Include specific proxy paths to be sure
|
||||||
|
const isInternalProxy = config.url.includes('/api/app') || config.url.includes('/api/workflow') || config.url.includes('/api/agents') || config.url.includes('/api/api') || config.url.includes('/api/v1');
|
||||||
|
|
||||||
|
if (isRelative || isInternalProxy) {
|
||||||
|
config.headers["x-api-key"] = apiKey;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (interceptorRef.current !== null) {
|
||||||
|
axios.interceptors.request.eject(interceptorRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const useUser = useCallback(
|
||||||
|
() => ({
|
||||||
|
user: {
|
||||||
|
username: userData?.email?.split("@")[0] || "Studio User",
|
||||||
|
name: userData?.email?.split("@")[0] || "Studio User",
|
||||||
|
email: userData?.email || null,
|
||||||
|
profile_photo: null,
|
||||||
|
balance: userData?.balance || 0,
|
||||||
|
},
|
||||||
|
isAuthorized: !!userData,
|
||||||
|
}),
|
||||||
|
[userData]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-screen w-full bg-black">
|
||||||
|
<AiAgent
|
||||||
|
initialAgentDetails={agentDetails}
|
||||||
|
initialHistory={initialHistory}
|
||||||
|
useUser={useUser}
|
||||||
|
usedIn="muapiapp"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
app/agents/[agent_id]/[conversation_id]/page.js
Normal file
111
app/agents/[agent_id]/[conversation_id]/page.js
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import AgentChatClient from "../AgentChatClient";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server component — fetches both agentDetails and initialHistory
|
||||||
|
* from the /api/agents proxy using the muapi_key cookie, then renders
|
||||||
|
* the client chat component with existing conversation messages pre-loaded.
|
||||||
|
*
|
||||||
|
* URL: /agents/[agent_id]/[conversation_id]
|
||||||
|
*/
|
||||||
|
export async function generateMetadata({ params }) {
|
||||||
|
return {
|
||||||
|
title: `Agent Chat — Open Generative AI`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE_URL = 'https://api.muapi.ai';
|
||||||
|
|
||||||
|
async function fetchAgentDetails(agentId, apiKey) {
|
||||||
|
if (!apiKey) return null;
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${BASE_URL}/agents/by-slug/${agentId}`,
|
||||||
|
{
|
||||||
|
cache: "no-store",
|
||||||
|
headers: { "x-api-key": apiKey },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (res.ok) return await res.json();
|
||||||
|
|
||||||
|
if (agentId.length > 20) {
|
||||||
|
const resId = await fetch(
|
||||||
|
`${BASE_URL}/agents/${agentId}`,
|
||||||
|
{
|
||||||
|
cache: "no-store",
|
||||||
|
headers: { "x-api-key": apiKey },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (resId.ok) return await resId.json();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchHistory(agentId, conversationId, apiKey) {
|
||||||
|
if (!apiKey) return null;
|
||||||
|
try {
|
||||||
|
// Try by slug first
|
||||||
|
const res = await fetch(
|
||||||
|
`${BASE_URL}/agents/by-slug/${agentId}/${conversationId}`,
|
||||||
|
{
|
||||||
|
cache: "no-store",
|
||||||
|
headers: { "x-api-key": apiKey },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (res.ok) return await res.json();
|
||||||
|
|
||||||
|
// Fallback to direct agent ID if needed
|
||||||
|
if (agentId.length > 20) {
|
||||||
|
const resId = await fetch(
|
||||||
|
`${BASE_URL}/agents/${agentId}/${conversationId}`,
|
||||||
|
{
|
||||||
|
cache: "no-store",
|
||||||
|
headers: { "x-api-key": apiKey },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (resId.ok) return await resId.json();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchUserData(apiKey) {
|
||||||
|
if (!apiKey) return null;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BASE_URL}/api/v1/account/balance`, {
|
||||||
|
cache: "no-store",
|
||||||
|
headers: { "x-api-key": apiKey },
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return await res.json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AgentConversationPage({ params }) {
|
||||||
|
const { agent_id, conversation_id } = await params;
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const apiKey = cookieStore.get("muapi_key")?.value;
|
||||||
|
|
||||||
|
console.log(`[ConvPage] Loading for agent: ${agent_id}, conv: ${conversation_id}, hasKey: ${!!apiKey}`);
|
||||||
|
|
||||||
|
const [agentDetails, initialHistory, userData] = await Promise.all([
|
||||||
|
fetchAgentDetails(agent_id, apiKey),
|
||||||
|
fetchHistory(agent_id, conversation_id, apiKey),
|
||||||
|
fetchUserData(apiKey)
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AgentChatClient
|
||||||
|
agentDetails={agentDetails}
|
||||||
|
initialHistory={initialHistory}
|
||||||
|
userData={userData}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
app/agents/[agent_id]/page.js
Normal file
89
app/agents/[agent_id]/page.js
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import AgentChatClient from "./AgentChatClient";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server component — fetches agentDetails from the /api/agents proxy
|
||||||
|
* (which forwards to https://api.muapi.ai/agents/by-slug/{id})
|
||||||
|
* using the muapi_key cookie for auth, then renders the client chat component.
|
||||||
|
*
|
||||||
|
* URL: /agents/[agent_id] (new chat — no conversation ID yet)
|
||||||
|
*/
|
||||||
|
export async function generateMetadata({ params }) {
|
||||||
|
const { agent_id } = await params;
|
||||||
|
return {
|
||||||
|
title: `Agent Chat — Open Generative AI`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE_URL = 'https://api.muapi.ai';
|
||||||
|
|
||||||
|
async function fetchAgentDetails(agentId, apiKey) {
|
||||||
|
if (!apiKey) return null;
|
||||||
|
|
||||||
|
// Try fetching by slug first
|
||||||
|
try {
|
||||||
|
console.log(`[AgentPage] Fetching agent by slug: ${agentId}`);
|
||||||
|
const res = await fetch(
|
||||||
|
`${BASE_URL}/agents/by-slug/${agentId}`,
|
||||||
|
{
|
||||||
|
cache: "no-store",
|
||||||
|
headers: { "x-api-key": apiKey },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (res.ok) return await res.json();
|
||||||
|
|
||||||
|
// If by-slug fails, try fetching by direct ID (if it looks like a UUID)
|
||||||
|
if (agentId.length > 20) {
|
||||||
|
console.log(`[AgentPage] Fetch by slug failed, trying by ID: ${agentId}`);
|
||||||
|
const resId = await fetch(
|
||||||
|
`${BASE_URL}/agents/${agentId}`,
|
||||||
|
{
|
||||||
|
cache: "no-store",
|
||||||
|
headers: { "x-api-key": apiKey },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (resId.ok) return await resId.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn(`[AgentPage] Failed to fetch agent details for: ${agentId}`);
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[AgentPage] Fetch error:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchUserData(apiKey) {
|
||||||
|
if (!apiKey) return null;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BASE_URL}/api/v1/account/balance`, {
|
||||||
|
cache: "no-store",
|
||||||
|
headers: { "x-api-key": apiKey },
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return await res.json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AgentPage({ params }) {
|
||||||
|
const { agent_id } = await params;
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const apiKey = cookieStore.get("muapi_key")?.value;
|
||||||
|
|
||||||
|
console.log(`[AgentPage] Loading page for agent: ${agent_id}, hasKey: ${!!apiKey}`);
|
||||||
|
|
||||||
|
const [agentDetails, userData] = await Promise.all([
|
||||||
|
fetchAgentDetails(agent_id, apiKey),
|
||||||
|
fetchUserData(apiKey)
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AgentChatClient
|
||||||
|
agentDetails={agentDetails}
|
||||||
|
initialHistory={null}
|
||||||
|
userData={userData}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
app/agents/create/AgentCreateClient.js
Normal file
62
app/agents/create/AgentCreateClient.js
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { CreateAgentPage } from "ai-agent";
|
||||||
|
import "ai-agent/dist/tailwind.css";
|
||||||
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "muapi_key";
|
||||||
|
|
||||||
|
export default function AgentCreateClient({ userData }) {
|
||||||
|
const interceptorRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const getKey = () => {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
const fromStorage = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (fromStorage) return fromStorage;
|
||||||
|
const match = document.cookie.match(/muapi_key=([^;]+)/);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const apiKey = getKey();
|
||||||
|
if (!apiKey) return;
|
||||||
|
|
||||||
|
interceptorRef.current = axios.interceptors.request.use((config) => {
|
||||||
|
const isRelative = config.url.startsWith("/") || !config.url.startsWith("http");
|
||||||
|
const isInternalProxy = config.url.includes('/api/app') || config.url.includes('/api/workflow') || config.url.includes('/api/agents') || config.url.includes('/api/api') || config.url.includes('/api/v1');
|
||||||
|
|
||||||
|
if (isRelative || isInternalProxy) {
|
||||||
|
config.headers["x-api-key"] = apiKey;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (interceptorRef.current !== null) {
|
||||||
|
axios.interceptors.request.eject(interceptorRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const useUser = useCallback(
|
||||||
|
() => ({
|
||||||
|
user: {
|
||||||
|
username: userData?.email?.split("@")[0] || "Studio User",
|
||||||
|
name: userData?.email?.split("@")[0] || "Studio User",
|
||||||
|
email: userData?.email || null,
|
||||||
|
profile_photo: null,
|
||||||
|
balance: userData?.balance || 0,
|
||||||
|
},
|
||||||
|
isAuthorized: !!userData,
|
||||||
|
}),
|
||||||
|
[userData]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CreateAgentPage
|
||||||
|
useUser={useUser}
|
||||||
|
usedIn="studio"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
app/agents/create/page.js
Normal file
29
app/agents/create/page.js
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import AgentCreateClient from "./AgentCreateClient";
|
||||||
|
|
||||||
|
const BASE_URL = 'https://api.muapi.ai';
|
||||||
|
|
||||||
|
async function fetchUserData(apiKey) {
|
||||||
|
if (!apiKey) return null;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BASE_URL}/api/v1/account/balance`, {
|
||||||
|
cache: "no-store",
|
||||||
|
headers: { "x-api-key": apiKey },
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return await res.json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function CreateAgentPage() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const apiKey = cookieStore.get("muapi_key")?.value;
|
||||||
|
|
||||||
|
const userData = await fetchUserData(apiKey);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AgentCreateClient userData={userData} />
|
||||||
|
);
|
||||||
|
}
|
||||||
62
app/agents/edit/[id]/AgentEditClient.js
Normal file
62
app/agents/edit/[id]/AgentEditClient.js
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { EditAgentPage } from "ai-agent";
|
||||||
|
import "ai-agent/dist/tailwind.css";
|
||||||
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "muapi_key";
|
||||||
|
|
||||||
|
export default function AgentEditClient({ userData }) {
|
||||||
|
const interceptorRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const getKey = () => {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
const fromStorage = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (fromStorage) return fromStorage;
|
||||||
|
const match = document.cookie.match(/muapi_key=([^;]+)/);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const apiKey = getKey();
|
||||||
|
if (!apiKey) return;
|
||||||
|
|
||||||
|
interceptorRef.current = axios.interceptors.request.use((config) => {
|
||||||
|
const isRelative = config.url.startsWith("/") || !config.url.startsWith("http");
|
||||||
|
const isInternalProxy = config.url.includes('/api/app') || config.url.includes('/api/workflow') || config.url.includes('/api/agents') || config.url.includes('/api/api') || config.url.includes('/api/v1');
|
||||||
|
|
||||||
|
if (isRelative || isInternalProxy) {
|
||||||
|
config.headers["x-api-key"] = apiKey;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (interceptorRef.current !== null) {
|
||||||
|
axios.interceptors.request.eject(interceptorRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const useUser = useCallback(
|
||||||
|
() => ({
|
||||||
|
user: {
|
||||||
|
username: userData?.email?.split("@")[0] || "Studio User",
|
||||||
|
name: userData?.email?.split("@")[0] || "Studio User",
|
||||||
|
email: userData?.email || null,
|
||||||
|
profile_photo: null,
|
||||||
|
balance: userData?.balance || 0,
|
||||||
|
},
|
||||||
|
isAuthorized: !!userData,
|
||||||
|
}),
|
||||||
|
[userData]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EditAgentPage
|
||||||
|
useUser={useUser}
|
||||||
|
usedIn="studio"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
app/agents/edit/[id]/page.js
Normal file
30
app/agents/edit/[id]/page.js
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import AgentEditClient from "./AgentEditClient";
|
||||||
|
|
||||||
|
const BASE_URL = 'https://api.muapi.ai';
|
||||||
|
|
||||||
|
async function fetchUserData(apiKey) {
|
||||||
|
if (!apiKey) return null;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BASE_URL}/api/v1/account/balance`, {
|
||||||
|
cache: "no-store",
|
||||||
|
headers: { "x-api-key": apiKey },
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return await res.json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function EditAgentPage({ params }) {
|
||||||
|
const { id } = await params; // although we don't use id on server here, it's used by useParams in client
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const apiKey = cookieStore.get("muapi_key")?.value;
|
||||||
|
|
||||||
|
const userData = await fetchUserData(apiKey);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AgentEditClient userData={userData} />
|
||||||
|
);
|
||||||
|
}
|
||||||
16
app/agents/layout.js
Normal file
16
app/agents/layout.js
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
/**
|
||||||
|
* Layout for /agents/* pages.
|
||||||
|
* These pages host the AiAgent component full-screen — no studio chrome needed.
|
||||||
|
* The api key is available via the muapi_key cookie which StandaloneShell sets.
|
||||||
|
*/
|
||||||
|
export const metadata = {
|
||||||
|
title: "Agent Chat — Open Generative AI",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AgentsLayout({ children }) {
|
||||||
|
return (
|
||||||
|
<div className="h-screen w-full overflow-hidden bg-black">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
app/api/agents/[[...path]]/route.js
Normal file
110
app/api/agents/[[...path]]/route.js
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
const MUAPI_BASE = 'https://api.muapi.ai';
|
||||||
|
|
||||||
|
function getApiKey(request) {
|
||||||
|
// Priority 1: Direct x-api-key header
|
||||||
|
const headerKey = request.headers.get('x-api-key');
|
||||||
|
if (headerKey) return headerKey;
|
||||||
|
|
||||||
|
// Priority 2: muapi_key cookie
|
||||||
|
const cookieKey = request.cookies.get('muapi_key')?.value;
|
||||||
|
return cookieKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanHeaders(request) {
|
||||||
|
const headers = new Headers(request.headers);
|
||||||
|
headers.delete('host');
|
||||||
|
headers.delete('connection');
|
||||||
|
headers.delete('cookie'); // CRITICAL: Stop forwarding browser cookies to MuAPI
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the target URL without a trailing slash when path is empty.
|
||||||
|
// e.g. GET /api/agents?is_template=true → https://api.muapi.ai/agents?is_template=true
|
||||||
|
// e.g. GET /api/agents/by-slug/foo → https://api.muapi.ai/agents/by-slug/foo
|
||||||
|
function buildTargetUrl(pathSegments, search) {
|
||||||
|
const path = pathSegments.join('/');
|
||||||
|
const base = `${MUAPI_BASE}/agents`;
|
||||||
|
return path ? `${base}/${path}${search}` : `${base}${search}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request, { params }) {
|
||||||
|
const slug = await params;
|
||||||
|
const pathSegments = slug.path || [];
|
||||||
|
const { search } = new URL(request.url);
|
||||||
|
const targetUrl = buildTargetUrl(pathSegments, search);
|
||||||
|
|
||||||
|
const headers = cleanHeaders(request);
|
||||||
|
const apiKey = getApiKey(request);
|
||||||
|
console.log(`[agents proxy GET] ${targetUrl} | apiKey: ${apiKey ? apiKey.slice(0,8)+'...' : 'MISSING'}`);
|
||||||
|
if (apiKey) headers.set('x-api-key', apiKey);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(targetUrl, { headers, method: 'GET' });
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data, { status: response.status });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request, { params }) {
|
||||||
|
const slug = await params;
|
||||||
|
const pathSegments = slug.path || [];
|
||||||
|
const { search } = new URL(request.url);
|
||||||
|
const targetUrl = buildTargetUrl(pathSegments, search);
|
||||||
|
|
||||||
|
const headers = cleanHeaders(request);
|
||||||
|
const apiKey = getApiKey(request);
|
||||||
|
console.log(`[agents proxy POST] ${targetUrl} | apiKey: ${apiKey ? apiKey.slice(0,8)+'...' : 'MISSING'}`);
|
||||||
|
if (apiKey) headers.set('x-api-key', apiKey);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.arrayBuffer();
|
||||||
|
const response = await fetch(targetUrl, { method: 'POST', headers, body });
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data, { status: response.status });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request, { params }) {
|
||||||
|
const slug = await params;
|
||||||
|
const pathSegments = slug.path || [];
|
||||||
|
const { search } = new URL(request.url);
|
||||||
|
const targetUrl = buildTargetUrl(pathSegments, search);
|
||||||
|
|
||||||
|
const headers = cleanHeaders(request);
|
||||||
|
const apiKey = getApiKey(request);
|
||||||
|
if (apiKey) headers.set('x-api-key', apiKey);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(targetUrl, { method: 'DELETE', headers });
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data, { status: response.status });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request, { params }) {
|
||||||
|
const slug = await params;
|
||||||
|
const pathSegments = slug.path || [];
|
||||||
|
const { search } = new URL(request.url);
|
||||||
|
const targetUrl = buildTargetUrl(pathSegments, search);
|
||||||
|
|
||||||
|
const headers = cleanHeaders(request);
|
||||||
|
const apiKey = getApiKey(request);
|
||||||
|
if (apiKey) headers.set('x-api-key', apiKey);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.arrayBuffer();
|
||||||
|
const response = await fetch(targetUrl, { method: 'PUT', headers, body });
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data, { status: response.status });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
65
app/api/api/v1/[[...path]]/route.js
Normal file
65
app/api/api/v1/[[...path]]/route.js
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
const MUAPI_BASE = 'https://api.muapi.ai';
|
||||||
|
|
||||||
|
function getApiKey(request) {
|
||||||
|
const headerKey = request.headers.get('x-api-key');
|
||||||
|
if (headerKey) return headerKey;
|
||||||
|
const cookieKey = request.cookies.get('muapi_key')?.value;
|
||||||
|
return cookieKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanHeaders(request) {
|
||||||
|
const headers = new Headers(request.headers);
|
||||||
|
headers.delete('host');
|
||||||
|
headers.delete('connection');
|
||||||
|
headers.delete('cookie');
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proxies /api/api/v1/* -> https://api.muapi.ai/api/v1/*
|
||||||
|
// This is required because the AiAgent library hardcodes a double /api/api
|
||||||
|
export async function GET(request, { params }) {
|
||||||
|
const slug = await params;
|
||||||
|
const pathSegments = slug.path || [];
|
||||||
|
const path = pathSegments.join('/');
|
||||||
|
|
||||||
|
const { search } = new URL(request.url);
|
||||||
|
const targetUrl = `${MUAPI_BASE}/api/v1/${path}${search}`;
|
||||||
|
|
||||||
|
const headers = cleanHeaders(request);
|
||||||
|
const apiKey = getApiKey(request);
|
||||||
|
|
||||||
|
console.log(`[double-api proxy GET] ${targetUrl} | apiKey: ${apiKey ? apiKey.slice(0,8)+'...' : 'MISSING'}`);
|
||||||
|
if (apiKey) headers.set('x-api-key', apiKey);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(targetUrl, { headers, method: 'GET' });
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data, { status: response.status });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request, { params }) {
|
||||||
|
const slug = await params;
|
||||||
|
const pathSegments = slug.path || [];
|
||||||
|
const path = pathSegments.join('/');
|
||||||
|
|
||||||
|
const { search } = new URL(request.url);
|
||||||
|
const targetUrl = `${MUAPI_BASE}/api/v1/${path}${search}`;
|
||||||
|
|
||||||
|
const headers = cleanHeaders(request);
|
||||||
|
const apiKey = getApiKey(request);
|
||||||
|
if (apiKey) headers.set('x-api-key', apiKey);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.arrayBuffer();
|
||||||
|
const response = await fetch(targetUrl, { method: 'POST', headers, body });
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data, { status: response.status });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
145
app/api/app/[[...path]]/route.js
Normal file
145
app/api/app/[[...path]]/route.js
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
const MUAPI_BASE = 'https://api.muapi.ai';
|
||||||
|
|
||||||
|
function getApiKey(request) {
|
||||||
|
// Priority 1: Direct x-api-key header
|
||||||
|
const headerKey = request.headers.get('x-api-key');
|
||||||
|
if (headerKey) return headerKey;
|
||||||
|
|
||||||
|
// Priority 2: muapi_key cookie (used by the fixed builder library)
|
||||||
|
const cookieKey = request.cookies.get('muapi_key')?.value;
|
||||||
|
return cookieKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanHeaders(request) {
|
||||||
|
const headers = new Headers(request.headers);
|
||||||
|
headers.delete('host');
|
||||||
|
headers.delete('connection');
|
||||||
|
headers.delete('cookie'); // CRITICAL: Stop forwarding browser cookies to MuAPI to avoid auth conflicts
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request, { params }) {
|
||||||
|
const slug = await params;
|
||||||
|
const pathSegments = slug.path || [];
|
||||||
|
const path = pathSegments.join('/');
|
||||||
|
|
||||||
|
// Handle alias: get_upload_file -> get_file_upload_url
|
||||||
|
const effectivePath = path === 'get_upload_file' ? 'get_file_upload_url' : path;
|
||||||
|
|
||||||
|
const { search } = new URL(request.url);
|
||||||
|
const targetUrl = `${MUAPI_BASE}/app/${effectivePath}${search}`;
|
||||||
|
|
||||||
|
const headers = cleanHeaders(request);
|
||||||
|
|
||||||
|
const apiKey = getApiKey(request);
|
||||||
|
if (apiKey) headers.set('x-api-key', apiKey);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(targetUrl, {
|
||||||
|
headers,
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// SPECIAL CASE: Intercept upload URL and redirect to local binary proxy
|
||||||
|
if (effectivePath === 'get_file_upload_url' && data.url) {
|
||||||
|
const originalS3Url = data.url;
|
||||||
|
// We pass the real S3 URL as a header to our proxy
|
||||||
|
data.url = `/api/upload-binary`;
|
||||||
|
|
||||||
|
// Store target in a temporary way?
|
||||||
|
// Better: Return the target URL as an extra field that our proxy will look for
|
||||||
|
data.fields = {
|
||||||
|
...data.fields,
|
||||||
|
'x-proxy-target-url': originalS3Url
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(data, { status: response.status });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request, { params }) {
|
||||||
|
const slug = await params;
|
||||||
|
const pathSegments = slug.path || [];
|
||||||
|
const path = pathSegments.join('/');
|
||||||
|
|
||||||
|
const { search } = new URL(request.url);
|
||||||
|
const targetUrl = `${MUAPI_BASE}/app/${path}${search}`;
|
||||||
|
|
||||||
|
const headers = cleanHeaders(request);
|
||||||
|
|
||||||
|
const apiKey = getApiKey(request);
|
||||||
|
if (apiKey) headers.set('x-api-key', apiKey);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.arrayBuffer();
|
||||||
|
const response = await fetch(targetUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data, { status: response.status });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request, { params }) {
|
||||||
|
const slug = await params;
|
||||||
|
const pathSegments = slug.path || [];
|
||||||
|
const path = pathSegments.join('/');
|
||||||
|
|
||||||
|
const { search } = new URL(request.url);
|
||||||
|
const targetUrl = `${MUAPI_BASE}/app/${path}${search}`;
|
||||||
|
|
||||||
|
const headers = cleanHeaders(request);
|
||||||
|
|
||||||
|
const apiKey = getApiKey(request);
|
||||||
|
if (apiKey) headers.set('x-api-key', apiKey);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(targetUrl, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data, { status: response.status });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request, { params }) {
|
||||||
|
const slug = await params;
|
||||||
|
const pathSegments = slug.path || [];
|
||||||
|
const path = pathSegments.join('/');
|
||||||
|
|
||||||
|
const { search } = new URL(request.url);
|
||||||
|
const targetUrl = `${MUAPI_BASE}/app/${path}${search}`;
|
||||||
|
|
||||||
|
const headers = cleanHeaders(request);
|
||||||
|
|
||||||
|
const apiKey = getApiKey(request);
|
||||||
|
if (apiKey) headers.set('x-api-key', apiKey);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.arrayBuffer();
|
||||||
|
const response = await fetch(targetUrl, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers,
|
||||||
|
body
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data, { status: response.status });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
44
app/api/upload-binary/route.js
Normal file
44
app/api/upload-binary/route.js
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function POST(request) {
|
||||||
|
try {
|
||||||
|
const formData = await request.formData();
|
||||||
|
|
||||||
|
// Extract the original S3 target URL we injected earlier
|
||||||
|
const targetUrl = formData.get('x-proxy-target-url');
|
||||||
|
|
||||||
|
if (!targetUrl) {
|
||||||
|
return NextResponse.json({ error: 'Missing proxy target URL' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconstruct the FormData for S3 (excluding our internal proxy marker)
|
||||||
|
const s3FormData = new FormData();
|
||||||
|
|
||||||
|
// S3 is very sensitive to field ordering. We must ensure 'file' is likely last
|
||||||
|
// or at least that all signature fields come before what S3 expects.
|
||||||
|
// The original library code appends 'file' last, so iterating should preserve that.
|
||||||
|
for (const [key, value] of formData.entries()) {
|
||||||
|
if (key !== 'x-proxy-target-url') {
|
||||||
|
s3FormData.append(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform the server-to-server POST to S3
|
||||||
|
// This bypasses browser CORS/Preflight security entirely
|
||||||
|
const s3Response = await fetch(targetUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
body: s3FormData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (s3Response.ok || s3Response.status === 204) {
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
} else {
|
||||||
|
const errorText = await s3Response.text();
|
||||||
|
console.error('S3 Proxy Error:', errorText);
|
||||||
|
return new Response(errorText, { status: s3Response.status });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Upload Proxy Exception:', error);
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
137
app/api/workflow/[[...path]]/route.js
Normal file
137
app/api/workflow/[[...path]]/route.js
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
const MUAPI_BASE = 'https://api.muapi.ai';
|
||||||
|
|
||||||
|
function getApiKey(request) {
|
||||||
|
// Priority 1: Direct x-api-key header
|
||||||
|
const headerKey = request.headers.get('x-api-key');
|
||||||
|
if (headerKey) return headerKey;
|
||||||
|
|
||||||
|
// Priority 2: muapi_key cookie (used by the fixed builder library)
|
||||||
|
const cookieKey = request.cookies.get('muapi_key')?.value;
|
||||||
|
return cookieKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanHeaders(request) {
|
||||||
|
const headers = new Headers(request.headers);
|
||||||
|
headers.delete('host');
|
||||||
|
headers.delete('connection');
|
||||||
|
headers.delete('cookie'); // CRITICAL: Stop forwarding browser cookies to MuAPI to avoid auth conflicts
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request, { params }) {
|
||||||
|
const slug = await params;
|
||||||
|
const pathSegments = slug.path || [];
|
||||||
|
const path = pathSegments.join('/');
|
||||||
|
|
||||||
|
const { search } = new URL(request.url);
|
||||||
|
const targetUrl = `${MUAPI_BASE}/workflow/${path}${search}`;
|
||||||
|
|
||||||
|
const headers = cleanHeaders(request);
|
||||||
|
|
||||||
|
const apiKey = getApiKey(request);
|
||||||
|
console.log(`[proxy GET] ${targetUrl} | apiKey: ${apiKey ? apiKey.slice(0,8)+'...' : 'MISSING'}`);
|
||||||
|
if (apiKey) headers.set('x-api-key', apiKey);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(targetUrl, {
|
||||||
|
headers,
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (path.includes('get-workflow-def')) {
|
||||||
|
console.log(`[proxy GET] get-workflow-def response: is_owner=${data?.is_owner}, workflow_id=${data?.workflow_id}`);
|
||||||
|
}
|
||||||
|
return NextResponse.json(data, { status: response.status });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request, { params }) {
|
||||||
|
const slug = await params;
|
||||||
|
const pathSegments = slug.path || [];
|
||||||
|
const path = pathSegments.join('/');
|
||||||
|
|
||||||
|
const { search } = new URL(request.url);
|
||||||
|
const targetUrl = `${MUAPI_BASE}/workflow/${path}${search}`;
|
||||||
|
|
||||||
|
const headers = cleanHeaders(request);
|
||||||
|
|
||||||
|
const apiKey = getApiKey(request);
|
||||||
|
console.log(`[proxy POST] ${targetUrl} | apiKey: ${apiKey ? apiKey.slice(0,8)+'...' : 'MISSING'} | cookie: ${request.cookies.get('muapi_key')?.value?.slice(0,8) || 'NONE'} | header: ${request.headers.get('x-api-key')?.slice(0,8) || 'NONE'}`);
|
||||||
|
if (apiKey) headers.set('x-api-key', apiKey);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.arrayBuffer();
|
||||||
|
// Decode body to see what workflow_id is being sent
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(Buffer.from(body).toString('utf-8'));
|
||||||
|
console.log(`[proxy POST] body: workflow_id=${parsed.workflow_id}, source_workflow_id=${parsed.source_workflow_id}, name=${parsed.name}`);
|
||||||
|
} catch(e) { /* ignore decode errors */ }
|
||||||
|
|
||||||
|
const response = await fetch(targetUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
console.log(`[proxy POST] response: status=${response.status}`, JSON.stringify(data).slice(0, 200));
|
||||||
|
return NextResponse.json(data, { status: response.status });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request, { params }) {
|
||||||
|
const slug = await params;
|
||||||
|
const pathSegments = slug.path || [];
|
||||||
|
const path = pathSegments.join('/');
|
||||||
|
|
||||||
|
const { search } = new URL(request.url);
|
||||||
|
const targetUrl = `${MUAPI_BASE}/workflow/${path}${search}`;
|
||||||
|
|
||||||
|
const headers = cleanHeaders(request);
|
||||||
|
|
||||||
|
const apiKey = getApiKey(request);
|
||||||
|
if (apiKey) headers.set('x-api-key', apiKey);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(targetUrl, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data, { status: response.status });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request, { params }) {
|
||||||
|
const slug = await params;
|
||||||
|
const pathSegments = slug.path || [];
|
||||||
|
const path = pathSegments.join('/');
|
||||||
|
|
||||||
|
const { search } = new URL(request.url);
|
||||||
|
const targetUrl = `${MUAPI_BASE}/workflow/${path}${search}`;
|
||||||
|
|
||||||
|
const headers = cleanHeaders(request);
|
||||||
|
|
||||||
|
const apiKey = getApiKey(request);
|
||||||
|
if (apiKey) headers.set('x-api-key', apiKey);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.arrayBuffer();
|
||||||
|
const response = await fetch(targetUrl, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers,
|
||||||
|
body
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data, { status: response.status });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,21 +2,32 @@
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap');
|
||||||
|
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: #050505;
|
background: #050505;
|
||||||
color: white;
|
color: white;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--color-primary: #d9ff00;
|
--color-primary: #d9ff00;
|
||||||
--bg-app: #050505;
|
--bg-app: #030303;
|
||||||
--bg-panel: #0a0a0a;
|
--bg-panel: #0a0a0a;
|
||||||
--bg-card: #111111;
|
--bg-card: #111111;
|
||||||
--border-color: rgba(255,255,255,0.08);
|
--border-color: rgba(255,255,255,0.05);
|
||||||
--border-radius-xl: 1rem;
|
--border-radius-xl: 0.75rem;
|
||||||
|
--glass-bg: rgba(255, 255, 255, 0.03);
|
||||||
|
--glass-border: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-panel {
|
||||||
|
background: var(--glass-bg);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
|
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
|
||||||
|
|
@ -24,7 +35,7 @@ body {
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 2px; }
|
.custom-scrollbar::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 2px; }
|
||||||
|
|
||||||
@keyframes fade-in-up {
|
@keyframes fade-in-up {
|
||||||
from { opacity: 0; transform: translateY(16px); }
|
from { opacity: 0; transform: translateY(12px); }
|
||||||
to { opacity: 1; transform: translateY(0); }
|
to { opacity: 1; transform: translateY(0); }
|
||||||
}
|
}
|
||||||
.animate-fade-in-up { animation: fade-in-up 0.4s ease forwards; }
|
.animate-fade-in-up { animation: fade-in-up 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards; }
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,20 @@
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
import { Inter } from "next/font/google";
|
||||||
|
|
||||||
|
const inter = Inter({
|
||||||
|
variable: "--font-inter",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'Open Higgsfield AI — Free AI Image & Video Studio',
|
title: 'Open Generative AI — Free AI Image & Video Studio',
|
||||||
description: 'Generate AI images and videos using 200+ models — Flux, Midjourney, Kling, Veo, Seedance and more. Free open-source alternative to Higgsfield AI.',
|
description: 'Generate AI images and videos using 200+ models — Flux, Midjourney, Kling, Veo, Seedance and more. Free open-source alternative to Higgsfield AI.',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({ children }) {
|
export default function RootLayout({ children }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body>{children}</body>
|
<body className={inter.variable}>{children}</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import StandaloneShell from '@/components/StandaloneShell';
|
import StandaloneShell from '@/components/StandaloneShell';
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'Studio — Open Higgsfield AI',
|
title: 'Studio — Open Generative AI',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function StudioPage() {
|
export default function StudioPage() {
|
||||||
9
app/workflow/[id]/[tab]/page.js
Normal file
9
app/workflow/[id]/[tab]/page.js
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import StandaloneShell from '@/components/StandaloneShell';
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: 'Workflow — Open Generative AI',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function WorkflowTabPage() {
|
||||||
|
return <StandaloneShell />;
|
||||||
|
}
|
||||||
9
app/workflow/[id]/page.js
Normal file
9
app/workflow/[id]/page.js
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import StandaloneShell from '@/components/StandaloneShell';
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: 'Workflow — Open Generative AI',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function WorkflowPage() {
|
||||||
|
return <StandaloneShell />;
|
||||||
|
}
|
||||||
7
build/installer.nsh
Normal file
7
build/installer.nsh
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
!macro customInit
|
||||||
|
; Override default installation directory to be directly in LocalAppData
|
||||||
|
StrCpy $INSTDIR "$LOCALAPPDATA\Open Generative AI"
|
||||||
|
|
||||||
|
; Forcefully kill the app if it's running to prevent installation failure
|
||||||
|
nsExec::ExecToStack 'taskkill /F /IM "Open Generative AI.exe"'
|
||||||
|
!macroend
|
||||||
7
build/linux/apparmor.profile
Normal file
7
build/linux/apparmor.profile
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
abi <abi/4.0>,
|
||||||
|
include <tunables/global>
|
||||||
|
|
||||||
|
profile open-generative-ai /opt/Open\ Generative\ AI/open-generative-ai flags=(unconfined) {
|
||||||
|
userns,
|
||||||
|
include if exists <local/open-generative-ai>
|
||||||
|
}
|
||||||
|
|
@ -14,48 +14,50 @@ export default function ApiKeyModal({ onSave }) {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#050505] flex items-center justify-center px-4">
|
<div className="min-h-screen bg-[#030303] flex items-center justify-center px-4 font-inter">
|
||||||
<div className="w-full max-w-md bg-[#0a0a0a] border border-white/10 rounded-3xl p-8">
|
<div className="w-full max-w-sm bg-[#0a0a0a]/40 backdrop-blur-xl border border-white/10 rounded-xl p-10 shadow-2xl">
|
||||||
<div className="flex flex-col items-center text-center mb-8">
|
<div className="flex flex-col items-center text-center mb-10">
|
||||||
<div className="w-16 h-16 bg-[#d9ff00]/10 rounded-2xl flex items-center justify-center border border-[#d9ff00]/20 mb-6">
|
<div className="w-14 h-14 bg-[#d9ff00]/5 rounded-2xl flex items-center justify-center border border-[#d9ff00]/10 mb-6 group hover:border-[#d9ff00]/30 transition-colors">
|
||||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#d9ff00" strokeWidth="1.5">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#d9ff00" strokeWidth="1.5" className="group-hover:scale-110 transition-transform">
|
||||||
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L12 17.25l-4.5-4.5L15.5 7.5z"/>
|
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L12 17.25l-4.5-4.5L15.5 7.5z" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-black text-white uppercase tracking-wider mb-2">
|
<h1 className="text-xl font-bold text-white tracking-tight mb-2">
|
||||||
Open Higgsfield AI
|
Open Generative AI
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-white/40 text-sm">
|
<p className="text-white/40 text-[13px] leading-relaxed px-4">
|
||||||
Enter your <a href="https://muapi.ai" target="_blank" rel="noreferrer" className="text-[#d9ff00] hover:underline">Muapi.ai</a> API key to start generating
|
Enter your <a href="https://muapi.ai/access-keys" target="_blank" rel="noreferrer" className="text-[#d9ff00] hover:text-[#e5ff33] transition-colors">Muapi.ai</a> API key to start creating
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<label className="block text-xs font-bold text-white/40 uppercase tracking-widest mb-2">
|
<label className="block text-xs font-bold text-white/30 ml-1">
|
||||||
Muapi API Key
|
API Access Key
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={key}
|
value={key}
|
||||||
onChange={(e) => { setKey(e.target.value); setError(''); }}
|
onChange={(e) => { setKey(e.target.value); setError(''); }}
|
||||||
placeholder="Enter your API key..."
|
placeholder="Paste your key here..."
|
||||||
className="w-full bg-black/40 border border-white/5 rounded-xl px-4 py-3 text-white placeholder:text-white/20 focus:outline-none focus:border-[#d9ff00]/40 transition-colors"
|
className="w-full bg-white/5 border border-white/[0.03] rounded-md px-5 py-3 text-sm text-white placeholder:text-white/10 focus:outline-none focus:ring-1 focus:ring-[#d9ff00]/30 focus:bg-white/[0.07] transition-all"
|
||||||
|
suppressHydrationWarning
|
||||||
/>
|
/>
|
||||||
{error && <p className="mt-1 text-red-400 text-xs">{error}</p>}
|
{error && <p className="mt-2 text-red-500/80 text-[11px] font-medium ml-1">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full bg-[#d9ff00] text-black font-black py-3 rounded-xl hover:opacity-90 transition-opacity"
|
className="w-full bg-[#d9ff00] text-black font-medium py-2.5 rounded-md hover:bg-[#e5ff33] hover:scale-[1.02] active:scale-[0.98] transition-all shadow-lg shadow-[#d9ff00]/5"
|
||||||
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
Launch Studio
|
Get Started
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p className="text-center text-xs text-white/30">
|
<p className="text-center text-[12px] text-white/20 pt-2">
|
||||||
Don't have a key?{' '}
|
Need a key?{' '}
|
||||||
<a href="https://muapi.ai" target="_blank" rel="noreferrer" className="text-[#d9ff00] hover:underline">
|
<a href="https://muapi.ai/access-keys" target="_blank" rel="noreferrer" className="text-white/40 hover:text-[#d9ff00] transition-colors font-medium">
|
||||||
Get one free at Muapi.ai →
|
Get one free →
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { ImageStudio, VideoStudio, LipSyncStudio, CinemaStudio } from 'studio';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
|
import { ImageStudio, VideoStudio, LipSyncStudio, CinemaStudio, MarketingStudio, WorkflowStudio, AgentStudio, AppsStudio, getUserBalance } from 'studio';
|
||||||
|
import axios from 'axios';
|
||||||
import ApiKeyModal from './ApiKeyModal';
|
import ApiKeyModal from './ApiKeyModal';
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
|
|
@ -9,96 +11,338 @@ const TABS = [
|
||||||
{ id: 'video', label: 'Video Studio' },
|
{ id: 'video', label: 'Video Studio' },
|
||||||
{ id: 'lipsync', label: 'Lip Sync' },
|
{ id: 'lipsync', label: 'Lip Sync' },
|
||||||
{ id: 'cinema', label: 'Cinema Studio' },
|
{ id: 'cinema', label: 'Cinema Studio' },
|
||||||
|
{ id: 'marketing', label: 'Marketing Studio' },
|
||||||
|
{ id: 'workflows', label: 'Workflows' },
|
||||||
|
{ id: 'agents', label: 'Agents' },
|
||||||
|
{ id: 'apps', label: 'Explore Apps' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const STORAGE_KEY = 'muapi_key';
|
const STORAGE_KEY = 'muapi_key';
|
||||||
|
|
||||||
export default function StandaloneShell() {
|
export default function StandaloneShell() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const slug = params?.slug || [];
|
||||||
|
const idFromParams = params?.id;
|
||||||
|
const tabFromParams = params?.tab;
|
||||||
|
|
||||||
|
// Helper to extract workflow details precisely from either route structure
|
||||||
|
const getWorkflowInfo = useCallback(() => {
|
||||||
|
if (idFromParams) {
|
||||||
|
return { id: idFromParams, tab: tabFromParams || null };
|
||||||
|
}
|
||||||
|
const wfIndex = slug.findIndex(s => s === 'workflows' || s === 'workflow');
|
||||||
|
if (wfIndex === -1) return { id: null, tab: null };
|
||||||
|
return {
|
||||||
|
id: slug[wfIndex + 1] || null,
|
||||||
|
tab: slug[wfIndex + 2] || null
|
||||||
|
};
|
||||||
|
}, [slug, idFromParams, tabFromParams]);
|
||||||
|
|
||||||
|
const { id: urlWorkflowId } = getWorkflowInfo();
|
||||||
|
|
||||||
|
// Initialize activeTab from URL slug/params or default to 'image'
|
||||||
|
const getInitialTab = () => {
|
||||||
|
if (idFromParams || slug.includes('workflow')) return 'workflows';
|
||||||
|
if (slug.includes('agents')) return 'agents';
|
||||||
|
if (slug.includes('apps')) return 'apps';
|
||||||
|
const firstSegment = slug[0];
|
||||||
|
if (firstSegment && TABS.find(t => t.id === firstSegment)) return firstSegment;
|
||||||
|
return 'image';
|
||||||
|
};
|
||||||
|
|
||||||
const [apiKey, setApiKey] = useState(null);
|
const [apiKey, setApiKey] = useState(null);
|
||||||
const [activeTab, setActiveTab] = useState('image');
|
const [activeTab, setActiveTab] = useState(getInitialTab());
|
||||||
|
|
||||||
|
const [balance, setBalance] = useState(null);
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
|
const [isHeaderVisible, setIsHeaderVisible] = useState(true);
|
||||||
|
const [hasMounted, setHasMounted] = useState(false);
|
||||||
|
|
||||||
|
// Drag and Drop State
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [droppedFiles, setDroppedFiles] = useState(null);
|
||||||
|
|
||||||
|
// Sync tab with URL if user navigates manually or via browser back/forward
|
||||||
|
useEffect(() => {
|
||||||
|
const info = getWorkflowInfo();
|
||||||
|
if (info.id) {
|
||||||
|
setActiveTab('workflows');
|
||||||
|
} else if (slug.includes('agents')) {
|
||||||
|
setActiveTab('agents');
|
||||||
|
} else if (slug.includes('apps')) {
|
||||||
|
setActiveTab('apps');
|
||||||
|
} else {
|
||||||
|
const firstSegment = slug[0];
|
||||||
|
if (firstSegment && TABS.find(t => t.id === firstSegment)) {
|
||||||
|
setActiveTab(firstSegment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [slug, getWorkflowInfo]);
|
||||||
|
|
||||||
|
const handleTabChange = (tabId) => {
|
||||||
|
setActiveTab(tabId);
|
||||||
|
router.push(`/studio/${tabId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-hide header when inside a specific workflow view
|
||||||
|
useEffect(() => {
|
||||||
|
const isEditingWorkflow = (activeTab === 'workflows' || !!idFromParams) && urlWorkflowId;
|
||||||
|
if (isEditingWorkflow) {
|
||||||
|
setIsHeaderVisible(false);
|
||||||
|
} else {
|
||||||
|
setIsHeaderVisible(true);
|
||||||
|
}
|
||||||
|
}, [activeTab, urlWorkflowId, idFromParams]);
|
||||||
|
|
||||||
|
// Global builder CSS cleanup when switching away from Workflows tab
|
||||||
|
useEffect(() => {
|
||||||
|
const fromBuilder = sessionStorage.getItem("fromWorkflowBuilder");
|
||||||
|
if (fromBuilder && activeTab !== 'workflows') {
|
||||||
|
sessionStorage.removeItem("fromWorkflowBuilder");
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
}, [activeTab]);
|
||||||
|
|
||||||
|
const fetchBalance = useCallback(async (key) => {
|
||||||
|
try {
|
||||||
|
const data = await getUserBalance(key);
|
||||||
|
setBalance(data.balance);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Balance fetch failed:', err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setHasMounted(true);
|
||||||
const stored = localStorage.getItem(STORAGE_KEY);
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
if (stored) setApiKey(stored);
|
if (stored) {
|
||||||
}, []);
|
setApiKey(stored);
|
||||||
|
fetchBalance(stored);
|
||||||
|
// Sync cookie immediately on mount to establish identity for background requests
|
||||||
|
document.cookie = `muapi_key=${stored}; path=/; max-age=31536000; SameSite=Lax`;
|
||||||
|
}
|
||||||
|
}, [fetchBalance]);
|
||||||
|
|
||||||
const handleKeySave = useCallback((key) => {
|
const handleKeySave = useCallback((key) => {
|
||||||
localStorage.setItem(STORAGE_KEY, key);
|
localStorage.setItem(STORAGE_KEY, key);
|
||||||
setApiKey(key);
|
setApiKey(key);
|
||||||
}, []);
|
fetchBalance(key);
|
||||||
|
document.cookie = `muapi_key=${key}; path=/; max-age=31536000; SameSite=Lax`;
|
||||||
|
}, [fetchBalance]);
|
||||||
|
|
||||||
const handleKeyChange = useCallback(() => {
|
const handleKeyChange = useCallback(() => {
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
setApiKey(null);
|
setApiKey(null);
|
||||||
|
setBalance(null);
|
||||||
|
document.cookie = "muapi_key=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT";
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Inject API key into all outgoing Axios requests (prop-based approach)
|
||||||
|
// We use an interceptor to be selective and NOT send the key to external domains like S3
|
||||||
|
useEffect(() => {
|
||||||
|
// Safety: Clear any global defaults that might have been set previously
|
||||||
|
delete axios.defaults.headers.common['x-api-key'];
|
||||||
|
|
||||||
|
if (!apiKey) return;
|
||||||
|
|
||||||
|
const interceptorId = axios.interceptors.request.use((config) => {
|
||||||
|
// Check if URL is local/proxied
|
||||||
|
const isRelative = config.url.startsWith('/') || !config.url.startsWith('http');
|
||||||
|
const isInternalProxy = config.url.includes('/api/app') || config.url.includes('/api/workflow') || config.url.includes('/api/agents') || config.url.includes('/api/api') || config.url.includes('/api/v1');
|
||||||
|
|
||||||
|
if (isRelative || isInternalProxy) {
|
||||||
|
config.headers['x-api-key'] = apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
axios.interceptors.request.eject(interceptorId);
|
||||||
|
};
|
||||||
|
}, [apiKey]);
|
||||||
|
|
||||||
|
// Poll for balance every 30 seconds if key is present
|
||||||
|
useEffect(() => {
|
||||||
|
if (!apiKey) return;
|
||||||
|
const interval = setInterval(() => fetchBalance(apiKey), 30000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [apiKey, fetchBalance]);
|
||||||
|
|
||||||
|
// Drag and Drop Handlers
|
||||||
|
const handleDragOver = useCallback((e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragEnter = useCallback((e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
|
||||||
|
setIsDragging(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragLeave = useCallback((e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
// Only set to false if we're leaving the container itself, not moving between children
|
||||||
|
if (e.currentTarget.contains(e.relatedTarget)) return;
|
||||||
|
setIsDragging(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDrop = useCallback((e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(false);
|
||||||
|
|
||||||
|
const files = Array.from(e.dataTransfer.files);
|
||||||
|
if (files.length > 0) {
|
||||||
|
setDroppedFiles(files);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFilesHandled = useCallback(() => {
|
||||||
|
setDroppedFiles(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!hasMounted) return (
|
||||||
|
<div className="min-h-screen bg-[#050505] flex items-center justify-center">
|
||||||
|
<div className="animate-spin text-[#d9ff00] text-3xl">◌</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return <ApiKeyModal onSave={handleKeySave} />;
|
return <ApiKeyModal onSave={handleKeySave} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen bg-[#050505] flex flex-col overflow-hidden">
|
<div
|
||||||
{/* Header */}
|
className="h-screen bg-[#030303] flex flex-col overflow-hidden text-white relative"
|
||||||
<header className="flex-shrink-0 flex items-center justify-between px-4 pt-4 pb-0 border-b border-white/5">
|
onDragOver={handleDragOver}
|
||||||
<div className="flex items-center gap-3">
|
onDragEnter={handleDragEnter}
|
||||||
<span className="text-white font-black text-lg tracking-wider uppercase">
|
onDragLeave={handleDragLeave}
|
||||||
Open Higgsfield AI
|
onDrop={handleDrop}
|
||||||
</span>
|
>
|
||||||
|
{/* Drag Overlay */}
|
||||||
|
{isDragging && (
|
||||||
|
<div className="fixed inset-0 z-[100] bg-[#d9ff00]/10 backdrop-blur-md border-4 border-dashed border-[#d9ff00]/50 flex items-center justify-center pointer-events-none transition-all duration-300">
|
||||||
|
<div className="bg-[#0a0a0a] p-8 rounded-3xl border border-white/10 shadow-2xl flex flex-col items-center gap-4 scale-110 animate-pulse">
|
||||||
|
<div className="w-20 h-20 bg-[#d9ff00] rounded-2xl flex items-center justify-center">
|
||||||
|
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="black" strokeWidth="2.5">
|
||||||
|
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<span className="text-xl font-bold text-white">Drop your media here</span>
|
||||||
|
<span className="text-sm text-white/40">Images, videos, or audio files</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
{isHeaderVisible && (
|
||||||
|
<header className="flex-shrink-0 h-14 border-b border-white/[0.03] flex items-center justify-between px-6 bg-black/20 backdrop-blur-md z-40">
|
||||||
|
{/* Left: Logo */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-8 h-8 bg-white rounded-lg flex items-center justify-center">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="black" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-bold tracking-tight hidden sm:block">OpenGenerativeAI</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Center: Navigation */}
|
||||||
|
<nav className="absolute left-1/2 -translate-x-1/2 flex items-center gap-6">
|
||||||
|
{TABS.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => handleTabChange(tab.id)}
|
||||||
|
className={`relative py-4 text-[13px] font-medium transition-all whitespace-nowrap px-1 ${
|
||||||
|
activeTab === tab.id
|
||||||
|
? 'text-[#d9ff00]'
|
||||||
|
: 'text-white/50 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
{activeTab === tab.id && (
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 h-[2px] bg-[#d9ff00] rounded-full" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Right: Actions */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-3 bg-white/5 px-3 py-1.5 rounded-full border border-white/5 transition-colors">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-xs font-bold text-white/90">
|
||||||
|
${balance !== null ? `${balance}` : '---'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<nav className="flex items-center gap-1">
|
|
||||||
{TABS.map((tab) => (
|
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
onClick={() => setShowSettings(true)}
|
||||||
onClick={() => setActiveTab(tab.id)}
|
title="Settings — API key, local models, preferences"
|
||||||
className={`px-4 py-2 text-sm font-medium rounded-t-lg transition-colors ${
|
className="flex items-center gap-2 px-3 py-1.5 rounded-md border border-white/10 bg-white/5 text-[13px] font-bold text-white/80 hover:text-white hover:bg-white/10 hover:border-white/20 transition-colors"
|
||||||
activeTab === tab.id
|
|
||||||
? 'bg-[#d9ff00] text-black'
|
|
||||||
: 'text-white/50 hover:text-white'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{tab.label}
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||||
|
</svg>
|
||||||
|
<span>Settings</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
</div>
|
||||||
</nav>
|
</header>
|
||||||
|
)}
|
||||||
{/* Settings */}
|
|
||||||
<button
|
|
||||||
onClick={() => setShowSettings(true)}
|
|
||||||
className="text-white/40 hover:text-white text-sm transition-colors"
|
|
||||||
>
|
|
||||||
⚙ Settings
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Studio Content */}
|
{/* Studio Content */}
|
||||||
<div className="flex-1">
|
<div className="flex-1 min-h-0 relative overflow-hidden">
|
||||||
{activeTab === 'image' && <ImageStudio apiKey={apiKey} />}
|
{activeTab === 'image' && <ImageStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} />}
|
||||||
{activeTab === 'video' && <VideoStudio apiKey={apiKey} />}
|
{activeTab === 'video' && <VideoStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} />}
|
||||||
{activeTab === 'lipsync' && <LipSyncStudio apiKey={apiKey} />}
|
{activeTab === 'lipsync' && <LipSyncStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} />}
|
||||||
{activeTab === 'cinema' && <CinemaStudio apiKey={apiKey} />}
|
{activeTab === 'cinema' && <CinemaStudio apiKey={apiKey} />}
|
||||||
|
{activeTab === 'marketing' && <MarketingStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} />}
|
||||||
|
{activeTab === 'workflows' && <WorkflowStudio apiKey={apiKey} isHeaderVisible={isHeaderVisible} onToggleHeader={setIsHeaderVisible} />}
|
||||||
|
{activeTab === 'agents' && <AgentStudio apiKey={apiKey} isHeaderVisible={isHeaderVisible} onToggleHeader={setIsHeaderVisible} />}
|
||||||
|
{activeTab === 'apps' && <AppsStudio apiKey={apiKey} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Settings Modal */}
|
{/* Settings Modal */}
|
||||||
{showSettings && (
|
{showSettings && (
|
||||||
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50">
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 animate-fade-in-up">
|
||||||
<div className="bg-[#111] border border-white/10 rounded-2xl p-8 w-full max-w-md">
|
<div className="bg-[#0a0a0a] border border-white/10 rounded-xl p-8 w-full max-w-sm shadow-2xl">
|
||||||
<h2 className="text-white font-bold text-xl mb-6">Settings</h2>
|
<h2 className="text-white font-bold text-lg mb-2">Settings</h2>
|
||||||
<p className="text-white/50 text-sm mb-4">
|
<p className="text-white/40 text-[13px] mb-8">
|
||||||
Current API key: <span className="text-white/80 font-mono">{apiKey.slice(0, 8)}••••••••</span>
|
Manage your AI studio preferences and authentication.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-4 mb-8">
|
||||||
|
<div className="bg-white/5 border border-white/[0.03] rounded-md p-4">
|
||||||
|
<label className="block text-xs font-bold text-white/30 mb-2">
|
||||||
|
Active API Key
|
||||||
|
</label>
|
||||||
|
<div className="text-[13px] font-mono text-white/80">
|
||||||
|
{apiKey.slice(0, 8)}••••••••••••••••
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={handleKeyChange}
|
onClick={handleKeyChange}
|
||||||
className="flex-1 py-2 rounded-lg bg-red-500/20 text-red-400 hover:bg-red-500/30 text-sm transition-colors"
|
className="flex-1 h-10 rounded-md bg-red-500/10 text-red-400 hover:bg-red-500/20 text-xs font-semibold transition-all"
|
||||||
>
|
>
|
||||||
Change API Key
|
Change Key
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowSettings(false)}
|
onClick={() => setShowSettings(false)}
|
||||||
className="flex-1 py-2 rounded-lg bg-white/5 text-white hover:bg-white/10 text-sm transition-colors"
|
className="flex-1 h-10 rounded-md bg-white/5 text-white/80 hover:bg-white/10 text-xs font-semibold transition-all border border-white/5"
|
||||||
>
|
>
|
||||||
Close
|
Close
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
9
docker-compose.yml
Normal file
9
docker-compose.yml
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
services:
|
||||||
|
open-generative-ai:
|
||||||
|
build: .
|
||||||
|
container_name: open-generative-ai
|
||||||
|
ports:
|
||||||
|
- "3001:3000"
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
restart: unless-stopped
|
||||||
526
electron/lib/localInference.js
Normal file
526
electron/lib/localInference.js
Normal file
|
|
@ -0,0 +1,526 @@
|
||||||
|
const { ipcMain, app, BrowserWindow } = require('electron');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const https = require('https');
|
||||||
|
const http = require('http');
|
||||||
|
const { spawn, execFile } = require('child_process');
|
||||||
|
const os = require('os');
|
||||||
|
|
||||||
|
// ─── Paths ────────────────────────────────────────────────────────────────────
|
||||||
|
const DATA_DIR = path.join(app.getPath('userData'), 'local-ai');
|
||||||
|
const BIN_DIR = path.join(DATA_DIR, 'bin');
|
||||||
|
const MODELS_DIR = path.join(DATA_DIR, 'models');
|
||||||
|
const TMP_DIR = path.join(DATA_DIR, 'tmp');
|
||||||
|
|
||||||
|
for (const dir of [BIN_DIR, MODELS_DIR, TMP_DIR]) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const BINARY_NAME = process.platform === 'win32' ? 'sd-cli.exe' : 'sd-cli';
|
||||||
|
const BINARY_PATH = path.join(BIN_DIR, BINARY_NAME);
|
||||||
|
|
||||||
|
// ─── State ────────────────────────────────────────────────────────────────────
|
||||||
|
let activeProcess = null;
|
||||||
|
const activeDownloads = new Map(); // modelId → request object
|
||||||
|
|
||||||
|
// ─── GitHub release asset matcher per platform ───────────────────────────────
|
||||||
|
// Asset names look like: sd-master-44cca3d-bin-Darwin-macOS-15.7.4-arm64.zip
|
||||||
|
// We pick the best match in priority order so a single release that only
|
||||||
|
// ships e.g. avx512 still resolves cleanly.
|
||||||
|
function pickBinaryAsset(zipNames) {
|
||||||
|
const { platform, arch } = process;
|
||||||
|
|
||||||
|
// The "cudart" zip in recent leejet releases is just the CUDA runtime DLLs,
|
||||||
|
// not an sd-cli build, so it must never satisfy the Windows match.
|
||||||
|
const isSdCliZip = (n) => n.startsWith('sd-master-') || n.includes('-bin-');
|
||||||
|
const candidates = zipNames.filter(isSdCliZip);
|
||||||
|
|
||||||
|
if (platform === 'darwin') {
|
||||||
|
// leejet only publishes arm64 macOS builds. Mac Intel must use the
|
||||||
|
// hosted API instead — caller maps the empty result to a clear error.
|
||||||
|
if (arch !== 'arm64') return null;
|
||||||
|
return candidates.find(n => n.includes('Darwin') && n.includes('arm64')) || null;
|
||||||
|
}
|
||||||
|
if (platform === 'win32') {
|
||||||
|
// Priority: avx2 > avx > avx512 > noavx > cuda12. cuda needs the
|
||||||
|
// separate cudart runtime so we only fall back to it if nothing else.
|
||||||
|
const winCandidates = candidates.filter(n => /win-(avx2?|avx512|noavx|cuda12|cu12)-x64/.test(n));
|
||||||
|
const order = ['win-avx2-x64', 'win-avx-x64', 'win-avx512-x64', 'win-noavx-x64', 'win-cuda12-x64', 'win-cu12-x64'];
|
||||||
|
for (const tag of order) {
|
||||||
|
const hit = winCandidates.find(n => n.includes(tag));
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Linux: prefer plain x86_64, then vulkan, then rocm.
|
||||||
|
const linuxCandidates = candidates.filter(n => n.includes('Linux') && n.includes('x86_64'));
|
||||||
|
const plain = linuxCandidates.find(n => !n.includes('rocm') && !n.includes('vulkan'));
|
||||||
|
return plain
|
||||||
|
|| linuxCandidates.find(n => n.includes('vulkan'))
|
||||||
|
|| linuxCandidates.find(n => n.includes('rocm'))
|
||||||
|
|| null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchJson(url) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
https.get(url, { headers: { 'User-Agent': 'open-generative-ai' } }, (res) => {
|
||||||
|
if (res.statusCode !== 200) {
|
||||||
|
res.resume();
|
||||||
|
reject(new Error(`HTTP ${res.statusCode} from ${url}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let body = '';
|
||||||
|
res.on('data', (d) => { body += d; });
|
||||||
|
res.on('end', () => {
|
||||||
|
try { resolve(JSON.parse(body)); } catch (e) { reject(e); }
|
||||||
|
});
|
||||||
|
res.on('error', reject);
|
||||||
|
}).on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Robust HTTPS download with redirect-following, range-resume, and retry ───
|
||||||
|
function downloadFile(url, destPath, onProgress) {
|
||||||
|
const tmp = destPath + '.part';
|
||||||
|
|
||||||
|
// Outer total so progress never goes backwards across retries/redirects
|
||||||
|
let knownTotal = 0;
|
||||||
|
|
||||||
|
const attempt = (requestUrl, redirectsLeft, retriesLeft) => new Promise((resolve, reject) => {
|
||||||
|
// Resume from however many bytes are already on disk
|
||||||
|
const alreadyDownloaded = fs.existsSync(tmp) ? fs.statSync(tmp).size : 0;
|
||||||
|
|
||||||
|
const parsed = new URL(requestUrl);
|
||||||
|
const mod = parsed.protocol === 'https:' ? https : http;
|
||||||
|
|
||||||
|
const reqHeaders = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (compatible; open-generative-ai/1.0)',
|
||||||
|
'Accept': '*/*',
|
||||||
|
'Connection': 'keep-alive',
|
||||||
|
};
|
||||||
|
if (alreadyDownloaded > 0) reqHeaders['Range'] = `bytes=${alreadyDownloaded}-`;
|
||||||
|
|
||||||
|
const req = mod.get({ hostname: parsed.hostname, path: parsed.pathname + parsed.search, headers: reqHeaders }, (res) => {
|
||||||
|
const { statusCode, headers } = res;
|
||||||
|
|
||||||
|
// Follow redirects
|
||||||
|
if ([301, 302, 303, 307, 308].includes(statusCode)) {
|
||||||
|
res.resume();
|
||||||
|
if (redirectsLeft <= 0) { reject(new Error('Too many redirects')); return; }
|
||||||
|
resolve(attempt(headers.location, redirectsLeft - 1, retriesLeft));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 206 Partial Content (range accepted) or 200 OK (server ignored Range)
|
||||||
|
if (statusCode !== 200 && statusCode !== 206) {
|
||||||
|
res.resume();
|
||||||
|
reject(new Error(`HTTP ${statusCode} from ${parsed.hostname}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// content-length on a 206 is the remaining bytes; on 200 it's the full file
|
||||||
|
const chunkSize = parseInt(headers['content-length'] || '0', 10);
|
||||||
|
if (statusCode === 200) {
|
||||||
|
// Server ignored our Range header — restart the file
|
||||||
|
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
|
||||||
|
knownTotal = chunkSize;
|
||||||
|
} else {
|
||||||
|
// 206: total = already downloaded + remaining
|
||||||
|
knownTotal = alreadyDownloaded + chunkSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
let received = alreadyDownloaded;
|
||||||
|
const out = fs.createWriteStream(tmp, { flags: statusCode === 206 ? 'a' : 'w' });
|
||||||
|
|
||||||
|
res.on('data', (chunk) => {
|
||||||
|
received += chunk.length;
|
||||||
|
if (knownTotal && onProgress) onProgress(received / knownTotal);
|
||||||
|
});
|
||||||
|
res.pipe(out);
|
||||||
|
out.on('finish', () => { fs.renameSync(tmp, destPath); resolve(); });
|
||||||
|
out.on('error', reject);
|
||||||
|
res.on('error', reject);
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', (err) => {
|
||||||
|
if (retriesLeft > 0) {
|
||||||
|
console.warn(`[download] ${err.message} — retrying in 3s (${retriesLeft} left)`);
|
||||||
|
setTimeout(() => resolve(attempt(requestUrl, redirectsLeft, retriesLeft - 1)), 3000);
|
||||||
|
} else {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
req.setTimeout(60000, () => req.destroy(new Error('Request timed out')));
|
||||||
|
});
|
||||||
|
|
||||||
|
return attempt(url, 10, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Extract zip on each platform ────────────────────────────────────────────
|
||||||
|
function extractZip(zipPath, destDir) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let cmd, args;
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
cmd = 'powershell';
|
||||||
|
args = ['-NoProfile', '-Command', `Expand-Archive -Force -Path "${zipPath}" -DestinationPath "${destDir}"`];
|
||||||
|
} else {
|
||||||
|
cmd = 'unzip';
|
||||||
|
args = ['-o', zipPath, '-d', destDir];
|
||||||
|
}
|
||||||
|
execFile(cmd, args, (err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Binary management ────────────────────────────────────────────────────────
|
||||||
|
// Recursively find a file by name under dir; returns full path or null.
|
||||||
|
function findFile(dir, name) {
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const full = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
const found = findFile(full, name);
|
||||||
|
if (found) return found;
|
||||||
|
} else if (entry.name === name) {
|
||||||
|
return full;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getBinaryStatus() {
|
||||||
|
const exists = fs.existsSync(BINARY_PATH);
|
||||||
|
return { exists, path: BINARY_PATH };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metal-enabled binaries hosted on our own release (macOS arm64 only).
|
||||||
|
// Other platforms fall back to the stock leejet release.
|
||||||
|
const CUSTOM_BINARIES = {
|
||||||
|
'darwin-arm64': 'https://github.com/Anil-matcha/Open-Generative-AI/releases/download/v1.0.3-binaries/sd-cli-metal-macos-arm64.zip',
|
||||||
|
};
|
||||||
|
|
||||||
|
async function downloadBinary(mainWindow) {
|
||||||
|
const send = (data) => mainWindow?.webContents.send('local-ai:download-progress', { id: '__binary__', ...data });
|
||||||
|
|
||||||
|
try {
|
||||||
|
send({ phase: 'fetching-release', progress: 0 });
|
||||||
|
|
||||||
|
const platformKey = `${process.platform}-${process.arch}`;
|
||||||
|
const customUrl = CUSTOM_BINARIES[platformKey];
|
||||||
|
|
||||||
|
let downloadUrl, zipName;
|
||||||
|
|
||||||
|
if (customUrl) {
|
||||||
|
downloadUrl = customUrl;
|
||||||
|
zipName = path.basename(customUrl);
|
||||||
|
} else {
|
||||||
|
// Walk recent releases until we find one that actually ships a
|
||||||
|
// build for this platform. leejet sometimes publishes a partial
|
||||||
|
// release (e.g. master-587 ships only Mac arm64 + Linux ROCm),
|
||||||
|
// so the very latest tag isn't always usable.
|
||||||
|
const releases = await fetchJson(
|
||||||
|
'https://api.github.com/repos/leejet/stable-diffusion.cpp/releases?per_page=15'
|
||||||
|
);
|
||||||
|
|
||||||
|
let chosen = null;
|
||||||
|
let lastSeen = [];
|
||||||
|
for (const release of releases) {
|
||||||
|
const zips = (release.assets || [])
|
||||||
|
.filter(a => a.name.endsWith('.zip'));
|
||||||
|
lastSeen = zips.map(a => a.name);
|
||||||
|
const pickedName = pickBinaryAsset(lastSeen);
|
||||||
|
if (pickedName) {
|
||||||
|
chosen = zips.find(a => a.name === pickedName);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!chosen) {
|
||||||
|
if (process.platform === 'darwin' && process.arch !== 'arm64') {
|
||||||
|
throw new Error('Local inference on macOS only supports Apple Silicon (M1/M2/M3/M4). Mac Intel is not supported by stable-diffusion.cpp upstream.');
|
||||||
|
}
|
||||||
|
const available = lastSeen.join(', ') || '(none)';
|
||||||
|
throw new Error(`No binary found for ${process.platform}-${process.arch} in the last 15 releases. Latest release assets: ${available}`);
|
||||||
|
}
|
||||||
|
downloadUrl = chosen.browser_download_url;
|
||||||
|
zipName = chosen.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
send({ phase: 'downloading', progress: 0 });
|
||||||
|
const zipPath = path.join(BIN_DIR, zipName);
|
||||||
|
await downloadFile(downloadUrl, zipPath, (p) => {
|
||||||
|
send({ phase: 'downloading', progress: p });
|
||||||
|
});
|
||||||
|
|
||||||
|
send({ phase: 'extracting', progress: 0.95 });
|
||||||
|
await extractZip(zipPath, BIN_DIR);
|
||||||
|
fs.unlinkSync(zipPath);
|
||||||
|
|
||||||
|
// The zip may extract into a subdirectory — find the binary wherever it landed
|
||||||
|
const foundBinary = findFile(BIN_DIR, BINARY_NAME);
|
||||||
|
if (!foundBinary) throw new Error(`Extracted archive but could not find "${BINARY_NAME}" inside ${BIN_DIR}`);
|
||||||
|
|
||||||
|
// Move it to the expected root location if it's nested
|
||||||
|
if (foundBinary !== BINARY_PATH) {
|
||||||
|
fs.renameSync(foundBinary, BINARY_PATH);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make binary executable on Unix
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
fs.chmodSync(BINARY_PATH, 0o755);
|
||||||
|
// Also chmod the dylib so it can be loaded
|
||||||
|
const dylib = findFile(BIN_DIR, 'libstable-diffusion.dylib');
|
||||||
|
if (dylib) fs.chmodSync(dylib, 0o755);
|
||||||
|
}
|
||||||
|
|
||||||
|
// macOS: strip Gatekeeper quarantine so the downloaded binary can run
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
await new Promise((res) => execFile('xattr', ['-cr', BIN_DIR], () => res()));
|
||||||
|
}
|
||||||
|
|
||||||
|
send({ phase: 'done', progress: 1 });
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
send({ phase: 'error', error: err.message });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Model management ─────────────────────────────────────────────────────────
|
||||||
|
function getModelState(model) {
|
||||||
|
const filePath = path.join(MODELS_DIR, model.filename);
|
||||||
|
const partPath = filePath + '.part';
|
||||||
|
if (fs.existsSync(filePath)) return 'downloaded';
|
||||||
|
if (fs.existsSync(partPath)) return 'partial';
|
||||||
|
return 'not-downloaded';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAuxState(aux) {
|
||||||
|
const filePath = path.join(MODELS_DIR, aux.filename);
|
||||||
|
return fs.existsSync(filePath) ? 'downloaded' : 'not-downloaded';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listModels() {
|
||||||
|
const { LOCAL_MODEL_CATALOG, ZIMAGE_AUXILIARY } = require('./modelCatalog');
|
||||||
|
const auxStatus = {
|
||||||
|
llm: getAuxState(ZIMAGE_AUXILIARY.llm),
|
||||||
|
vae: getAuxState(ZIMAGE_AUXILIARY.vae),
|
||||||
|
};
|
||||||
|
return LOCAL_MODEL_CATALOG.map(m => ({
|
||||||
|
...m,
|
||||||
|
state: getModelState(m),
|
||||||
|
path: path.join(MODELS_DIR, m.filename),
|
||||||
|
...(m.requiresAuxiliary ? { auxiliaryStatus: auxStatus } : {}),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadModel(modelId, mainWindow) {
|
||||||
|
const { LOCAL_MODEL_CATALOG } = require('./modelCatalog');
|
||||||
|
const model = LOCAL_MODEL_CATALOG.find(m => m.id === modelId);
|
||||||
|
if (!model) throw new Error(`Unknown model: ${modelId}`);
|
||||||
|
|
||||||
|
const destPath = path.join(MODELS_DIR, model.filename);
|
||||||
|
if (fs.existsSync(destPath)) return { ok: true, path: destPath };
|
||||||
|
|
||||||
|
const send = (data) => mainWindow?.webContents.send('local-ai:download-progress', { id: modelId, ...data });
|
||||||
|
send({ phase: 'downloading', progress: 0 });
|
||||||
|
|
||||||
|
await downloadFile(model.downloadUrl, destPath, (p) => {
|
||||||
|
send({ phase: 'downloading', progress: p });
|
||||||
|
});
|
||||||
|
|
||||||
|
send({ phase: 'done', progress: 1 });
|
||||||
|
return { ok: true, path: destPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadAuxiliary(auxKey, mainWindow) {
|
||||||
|
const { ZIMAGE_AUXILIARY } = require('./modelCatalog');
|
||||||
|
const aux = ZIMAGE_AUXILIARY[auxKey];
|
||||||
|
if (!aux) throw new Error(`Unknown auxiliary file: ${auxKey}`);
|
||||||
|
|
||||||
|
const destPath = path.join(MODELS_DIR, aux.filename);
|
||||||
|
if (fs.existsSync(destPath)) return { ok: true, path: destPath };
|
||||||
|
|
||||||
|
const id = aux.id;
|
||||||
|
const send = (data) => mainWindow?.webContents.send('local-ai:download-progress', { id, ...data });
|
||||||
|
send({ phase: 'downloading', progress: 0 });
|
||||||
|
|
||||||
|
await downloadFile(aux.downloadUrl, destPath, (p) => {
|
||||||
|
send({ phase: 'downloading', progress: p });
|
||||||
|
});
|
||||||
|
|
||||||
|
send({ phase: 'done', progress: 1 });
|
||||||
|
return { ok: true, path: destPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteModel(modelId) {
|
||||||
|
const { LOCAL_MODEL_CATALOG } = require('./modelCatalog');
|
||||||
|
const model = LOCAL_MODEL_CATALOG.find(m => m.id === modelId);
|
||||||
|
if (!model) throw new Error(`Unknown model: ${modelId}`);
|
||||||
|
|
||||||
|
const filePath = path.join(MODELS_DIR, model.filename);
|
||||||
|
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
||||||
|
const partPath = filePath + '.part';
|
||||||
|
if (fs.existsSync(partPath)) fs.unlinkSync(partPath);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Generation ───────────────────────────────────────────────────────────────
|
||||||
|
function arToDimensions(ar, modelType) {
|
||||||
|
const base = (modelType === 'sdxl' || modelType === 'z-image') ? 1024 : 512;
|
||||||
|
const map = {
|
||||||
|
'1:1': [base, base],
|
||||||
|
'16:9': [Math.round(base * 16 / 9 / 64) * 64, base],
|
||||||
|
'9:16': [base, Math.round(base * 16 / 9 / 64) * 64],
|
||||||
|
'4:3': [Math.round(base * 4 / 3 / 64) * 64, base],
|
||||||
|
'3:4': [base, Math.round(base * 4 / 3 / 64) * 64],
|
||||||
|
};
|
||||||
|
return map[ar] || [base, base];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generate(params, mainWindow) {
|
||||||
|
const { LOCAL_MODEL_CATALOG, ZIMAGE_AUXILIARY } = require('./modelCatalog');
|
||||||
|
const send = (data) => mainWindow?.webContents.send('local-ai:progress', data);
|
||||||
|
|
||||||
|
if (!fs.existsSync(BINARY_PATH)) throw new Error('sd.cpp binary not installed. Download it in Settings > Local Models.');
|
||||||
|
|
||||||
|
const model = LOCAL_MODEL_CATALOG.find(m => m.id === params.model);
|
||||||
|
if (!model) throw new Error(`Unknown local model: ${params.model}`);
|
||||||
|
|
||||||
|
const modelPath = path.join(MODELS_DIR, model.filename);
|
||||||
|
if (!fs.existsSync(modelPath)) throw new Error(`Model file not found. Download "${model.name}" in Settings > Local Models.`);
|
||||||
|
|
||||||
|
if (model.requiresAuxiliary) {
|
||||||
|
const llmPath = path.join(MODELS_DIR, ZIMAGE_AUXILIARY.llm.filename);
|
||||||
|
const vaePath = path.join(MODELS_DIR, ZIMAGE_AUXILIARY.vae.filename);
|
||||||
|
if (!fs.existsSync(llmPath)) throw new Error('Text encoder (Qwen3-4B) not downloaded. Go to Settings > Local Models and download all required files for Z-Image.');
|
||||||
|
if (!fs.existsSync(vaePath)) throw new Error('VAE (ae.safetensors) not downloaded. Go to Settings > Local Models and download all required files for Z-Image.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [width, height] = arToDimensions(params.aspect_ratio || '1:1', model.type);
|
||||||
|
const seed = params.seed && params.seed !== -1 ? params.seed : Math.floor(Math.random() * 2147483647);
|
||||||
|
const outPath = path.join(TMP_DIR, `gen-${Date.now()}.png`);
|
||||||
|
|
||||||
|
const steps = model.defaultSteps || params.steps || 20;
|
||||||
|
const cfgScale = model.defaultGuidance !== undefined ? model.defaultGuidance : (params.guidance_scale || 7.5);
|
||||||
|
const sampler = model.sampler || 'euler_a';
|
||||||
|
|
||||||
|
// z-image GGUFs are standalone diffusion transformers loaded via --diffusion-model.
|
||||||
|
// -m triggers full-model SD version detection which fails for these files (0 KV metadata).
|
||||||
|
const modelFlag = (model.type === 'z-image' || model.type === 'flux')
|
||||||
|
? '--diffusion-model'
|
||||||
|
: '-m';
|
||||||
|
|
||||||
|
const args = [
|
||||||
|
modelFlag, modelPath,
|
||||||
|
'-p', params.prompt || '',
|
||||||
|
'-o', outPath,
|
||||||
|
'--steps', String(steps),
|
||||||
|
'-H', String(height),
|
||||||
|
'-W', String(width),
|
||||||
|
'--cfg-scale', String(cfgScale),
|
||||||
|
'--seed', String(seed),
|
||||||
|
'--sampling-method', sampler,
|
||||||
|
'-v',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (params.negative_prompt) {
|
||||||
|
args.push('-n', params.negative_prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (model.type === 'z-image') {
|
||||||
|
const llmPath = path.join(MODELS_DIR, ZIMAGE_AUXILIARY.llm.filename);
|
||||||
|
const vaePath = path.join(MODELS_DIR, ZIMAGE_AUXILIARY.vae.filename);
|
||||||
|
args.push('--llm', llmPath);
|
||||||
|
args.push('--vae', vaePath);
|
||||||
|
if (model.scheduler) args.push('--scheduler', model.scheduler);
|
||||||
|
} else if (model.type === 'sdxl') {
|
||||||
|
args.push('--sd-version', 'sdxl');
|
||||||
|
} else if (model.type === 'sd2') {
|
||||||
|
args.push('--sd-version', 'sd2');
|
||||||
|
} else if (model.type === 'flux') {
|
||||||
|
args.push('--flux');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
send({ step: 0, totalSteps: params.steps || model.defaultSteps || 20, status: 'starting' });
|
||||||
|
|
||||||
|
console.log('[sd-cli] command:', BINARY_PATH, args.join(' '));
|
||||||
|
// DYLD_LIBRARY_PATH lets macOS find libstable-diffusion.dylib next to sd-cli
|
||||||
|
const spawnEnv = { ...process.env, DYLD_LIBRARY_PATH: BIN_DIR, LD_LIBRARY_PATH: BIN_DIR };
|
||||||
|
activeProcess = spawn(BINARY_PATH, args, { env: spawnEnv });
|
||||||
|
const stepRegex = /step\s+(\d+)\/(\d+)/i;
|
||||||
|
const outputLines = [];
|
||||||
|
|
||||||
|
const handleOutput = (data) => {
|
||||||
|
const line = data.toString();
|
||||||
|
outputLines.push(line.trimEnd());
|
||||||
|
const match = line.match(stepRegex);
|
||||||
|
if (match) {
|
||||||
|
const step = parseInt(match[1]);
|
||||||
|
const total = parseInt(match[2]);
|
||||||
|
send({ step, totalSteps: total, status: 'generating', progress: step / total });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
activeProcess.stdout.on('data', handleOutput);
|
||||||
|
activeProcess.stderr.on('data', handleOutput);
|
||||||
|
|
||||||
|
activeProcess.on('close', (code) => {
|
||||||
|
activeProcess = null;
|
||||||
|
const allOutput = outputLines.filter(l => l.trim()).join('\n');
|
||||||
|
console.error('[sd-cli] full output:\n' + allOutput);
|
||||||
|
if (code !== 0) {
|
||||||
|
const tail = outputLines.filter(l => l.trim()).slice(-20).join('\n');
|
||||||
|
reject(new Error(`sd-cli exited (code ${code}):\n${tail}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(outPath)) {
|
||||||
|
reject(new Error('sd.cpp finished but no output image found'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const imgBuffer = fs.readFileSync(outPath);
|
||||||
|
const dataUrl = `data:image/png;base64,${imgBuffer.toString('base64')}`;
|
||||||
|
fs.unlinkSync(outPath);
|
||||||
|
send({ step: 1, totalSteps: 1, status: 'done', progress: 1 });
|
||||||
|
resolve({ url: dataUrl, seed });
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
activeProcess.on('error', (err) => {
|
||||||
|
activeProcess = null;
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelGeneration() {
|
||||||
|
if (activeProcess) {
|
||||||
|
activeProcess.kill('SIGTERM');
|
||||||
|
activeProcess = null;
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── IPC Registration ─────────────────────────────────────────────────────────
|
||||||
|
function getMainWindow() {
|
||||||
|
return BrowserWindow.getAllWindows()[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function register() {
|
||||||
|
ipcMain.handle('local-ai:binary-status', () => getBinaryStatus());
|
||||||
|
ipcMain.handle('local-ai:download-binary', () => downloadBinary(getMainWindow()));
|
||||||
|
ipcMain.handle('local-ai:list-models', () => listModels());
|
||||||
|
ipcMain.handle('local-ai:download-model', (_, modelId) => downloadModel(modelId, getMainWindow()));
|
||||||
|
ipcMain.handle('local-ai:download-auxiliary', (_, auxKey) => downloadAuxiliary(auxKey, getMainWindow()));
|
||||||
|
ipcMain.handle('local-ai:delete-model', (_, modelId) => deleteModel(modelId));
|
||||||
|
ipcMain.handle('local-ai:generate', (_, params) => generate(params, getMainWindow()));
|
||||||
|
ipcMain.handle('local-ai:cancel-generation', () => cancelGeneration());
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { register };
|
||||||
131
electron/lib/modelCatalog.js
Normal file
131
electron/lib/modelCatalog.js
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
// Curated local model catalog for sd.cpp
|
||||||
|
// All models must be publicly available (no auth required)
|
||||||
|
|
||||||
|
// Shared auxiliary files needed by Z-Image type models
|
||||||
|
const ZIMAGE_AUXILIARY = {
|
||||||
|
llm: {
|
||||||
|
id: '__llm__',
|
||||||
|
filename: 'Qwen3-4B-Instruct-2507-UD-Q4_K_XL.gguf',
|
||||||
|
displayName: 'Qwen3-4B Text Encoder',
|
||||||
|
sizeGB: 2.4,
|
||||||
|
downloadUrl: 'https://huggingface.co/unsloth/Qwen3-4B-Instruct-2507-GGUF/resolve/main/Qwen3-4B-Instruct-2507-UD-Q4_K_XL.gguf',
|
||||||
|
},
|
||||||
|
vae: {
|
||||||
|
id: '__vae__',
|
||||||
|
filename: 'ae.safetensors',
|
||||||
|
displayName: 'FLUX VAE',
|
||||||
|
sizeGB: 0.33,
|
||||||
|
downloadUrl: 'https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/vae/ae.safetensors',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const LOCAL_MODEL_CATALOG = [
|
||||||
|
// ── Z-Image (Tongyi-MAI) — native sd.cpp GGUF support ──────────────────
|
||||||
|
// Requires auxiliary files: Qwen3-4B LLM text encoder + FLUX VAE
|
||||||
|
{
|
||||||
|
id: 'z-image-turbo',
|
||||||
|
name: 'Z-Image Turbo',
|
||||||
|
description: 'WaveSpeed\'s featured local model — ultra-fast 8-step generation. No API key needed. Requires text encoder + VAE (~2.7 GB extra).',
|
||||||
|
type: 'z-image',
|
||||||
|
filename: 'z_image_turbo-Q4_K.gguf',
|
||||||
|
sizeGB: 2.5,
|
||||||
|
downloadUrl: 'https://huggingface.co/leejet/Z-Image-Turbo-GGUF/resolve/main/z_image_turbo-Q4_K.gguf',
|
||||||
|
aspectRatios: ['1:1', '4:3', '3:4', '16:9', '9:16'],
|
||||||
|
defaultWidth: 1024,
|
||||||
|
defaultHeight: 1024,
|
||||||
|
defaultSteps: 8,
|
||||||
|
defaultGuidance: 1.0,
|
||||||
|
sampler: 'euler',
|
||||||
|
scheduler: 'simple',
|
||||||
|
tags: ['turbo', 'fast', 'local', 'featured'],
|
||||||
|
featured: true,
|
||||||
|
requiresAuxiliary: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'z-image-base',
|
||||||
|
name: 'Z-Image Base',
|
||||||
|
description: 'Full-quality model from Tongyi-MAI — higher detail, 50-step generation. Requires text encoder + VAE (~2.7 GB extra).',
|
||||||
|
type: 'z-image',
|
||||||
|
filename: 'Z-Image-Q4_K_M.gguf',
|
||||||
|
sizeGB: 3.5,
|
||||||
|
downloadUrl: 'https://huggingface.co/unsloth/Z-Image-GGUF/resolve/main/Z-Image-Q4_K_M.gguf',
|
||||||
|
aspectRatios: ['1:1', '4:3', '3:4', '16:9', '9:16'],
|
||||||
|
defaultWidth: 1024,
|
||||||
|
defaultHeight: 1024,
|
||||||
|
defaultSteps: 50,
|
||||||
|
defaultGuidance: 7.5,
|
||||||
|
sampler: 'euler',
|
||||||
|
scheduler: 'simple',
|
||||||
|
tags: ['high-quality', 'local', 'detailed'],
|
||||||
|
featured: true,
|
||||||
|
requiresAuxiliary: true,
|
||||||
|
},
|
||||||
|
// ── Classic SD 1.5 models ───────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'dreamshaper-8',
|
||||||
|
name: 'Dreamshaper 8',
|
||||||
|
description: 'Versatile SD 1.5 model — great for portraits, landscapes, and artistic styles.',
|
||||||
|
type: 'sd1',
|
||||||
|
filename: 'DreamShaper_8_pruned.safetensors',
|
||||||
|
sizeGB: 2.1,
|
||||||
|
downloadUrl: 'https://huggingface.co/Lykon/DreamShaper/resolve/main/DreamShaper_8_pruned.safetensors',
|
||||||
|
aspectRatios: ['1:1', '4:3', '3:4', '16:9', '9:16'],
|
||||||
|
defaultWidth: 512,
|
||||||
|
defaultHeight: 512,
|
||||||
|
defaultSteps: 20,
|
||||||
|
defaultGuidance: 7.5,
|
||||||
|
sampler: 'euler_a',
|
||||||
|
tags: ['photorealistic', 'artistic', 'versatile'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'realistic-vision-v51',
|
||||||
|
name: 'Realistic Vision v5.1',
|
||||||
|
description: 'Highly photorealistic people and scenes, based on SD 1.5.',
|
||||||
|
type: 'sd1',
|
||||||
|
filename: 'realisticVisionV51_v51VAE.safetensors',
|
||||||
|
sizeGB: 2.1,
|
||||||
|
downloadUrl: 'https://huggingface.co/SG161222/Realistic_Vision_V5.1_noVAE/resolve/main/Realistic_Vision_V5.1_fp16-no-ema.safetensors',
|
||||||
|
aspectRatios: ['1:1', '4:3', '3:4', '16:9', '9:16'],
|
||||||
|
defaultWidth: 512,
|
||||||
|
defaultHeight: 768,
|
||||||
|
defaultSteps: 25,
|
||||||
|
defaultGuidance: 7,
|
||||||
|
sampler: 'euler_a',
|
||||||
|
tags: ['photorealistic', 'portraits', 'people'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'anything-v5',
|
||||||
|
name: 'Anything v5',
|
||||||
|
description: 'High quality anime and illustration style image generation.',
|
||||||
|
type: 'sd1',
|
||||||
|
filename: 'Anything-v5.0-PRT.safetensors',
|
||||||
|
sizeGB: 2.1,
|
||||||
|
downloadUrl: 'https://huggingface.co/Yntec/AnythingV5/resolve/main/Anything-v5.0-PRT.safetensors',
|
||||||
|
aspectRatios: ['1:1', '4:3', '3:4', '16:9', '9:16'],
|
||||||
|
defaultWidth: 512,
|
||||||
|
defaultHeight: 768,
|
||||||
|
defaultSteps: 20,
|
||||||
|
defaultGuidance: 7,
|
||||||
|
sampler: 'euler_a',
|
||||||
|
tags: ['anime', 'illustration', 'artistic'],
|
||||||
|
},
|
||||||
|
// ── SDXL ───────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'stable-diffusion-xl-base',
|
||||||
|
name: 'SDXL Base 1.0',
|
||||||
|
description: 'Official Stable Diffusion XL base model — higher resolution, excellent quality.',
|
||||||
|
type: 'sdxl',
|
||||||
|
filename: 'sd_xl_base_1.0.safetensors',
|
||||||
|
sizeGB: 6.9,
|
||||||
|
downloadUrl: 'https://huggingface.co/stabilityai/stable-diffusion-xl-base-1.0/resolve/main/sd_xl_base_1.0.safetensors',
|
||||||
|
aspectRatios: ['1:1', '4:3', '3:4', '16:9', '9:16'],
|
||||||
|
defaultWidth: 1024,
|
||||||
|
defaultHeight: 1024,
|
||||||
|
defaultSteps: 30,
|
||||||
|
defaultGuidance: 7.5,
|
||||||
|
sampler: 'dpmpp2m',
|
||||||
|
tags: ['sdxl', 'high-quality', 'versatile'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
module.exports = { LOCAL_MODEL_CATALOG, ZIMAGE_AUXILIARY };
|
||||||
466
electron/lib/wan2gpProvider.js
Normal file
466
electron/lib/wan2gpProvider.js
Normal file
|
|
@ -0,0 +1,466 @@
|
||||||
|
// Wan2GP HTTP provider — alternate local engine alongside sd.cpp.
|
||||||
|
// User runs Wan2GP themselves (https://github.com/deepbeepmeep/Wan2GP) and
|
||||||
|
// points this app at its Gradio server. We never bundle Python or weights.
|
||||||
|
// Useful when sd.cpp can't run a model (e.g. video) or the user has a
|
||||||
|
// dedicated CUDA box and only wants this Mac as the UI.
|
||||||
|
|
||||||
|
const { ipcMain, app, BrowserWindow } = require('electron');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const http = require('http');
|
||||||
|
const https = require('https');
|
||||||
|
|
||||||
|
const DATA_DIR = path.join(app.getPath('userData'), 'local-ai');
|
||||||
|
const CONFIG_FILE = path.join(DATA_DIR, 'wan2gp.json');
|
||||||
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||||
|
|
||||||
|
// ─── Catalog ──────────────────────────────────────────────────────────────────
|
||||||
|
// `fn` is the *preferred* Gradio api_name Wan2GP exposes via /gradio_api/call/<fn>.
|
||||||
|
// Wan2GP builds rename these between versions (and Pinokio packages drop them
|
||||||
|
// entirely on some endpoints), so at probe time we pull /info from the server
|
||||||
|
// and remap each catalog entry's `fn` to whatever is actually registered. The
|
||||||
|
// `fnAliases` list lets us match newer/older variants; `family` is the final
|
||||||
|
// fallback for fuzzy matching. See resolveFnNames() below.
|
||||||
|
const WAN2GP_CATALOG = [
|
||||||
|
{
|
||||||
|
id: 'wan2gp:flux-dev',
|
||||||
|
name: 'Flux.1 Dev (Wan2GP)',
|
||||||
|
description: 'Image — FLUX.1 dev served by Wan2GP. 1024px output.',
|
||||||
|
type: 'image',
|
||||||
|
family: 'flux',
|
||||||
|
provider: 'wan2gp',
|
||||||
|
fn: 'flux',
|
||||||
|
fnAliases: ['flux_dev', 'flux_1_dev', 'flux1_dev', 'flux_image'],
|
||||||
|
aspectRatios: ['1:1', '4:3', '3:4', '16:9', '9:16'],
|
||||||
|
defaultSteps: 28,
|
||||||
|
defaultGuidance: 3.5,
|
||||||
|
tags: ['image', 'flux', 'remote'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'wan2gp:qwen-image',
|
||||||
|
name: 'Qwen Image (Wan2GP)',
|
||||||
|
description: 'Image — Qwen-Image text-to-image served by Wan2GP.',
|
||||||
|
type: 'image',
|
||||||
|
family: 'qwen',
|
||||||
|
provider: 'wan2gp',
|
||||||
|
fn: 'qwen_image',
|
||||||
|
fnAliases: ['qwen', 'qwen_t2i', 'qwen_image_t2i'],
|
||||||
|
aspectRatios: ['1:1', '4:3', '3:4', '16:9', '9:16'],
|
||||||
|
defaultSteps: 30,
|
||||||
|
defaultGuidance: 4.0,
|
||||||
|
tags: ['image', 'qwen', 'remote'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'wan2gp:wan22-t2v',
|
||||||
|
name: 'Wan 2.2 (Text-to-Video)',
|
||||||
|
description: 'Video — Wan 2.2 text-to-video. Slow on consumer GPUs.',
|
||||||
|
type: 'video',
|
||||||
|
family: 'wan',
|
||||||
|
provider: 'wan2gp',
|
||||||
|
fn: 'wan22_t2v',
|
||||||
|
fnAliases: ['wan_2_2_t2v', 'wan22_text2video', 'wan_t2v', 'wan2_2_t2v', 't2v'],
|
||||||
|
aspectRatios: ['16:9', '1:1', '9:16'],
|
||||||
|
defaultSteps: 25,
|
||||||
|
defaultGuidance: 5.0,
|
||||||
|
tags: ['video', 'wan', 'text-to-video'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'wan2gp:wan22-i2v',
|
||||||
|
name: 'Wan 2.2 (Image-to-Video)',
|
||||||
|
description: 'Video — Wan 2.2 image-to-video. Provide a start frame.',
|
||||||
|
type: 'video',
|
||||||
|
family: 'wan',
|
||||||
|
provider: 'wan2gp',
|
||||||
|
fn: 'wan22_i2v',
|
||||||
|
fnAliases: ['wan_2_2_i2v', 'wan22_image2video', 'wan_i2v', 'wan2_2_i2v', 'i2v'],
|
||||||
|
needsImage: true,
|
||||||
|
aspectRatios: ['16:9', '1:1', '9:16'],
|
||||||
|
defaultSteps: 25,
|
||||||
|
defaultGuidance: 5.0,
|
||||||
|
tags: ['video', 'wan', 'image-to-video'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'wan2gp:hunyuan-video',
|
||||||
|
name: 'Hunyuan Video (Wan2GP)',
|
||||||
|
description: 'Video — Hunyuan text-to-video via Wan2GP.',
|
||||||
|
type: 'video',
|
||||||
|
family: 'hunyuan',
|
||||||
|
provider: 'wan2gp',
|
||||||
|
fn: 'hunyuan_video',
|
||||||
|
fnAliases: ['hunyuan', 'hunyuan_t2v', 'hyvideo', 'hy_video'],
|
||||||
|
aspectRatios: ['16:9', '1:1', '9:16'],
|
||||||
|
defaultSteps: 30,
|
||||||
|
defaultGuidance: 6.0,
|
||||||
|
tags: ['video', 'hunyuan'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'wan2gp:ltx-video',
|
||||||
|
name: 'LTX Video (Wan2GP)',
|
||||||
|
description: 'Video — LTX text-to-video. Fastest video option in Wan2GP.',
|
||||||
|
type: 'video',
|
||||||
|
family: 'ltx',
|
||||||
|
provider: 'wan2gp',
|
||||||
|
fn: 'ltx_video',
|
||||||
|
fnAliases: ['ltx', 'ltx_t2v', 'ltxv', 'ltx_v', 'ltx_2', 'ltx2'],
|
||||||
|
aspectRatios: ['16:9', '1:1', '9:16'],
|
||||||
|
defaultSteps: 20,
|
||||||
|
defaultGuidance: 3.0,
|
||||||
|
tags: ['video', 'ltx', 'fast'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function getModelById(id) { return WAN2GP_CATALOG.find(m => m.id === id) || null; }
|
||||||
|
|
||||||
|
// ─── Config ───────────────────────────────────────────────────────────────────
|
||||||
|
function readConfig() {
|
||||||
|
if (!fs.existsSync(CONFIG_FILE)) return { url: '' };
|
||||||
|
try { return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8')); }
|
||||||
|
catch { return { url: '' }; }
|
||||||
|
}
|
||||||
|
function writeConfig(cfg) { fs.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2)); }
|
||||||
|
function normalizeUrl(url) { return (url || '').trim().replace(/\/+$/, ''); }
|
||||||
|
|
||||||
|
// ─── State ────────────────────────────────────────────────────────────────────
|
||||||
|
let activeAbort = null;
|
||||||
|
|
||||||
|
// Map of uploaded source URL → { path, url, orig_name } so generate() can
|
||||||
|
// rehydrate the Gradio file descriptor when the renderer passes the URL back.
|
||||||
|
const uploadedFiles = new Map();
|
||||||
|
|
||||||
|
// Per-base cache of resolved api_names. Populated by probe(); consumed by
|
||||||
|
// listModels() and generate(). Without this we'd hit FnIndexInferError on
|
||||||
|
// every call because Wan2GP's api_name strings drift between releases.
|
||||||
|
// Shape: Map<baseUrl, { apiNames: string[], resolved: Map<modelId, string|null> }>
|
||||||
|
const fnResolutionCache = new Map();
|
||||||
|
|
||||||
|
// ─── HTTP helpers ─────────────────────────────────────────────────────────────
|
||||||
|
function httpJson(urlStr, { method = 'GET', body = null, timeoutMs = 5000 } = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const u = new URL(urlStr);
|
||||||
|
const mod = u.protocol === 'https:' ? https : http;
|
||||||
|
const headers = body ? { 'Content-Type': 'application/json' } : {};
|
||||||
|
const req = mod.request({
|
||||||
|
hostname: u.hostname, port: u.port, path: u.pathname + u.search,
|
||||||
|
method, headers,
|
||||||
|
}, (res) => {
|
||||||
|
let buf = '';
|
||||||
|
res.on('data', (d) => { buf += d; });
|
||||||
|
res.on('end', () => resolve({ status: res.statusCode, body: buf }));
|
||||||
|
res.on('error', reject);
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
req.setTimeout(timeoutMs, () => req.destroy(new Error('Request timed out')));
|
||||||
|
if (body) req.write(body);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull the list of registered api_names from Gradio's /info endpoint.
|
||||||
|
// Gradio v4 exposes { named_endpoints: { "/api_name": {...}, ... } }; older
|
||||||
|
// builds use the legacy /api or a flat dependencies array. We try /info first,
|
||||||
|
// fall back to /api, and finally to /config['dependencies']. Returns an array
|
||||||
|
// of bare api_name strings (no leading slash). Empty array means we couldn't
|
||||||
|
// discover any — the server is reachable but doesn't expose a name index.
|
||||||
|
async function fetchApiNames(base) {
|
||||||
|
const candidates = [`${base}/info`, `${base}/api`, `${base}/gradio_api/info`];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
try {
|
||||||
|
const res = await httpJson(candidate, { timeoutMs: 8000 });
|
||||||
|
if (res.status !== 200) continue;
|
||||||
|
const parsed = JSON.parse(res.body);
|
||||||
|
const named = parsed.named_endpoints || parsed.unnamed_endpoints || parsed;
|
||||||
|
if (named && typeof named === 'object') {
|
||||||
|
const keys = Object.keys(named).filter(k => k.startsWith('/'));
|
||||||
|
if (keys.length) return keys.map(k => k.replace(/^\/+/, ''));
|
||||||
|
}
|
||||||
|
} catch { /* try next */ }
|
||||||
|
}
|
||||||
|
// Legacy fallback — /config exposes dependencies[] with api_name fields.
|
||||||
|
try {
|
||||||
|
const res = await httpJson(`${base}/config`, { timeoutMs: 5000 });
|
||||||
|
if (res.status === 200) {
|
||||||
|
const cfg = JSON.parse(res.body);
|
||||||
|
const deps = Array.isArray(cfg.dependencies) ? cfg.dependencies : [];
|
||||||
|
return deps.map(d => d.api_name).filter(n => typeof n === 'string' && n && n !== 'false');
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map each catalog model to a real api_name on this server. Strategy:
|
||||||
|
// 1. exact match on `fn`
|
||||||
|
// 2. exact match on any `fnAliases` entry
|
||||||
|
// 3. fuzzy: api_name contains the model's `family` substring
|
||||||
|
// (e.g. catalog wants `ltx_video`; server registered `ltx_2_t2v` → match)
|
||||||
|
// Returns { resolved: Map<modelId, string|null>, apiNames: string[] }.
|
||||||
|
function resolveFnNames(apiNames) {
|
||||||
|
const set = new Set(apiNames);
|
||||||
|
const resolved = new Map();
|
||||||
|
for (const m of WAN2GP_CATALOG) {
|
||||||
|
let hit = null;
|
||||||
|
if (set.has(m.fn)) hit = m.fn;
|
||||||
|
if (!hit && Array.isArray(m.fnAliases)) {
|
||||||
|
for (const a of m.fnAliases) { if (set.has(a)) { hit = a; break; } }
|
||||||
|
}
|
||||||
|
if (!hit && m.family) {
|
||||||
|
// Prefer names that also match the type (image vs video keyword).
|
||||||
|
const typeHint = m.type === 'video' ? /(video|t2v|i2v|v2v)/i : /(image|t2i|txt2img)/i;
|
||||||
|
const fuzzy = apiNames.find(n => n.toLowerCase().includes(m.family) && typeHint.test(n))
|
||||||
|
|| apiNames.find(n => n.toLowerCase().includes(m.family));
|
||||||
|
if (fuzzy) hit = fuzzy;
|
||||||
|
}
|
||||||
|
resolved.set(m.id, hit);
|
||||||
|
}
|
||||||
|
return { resolved, apiNames };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probe(url) {
|
||||||
|
const base = normalizeUrl(url);
|
||||||
|
if (!base) return { ok: false, error: 'URL is empty' };
|
||||||
|
try {
|
||||||
|
const res = await httpJson(`${base}/config`, { timeoutMs: 5000 });
|
||||||
|
if (res.status !== 200) return { ok: false, error: `HTTP ${res.status} from /config — is this a Gradio server?` };
|
||||||
|
const cfg = JSON.parse(res.body);
|
||||||
|
const apiNames = await fetchApiNames(base);
|
||||||
|
const { resolved } = resolveFnNames(apiNames);
|
||||||
|
fnResolutionCache.set(base, { apiNames, resolved });
|
||||||
|
const matched = [...resolved.values()].filter(Boolean).length;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
version: cfg.version || 'unknown',
|
||||||
|
apiNames,
|
||||||
|
matchedModels: matched,
|
||||||
|
totalModels: WAN2GP_CATALOG.length,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: e.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Upload (Gradio v4 /upload) ───────────────────────────────────────────────
|
||||||
|
// Renderer hands us { name, type, bytes:Uint8Array }. We POST as multipart to
|
||||||
|
// <base>/upload?upload_id=<id>; Gradio replies with an array of server paths.
|
||||||
|
// We expose those as a stable HTTP URL the renderer can preview AND stash the
|
||||||
|
// raw path for generate() to feed back into Gradio's file descriptor.
|
||||||
|
async function uploadFile({ name, type, bytes }) {
|
||||||
|
const { url } = readConfig();
|
||||||
|
if (!url) throw new Error('Wan2GP server URL not set. Open Settings → Local Models to configure.');
|
||||||
|
const base = normalizeUrl(url);
|
||||||
|
|
||||||
|
if (!bytes || !bytes.length) throw new Error('Empty file payload');
|
||||||
|
const safeName = name || 'upload.bin';
|
||||||
|
const mime = type || 'application/octet-stream';
|
||||||
|
|
||||||
|
const blob = new Blob([new Uint8Array(bytes)], { type: mime });
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('files', blob, safeName);
|
||||||
|
|
||||||
|
const uploadId = Math.random().toString(36).slice(2, 12);
|
||||||
|
const res = await fetch(`${base}/upload?upload_id=${uploadId}`, { method: 'POST', body: form });
|
||||||
|
if (!res.ok) throw new Error(`Wan2GP upload failed: HTTP ${res.status}`);
|
||||||
|
|
||||||
|
const paths = await res.json();
|
||||||
|
const path = Array.isArray(paths) ? paths[0] : paths;
|
||||||
|
if (!path || typeof path !== 'string') throw new Error('Wan2GP upload returned no path');
|
||||||
|
|
||||||
|
const fileUrl = `${base}/file=${path.replace(/^\/+/, '')}`;
|
||||||
|
uploadedFiles.set(fileUrl, { path, url: fileUrl, orig_name: safeName, mime_type: mime });
|
||||||
|
return { url: fileUrl, path };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listModels() {
|
||||||
|
const { url } = readConfig();
|
||||||
|
if (!url) return WAN2GP_CATALOG.map(m => ({ ...m, ready: false, unavailableReason: 'Wan2GP URL not set' }));
|
||||||
|
const base = normalizeUrl(url);
|
||||||
|
const probeRes = await probe(url); // populates fnResolutionCache
|
||||||
|
const cached = fnResolutionCache.get(base);
|
||||||
|
return WAN2GP_CATALOG.map(m => {
|
||||||
|
if (!probeRes.ok) return { ...m, ready: false, unavailableReason: probeRes.error };
|
||||||
|
const realFn = cached?.resolved.get(m.id) || null;
|
||||||
|
if (!realFn) {
|
||||||
|
return {
|
||||||
|
...m,
|
||||||
|
ready: false,
|
||||||
|
unavailableReason: `Wan2GP server has no api_name matching "${m.fn}". Check Wan2GP version or load this model in its UI.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ...m, ready: true, fn: realFn };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Generate ─────────────────────────────────────────────────────────────────
|
||||||
|
function arToDimensions(ar) {
|
||||||
|
const base = 1024;
|
||||||
|
const map = {
|
||||||
|
'1:1': [base, base],
|
||||||
|
'16:9': [Math.round(base * 16 / 9 / 64) * 64, base],
|
||||||
|
'9:16': [base, Math.round(base * 16 / 9 / 64) * 64],
|
||||||
|
'4:3': [Math.round(base * 4 / 3 / 64) * 64, base],
|
||||||
|
'3:4': [base, Math.round(base * 4 / 3 / 64) * 64],
|
||||||
|
};
|
||||||
|
return map[ar] || [base, base];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gradio v4 protocol: POST /gradio_api/call/<fn> → { event_id }
|
||||||
|
// GET /gradio_api/call/<fn>/<event_id> → SSE stream
|
||||||
|
async function gradioCall(base, fn, payload, onProgress, signal) {
|
||||||
|
const post = await fetch(`${base}/gradio_api/call/${fn}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
if (!post.ok) throw new Error(`Wan2GP POST /call/${fn} → HTTP ${post.status}`);
|
||||||
|
const { event_id } = await post.json();
|
||||||
|
if (!event_id) throw new Error('Wan2GP did not return an event_id');
|
||||||
|
|
||||||
|
const stream = await fetch(`${base}/gradio_api/call/${fn}/${event_id}`, { signal });
|
||||||
|
if (!stream.ok) throw new Error(`Wan2GP stream → HTTP ${stream.status}`);
|
||||||
|
|
||||||
|
const reader = stream.body.getReader();
|
||||||
|
const dec = new TextDecoder();
|
||||||
|
let buf = '';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buf += dec.decode(value, { stream: true });
|
||||||
|
|
||||||
|
const blocks = buf.split('\n\n');
|
||||||
|
buf = blocks.pop();
|
||||||
|
for (const block of blocks) {
|
||||||
|
const eMatch = block.match(/event:\s*(\S+)/);
|
||||||
|
const dMatch = block.match(/data:\s*(.*)$/s);
|
||||||
|
if (!eMatch) continue;
|
||||||
|
const evt = eMatch[1];
|
||||||
|
const dataStr = dMatch ? dMatch[1].trim() : '';
|
||||||
|
|
||||||
|
if (evt === 'generating' || evt === 'process_generating') {
|
||||||
|
const m = dataStr.match(/"progress":\s*([\d.]+)/);
|
||||||
|
if (m && onProgress) onProgress(parseFloat(m[1]));
|
||||||
|
} else if (evt === 'complete' || evt === 'process_completes') {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(dataStr);
|
||||||
|
return Array.isArray(parsed) ? parsed : (parsed.data || parsed);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Wan2GP returned malformed completion: ${dataStr.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
} else if (evt === 'error' || evt === 'process_error') {
|
||||||
|
throw new Error(`Wan2GP error: ${dataStr.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('Wan2GP stream ended without a completion event');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveOutputUrl(base, output) {
|
||||||
|
const first = Array.isArray(output) ? output[0] : output;
|
||||||
|
if (!first) return null;
|
||||||
|
if (typeof first === 'string') {
|
||||||
|
return first.startsWith('http') ? first : `${base}/file=${first.replace(/^\/+/, '')}`;
|
||||||
|
}
|
||||||
|
if (first.url) return first.url.startsWith('http') ? first.url : `${base}${first.url}`;
|
||||||
|
if (first.path) return `${base}/file=${first.path.replace(/^\/+/, '')}`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generate(params, mainWindow) {
|
||||||
|
const { url } = readConfig();
|
||||||
|
if (!url) throw new Error('Wan2GP server URL not set. Open Settings → Local Models to configure.');
|
||||||
|
const base = normalizeUrl(url);
|
||||||
|
|
||||||
|
const model = getModelById(params.model);
|
||||||
|
if (!model) throw new Error(`Unknown Wan2GP model: ${params.model}`);
|
||||||
|
|
||||||
|
const send = (data) => mainWindow?.webContents.send('local-ai:progress', data);
|
||||||
|
send({ status: 'starting', progress: 0 });
|
||||||
|
|
||||||
|
const [width, height] = arToDimensions(params.aspect_ratio || '1:1');
|
||||||
|
const seed = params.seed && params.seed !== -1 ? params.seed : Math.floor(Math.random() * 2147483647);
|
||||||
|
const steps = params.steps ?? model.defaultSteps;
|
||||||
|
const guidance = params.guidance_scale ?? model.defaultGuidance;
|
||||||
|
|
||||||
|
// Image input → resolve to a Gradio file descriptor if we uploaded it.
|
||||||
|
let imageDescriptor = null;
|
||||||
|
if (params.image) {
|
||||||
|
const cached = uploadedFiles.get(params.image);
|
||||||
|
if (cached) {
|
||||||
|
imageDescriptor = { path: cached.path, url: cached.url, orig_name: cached.orig_name, mime_type: cached.mime_type, meta: { _type: 'gradio.FileData' } };
|
||||||
|
} else if (typeof params.image === 'string') {
|
||||||
|
imageDescriptor = params.image; // raw URL — Gradio fetches it
|
||||||
|
} else {
|
||||||
|
imageDescriptor = params.image;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (model.needsImage && !imageDescriptor) {
|
||||||
|
throw new Error(`${model.name} requires a start-frame image — upload one first.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic positional input — adjust upstream `fn` if signature differs.
|
||||||
|
const payload = {
|
||||||
|
data: [
|
||||||
|
params.prompt || '',
|
||||||
|
params.negative_prompt || '',
|
||||||
|
width, height, steps, guidance, seed,
|
||||||
|
imageDescriptor,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resolve to whatever api_name the server actually exposes. Falls back to
|
||||||
|
// the catalog default so a user with a current Wan2GP build still works
|
||||||
|
// even if /info isn't reachable.
|
||||||
|
let cached = fnResolutionCache.get(base);
|
||||||
|
if (!cached) { await probe(url); cached = fnResolutionCache.get(base); }
|
||||||
|
const realFn = cached?.resolved.get(model.id) || model.fn;
|
||||||
|
if (cached && cached.apiNames.length && !cached.resolved.get(model.id)) {
|
||||||
|
const sample = cached.apiNames.slice(0, 8).join(', ');
|
||||||
|
throw new Error(
|
||||||
|
`${model.name}: Wan2GP server doesn't expose an api_name matching "${model.fn}". ` +
|
||||||
|
`Available endpoints: ${sample}${cached.apiNames.length > 8 ? '…' : ''}. ` +
|
||||||
|
`Make sure the model is loaded in Wan2GP, or update Wan2GP to a build that registers this api_name.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ac = new AbortController();
|
||||||
|
activeAbort = ac;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await gradioCall(base, realFn, payload, (p) => {
|
||||||
|
send({ status: 'generating', progress: p });
|
||||||
|
}, ac.signal);
|
||||||
|
activeAbort = null;
|
||||||
|
|
||||||
|
const mediaUrl = resolveOutputUrl(base, result);
|
||||||
|
if (!mediaUrl) throw new Error(`Wan2GP returned unrecognized output: ${JSON.stringify(result).slice(0, 200)}`);
|
||||||
|
|
||||||
|
send({ status: 'done', progress: 1 });
|
||||||
|
return { url: mediaUrl, mediaType: model.type, seed };
|
||||||
|
} catch (e) {
|
||||||
|
activeAbort = null;
|
||||||
|
if (e.name === 'AbortError') throw new Error('Generation cancelled');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelGeneration() {
|
||||||
|
if (activeAbort) {
|
||||||
|
activeAbort.abort();
|
||||||
|
activeAbort = null;
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── IPC ──────────────────────────────────────────────────────────────────────
|
||||||
|
function getMainWindow() { return BrowserWindow.getAllWindows()[0] || null; }
|
||||||
|
|
||||||
|
function register() {
|
||||||
|
ipcMain.handle('wan2gp:get-config', () => readConfig());
|
||||||
|
ipcMain.handle('wan2gp:set-url', (_, url) => { writeConfig({ url: normalizeUrl(url) }); return { ok: true }; });
|
||||||
|
ipcMain.handle('wan2gp:probe', (_, url) => probe(url));
|
||||||
|
ipcMain.handle('wan2gp:list-models', () => listModels());
|
||||||
|
ipcMain.handle('wan2gp:generate', (_, params) => generate(params, getMainWindow()));
|
||||||
|
ipcMain.handle('wan2gp:cancel-generation', () => cancelGeneration());
|
||||||
|
ipcMain.handle('wan2gp:upload-file', (_, payload) => uploadFile(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { register, WAN2GP_CATALOG };
|
||||||
|
|
@ -1,33 +1,49 @@
|
||||||
import { app, BrowserWindow, shell } from 'electron';
|
const { app, BrowserWindow, shell } = require('electron');
|
||||||
import { fileURLToPath } from 'url';
|
const path = require('path');
|
||||||
import path from 'path';
|
const { register: registerLocalInference } = require('./lib/localInference');
|
||||||
|
const { register: registerWan2gp } = require('./lib/wan2gpProvider');
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
// Ubuntu 24.04+ sets kernel.apparmor_restrict_unprivileged_userns=1 which
|
||||||
const __dirname = path.dirname(__filename);
|
// blocks Chromium's user namespace sandbox. The .deb package ships an AppArmor
|
||||||
|
// profile that grants the permission cleanly. When running the AppImage on an
|
||||||
|
// affected system, run once: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||||
|
// or pass --no-sandbox on the command line.
|
||||||
|
if (process.platform === 'linux') {
|
||||||
|
app.commandLine.appendSwitch('disable-dev-shm-usage');
|
||||||
|
}
|
||||||
|
|
||||||
let mainWindow;
|
let mainWindow;
|
||||||
|
|
||||||
function createWindow() {
|
function createWindow() {
|
||||||
|
const isMac = process.platform === 'darwin';
|
||||||
|
|
||||||
mainWindow = new BrowserWindow({
|
mainWindow = new BrowserWindow({
|
||||||
width: 1440,
|
width: 1440,
|
||||||
height: 900,
|
height: 900,
|
||||||
minWidth: 1024,
|
minWidth: 1024,
|
||||||
minHeight: 640,
|
minHeight: 640,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
webSecurity: false, // Allow file:// origin to call external APIs
|
webSecurity: false,
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
|
preload: path.join(__dirname, 'preload.js'),
|
||||||
},
|
},
|
||||||
titleBarStyle: 'hiddenInset',
|
...(isMac ? { titleBarStyle: 'hiddenInset' } : {}),
|
||||||
backgroundColor: '#0d0d0d',
|
backgroundColor: '#0d0d0d',
|
||||||
show: false,
|
show: false,
|
||||||
title: 'Open Higgsfield AI',
|
title: 'Open Generative AI',
|
||||||
});
|
});
|
||||||
|
|
||||||
const indexPath = path.join(__dirname, '../dist/index.html');
|
const indexPath = path.join(__dirname, '../dist/index.html');
|
||||||
mainWindow.loadFile(indexPath);
|
mainWindow.loadFile(indexPath).catch((err) => {
|
||||||
|
console.error('Failed to load index.html:', err);
|
||||||
|
mainWindow.show();
|
||||||
|
});
|
||||||
|
|
||||||
|
mainWindow.webContents.on('did-fail-load', (event, code, desc) => {
|
||||||
|
console.error('did-fail-load:', code, desc);
|
||||||
|
});
|
||||||
|
|
||||||
// Open external links in the system browser
|
|
||||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
shell.openExternal(url);
|
shell.openExternal(url);
|
||||||
return { action: 'deny' };
|
return { action: 'deny' };
|
||||||
|
|
@ -44,6 +60,8 @@ function createWindow() {
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
createWindow();
|
createWindow();
|
||||||
|
registerLocalInference();
|
||||||
|
registerWan2gp();
|
||||||
|
|
||||||
app.on('activate', () => {
|
app.on('activate', () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
|
|
|
||||||
41
electron/preload.js
Normal file
41
electron/preload.js
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
const { contextBridge, ipcRenderer } = require('electron');
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('localAI', {
|
||||||
|
isElectron: true,
|
||||||
|
|
||||||
|
// ── sd.cpp engine ──────────────────────────────────────────────────────
|
||||||
|
getBinaryStatus: () => ipcRenderer.invoke('local-ai:binary-status'),
|
||||||
|
downloadBinary: () => ipcRenderer.invoke('local-ai:download-binary'),
|
||||||
|
|
||||||
|
listModels: () => ipcRenderer.invoke('local-ai:list-models'),
|
||||||
|
downloadModel: (modelId) => ipcRenderer.invoke('local-ai:download-model', modelId),
|
||||||
|
downloadAuxiliary: (auxKey) => ipcRenderer.invoke('local-ai:download-auxiliary', auxKey),
|
||||||
|
deleteModel: (modelId) => ipcRenderer.invoke('local-ai:delete-model', modelId),
|
||||||
|
cancelDownload: (modelId) => ipcRenderer.invoke('local-ai:cancel-download', modelId),
|
||||||
|
|
||||||
|
generate: (params) => ipcRenderer.invoke('local-ai:generate', params),
|
||||||
|
cancelGeneration: () => ipcRenderer.invoke('local-ai:cancel-generation'),
|
||||||
|
|
||||||
|
// ── Wan2GP engine (remote Gradio server) ───────────────────────────────
|
||||||
|
wan2gp: {
|
||||||
|
getConfig: () => ipcRenderer.invoke('wan2gp:get-config'),
|
||||||
|
setUrl: (url) => ipcRenderer.invoke('wan2gp:set-url', url),
|
||||||
|
probe: (url) => ipcRenderer.invoke('wan2gp:probe', url),
|
||||||
|
listModels: () => ipcRenderer.invoke('wan2gp:list-models'),
|
||||||
|
generate: (params) => ipcRenderer.invoke('wan2gp:generate', params),
|
||||||
|
cancelGeneration: () => ipcRenderer.invoke('wan2gp:cancel-generation'),
|
||||||
|
uploadFile: (payload) => ipcRenderer.invoke('wan2gp:upload-file', payload),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Progress events — both engines emit on local-ai:progress
|
||||||
|
onProgress: (callback) => {
|
||||||
|
const listener = (_, data) => callback(data);
|
||||||
|
ipcRenderer.on('local-ai:progress', listener);
|
||||||
|
return () => ipcRenderer.removeListener('local-ai:progress', listener);
|
||||||
|
},
|
||||||
|
onDownloadProgress: (callback) => {
|
||||||
|
const listener = (_, data) => callback(data);
|
||||||
|
ipcRenderer.on('local-ai:download-progress', listener);
|
||||||
|
return () => ipcRenderer.removeListener('local-ai:download-progress', listener);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -4,9 +4,9 @@
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="description" content="Open Higgsfield AI — the free, open-source alternative to Higgsfield AI. Generate AI images and cinematic shots with 20+ models including Flux, SDXL, Ideogram, and Midjourney." />
|
<meta name="description" content="Open Generative AI — the free, open-source alternative to Higgsfield AI. Generate AI images and cinematic shots with 20+ models including Flux, SDXL, Ideogram, and Midjourney." />
|
||||||
<meta name="keywords" content="higgsfield ai, higgsfield alternative, open source higgsfield, ai image generator, ai cinema studio, flux ai, ai video generation, free higgsfield, open source ai image generation" />
|
<meta name="keywords" content="higgsfield ai, higgsfield alternative, open source higgsfield, ai image generator, ai cinema studio, flux ai, ai video generation, free higgsfield, open source ai image generation" />
|
||||||
<title>Open Higgsfield AI — Free Open-Source Alternative to Higgsfield AI</title>
|
<title>Open Generative AI — Free Open-Source Alternative to Higgsfield AI</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./*"]
|
"@/*": ["./*"],
|
||||||
|
"ai-agent": ["./packages/Open-Poe-AI/packages/agents/src/index.js"],
|
||||||
|
"workflow-builder": ["./packages/Vibe-Workflow/packages/workflow-builder/src/index.js"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
31
middleware.js
Normal file
31
middleware.js
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export function middleware(request) {
|
||||||
|
const url = request.nextUrl;
|
||||||
|
|
||||||
|
// Catch requests to /api/workflow, /api/app, and /api/v1
|
||||||
|
const isMuApi = url.pathname.startsWith('/api/workflow') ||
|
||||||
|
url.pathname.startsWith('/api/app') ||
|
||||||
|
url.pathname.startsWith('/api/v1');
|
||||||
|
|
||||||
|
if (isMuApi) {
|
||||||
|
// Remap /api/v1 ONLY if it's not handled by a specific route.
|
||||||
|
// Actually, we'll let existing remapping for /api/v1 stay if needed,
|
||||||
|
// but we'll remove app/workflow as they need special handling.
|
||||||
|
if (url.pathname.startsWith('/api/v1')) {
|
||||||
|
const targetUrl = new URL(url.pathname + url.search, 'https://api.muapi.ai');
|
||||||
|
return NextResponse.rewrite(targetUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match the paths we want to proxy
|
||||||
|
export const config = {
|
||||||
|
matcher: [
|
||||||
|
'/api/workflow/:path*',
|
||||||
|
'/api/app/:path*',
|
||||||
|
'/api/v1/:path*'
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
@ -1819,6 +1819,50 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "gpt-image-2",
|
||||||
|
"name": "Gpt Image 2",
|
||||||
|
"endpoint": "gpt-image-2-text-to-image",
|
||||||
|
"family": "gpt-2",
|
||||||
|
"inputs": {
|
||||||
|
"prompt": {
|
||||||
|
"examples": [
|
||||||
|
"A photorealistic product photo of a luxury watch resting on a slab of black marble, dramatic cinematic lighting with a soft rim glow, ultra-detailed metallic textures, shallow depth of field, studio quality."
|
||||||
|
],
|
||||||
|
"description": "Text prompt describing the image. Up to 20,000 characters supported.",
|
||||||
|
"type": "string",
|
||||||
|
"title": "Prompt",
|
||||||
|
"name": "prompt"
|
||||||
|
},
|
||||||
|
"aspect_ratio": {
|
||||||
|
"enum": [
|
||||||
|
"auto",
|
||||||
|
"1:1",
|
||||||
|
"16:9",
|
||||||
|
"9:16",
|
||||||
|
"4:3",
|
||||||
|
"3:4"
|
||||||
|
],
|
||||||
|
"title": "Aspect Ratio",
|
||||||
|
"name": "aspect_ratio",
|
||||||
|
"type": "string",
|
||||||
|
"description": "Aspect ratio of the output image.",
|
||||||
|
"default": "auto"
|
||||||
|
},
|
||||||
|
"resolution": {
|
||||||
|
"enum": [
|
||||||
|
"1K",
|
||||||
|
"2K",
|
||||||
|
"4K"
|
||||||
|
],
|
||||||
|
"title": "Resolution",
|
||||||
|
"name": "resolution",
|
||||||
|
"type": "string",
|
||||||
|
"description": "The target resolution of the generated image.",
|
||||||
|
"default": "2K"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "wan2.6-text-to-image",
|
"id": "wan2.6-text-to-image",
|
||||||
"name": "Wan2.6 Text To Image",
|
"name": "Wan2.6 Text To Image",
|
||||||
|
|
@ -2001,6 +2045,48 @@
|
||||||
"step": 0.01
|
"step": 0.01
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "minimax-image-01",
|
||||||
|
"name": "MiniMax Image 01",
|
||||||
|
"inputs": {
|
||||||
|
"prompt": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Prompt",
|
||||||
|
"name": "prompt",
|
||||||
|
"description": "Text prompt describing the image to generate (max 1500 characters).",
|
||||||
|
"examples": [
|
||||||
|
"A serene mountain lake at sunset with golden reflections on the water, surrounded by pine forests and snow-capped peaks, photorealistic, 8k."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"aspect_ratio": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Aspect Ratio",
|
||||||
|
"name": "aspect_ratio",
|
||||||
|
"description": "Aspect ratio of the output image.",
|
||||||
|
"enum": [
|
||||||
|
"16:9",
|
||||||
|
"9:16",
|
||||||
|
"1:1",
|
||||||
|
"4:3",
|
||||||
|
"3:4",
|
||||||
|
"3:2",
|
||||||
|
"2:3",
|
||||||
|
"21:9"
|
||||||
|
],
|
||||||
|
"default": "1:1"
|
||||||
|
},
|
||||||
|
"num_images": {
|
||||||
|
"type": "int",
|
||||||
|
"title": "Number of images",
|
||||||
|
"name": "num_images",
|
||||||
|
"description": "Number of images to generate in a single request.",
|
||||||
|
"default": 1,
|
||||||
|
"minValue": 1,
|
||||||
|
"maxValue": 4,
|
||||||
|
"step": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
transpilePackages: ['studio'],
|
transpilePackages: ['studio', 'ai-agent', 'workflow-builder'],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|
|
||||||
5093
package-lock.json
generated
5093
package-lock.json
generated
File diff suppressed because it is too large
Load diff
95
package.json
95
package.json
|
|
@ -1,52 +1,113 @@
|
||||||
{
|
{
|
||||||
"name": "open-higgsfield-ai",
|
"name": "open-generative-ai",
|
||||||
"description": "Open-source alternative to Higgsfield AI — AI image, video, cinema and lip sync studio",
|
"description": "Open-source alternative to Higgsfield AI — AI image, video, cinema and lip sync studio",
|
||||||
|
"homepage": "https://github.com/Anil-matcha/Open-Generative-AI",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0",
|
"version": "1.0.10",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/studio"
|
"packages/studio",
|
||||||
|
"packages/Vibe-Workflow/packages/workflow-builder",
|
||||||
|
"packages/Open-Poe-AI/packages/agents"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"build:studio": "cd packages/studio && npm run build",
|
"build:studio": "npm run build -w studio",
|
||||||
"setup": "npm install && npm run build:studio",
|
"build:workflow": "npm run build -w workflow-builder",
|
||||||
|
"build:agent": "npm run build -w ai-agent",
|
||||||
|
"build:packages": "npm run build:workflow && npm run build:agent && npm run build:studio",
|
||||||
|
"setup": "git submodule update --init --recursive && npm install && npm run build:packages",
|
||||||
"vite:dev": "vite",
|
"vite:dev": "vite",
|
||||||
"vite:build": "vite build",
|
"vite:build": "vite build",
|
||||||
|
"electron:dev": "npm run vite:build && electron .",
|
||||||
"electron:build": "vite build && electron-builder --mac",
|
"electron:build": "vite build && electron-builder --mac",
|
||||||
"electron:build:win": "vite build && electron-builder --win",
|
"electron:build:win": "vite build && electron-builder --win",
|
||||||
"electron:build:all": "vite build && electron-builder --mac --win"
|
"electron:build:linux": "vite build && electron-builder --linux",
|
||||||
|
"electron:build:all": "vite build && electron-builder --mac --win --linux"
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"appId": "ai.higgsfield.open",
|
"appId": "ai.generative.open",
|
||||||
"productName": "Open Higgsfield AI",
|
"productName": "Open Generative AI",
|
||||||
"copyright": "Copyright © 2025",
|
"copyright": "Copyright © 2025",
|
||||||
"directories": { "output": "release" },
|
"directories": {
|
||||||
|
"output": "release"
|
||||||
|
},
|
||||||
"afterPack": "./afterPack.js",
|
"afterPack": "./afterPack.js",
|
||||||
"files": ["dist/**/*", "electron/**/*"],
|
"files": [
|
||||||
|
"dist/**/*",
|
||||||
|
"electron/**/*"
|
||||||
|
],
|
||||||
"mac": {
|
"mac": {
|
||||||
"category": "public.app-category.graphics-design",
|
"category": "public.app-category.graphics-design",
|
||||||
"icon": "public/banner.png",
|
"icon": "public/banner.png",
|
||||||
"gatekeeperAssess": false,
|
"gatekeeperAssess": false,
|
||||||
"target": [{ "target": "dmg", "arch": ["x64", "arm64"] }]
|
"target": [
|
||||||
|
{
|
||||||
|
"target": "dmg",
|
||||||
|
"arch": [
|
||||||
|
"x64",
|
||||||
|
"arm64"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"win": {
|
"win": {
|
||||||
"icon": "public/banner.png",
|
"icon": "public/banner.png",
|
||||||
"target": [{ "target": "nsis", "arch": ["x64", "arm64"] }]
|
"target": [
|
||||||
|
{
|
||||||
|
"target": "nsis",
|
||||||
|
"arch": [
|
||||||
|
"x64"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nsis": {
|
||||||
|
"oneClick": false,
|
||||||
|
"allowToChangeInstallationDirectory": true,
|
||||||
|
"include": "build/installer.nsh"
|
||||||
|
},
|
||||||
|
"linux": {
|
||||||
|
"icon": "public/banner.png",
|
||||||
|
"category": "Utility",
|
||||||
|
"maintainer": "Open Generative AI Team",
|
||||||
|
"extraFiles": [
|
||||||
|
{
|
||||||
|
"from": "build/linux/apparmor.profile",
|
||||||
|
"to": "resources/apparmor.profile"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"target": [
|
||||||
|
{
|
||||||
|
"target": "AppImage",
|
||||||
|
"arch": [
|
||||||
|
"x64"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"target": "deb",
|
||||||
|
"arch": [
|
||||||
|
"x64"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"axios": "^1.7.0",
|
||||||
"next": "^15.0.0",
|
"next": "^15.0.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"react-hot-toast": "^2.4.1",
|
||||||
"studio": "*",
|
"studio": "*",
|
||||||
"axios": "^1.7.0",
|
"workflow-builder": "file:./packages/Vibe-Workflow/packages/workflow-builder",
|
||||||
"react-hot-toast": "^2.4.1"
|
"ai-agent": "file:./packages/Open-Poe-AI/packages/agents"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
"@eslint/eslintrc": "^3",
|
||||||
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"autoprefixer": "^10.4.24",
|
"autoprefixer": "^10.4.24",
|
||||||
"electron": "^33.4.11",
|
"electron": "^33.4.11",
|
||||||
"electron-builder": "^25.1.8",
|
"electron-builder": "^25.1.8",
|
||||||
|
|
@ -54,7 +115,7 @@
|
||||||
"eslint-config-next": "^15.0.0",
|
"eslint-config-next": "^15.0.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^3.4.0",
|
"tailwindcss": "^3.4.0",
|
||||||
"vite": "^5.4.0",
|
"vite": "^5.4.0"
|
||||||
"@tailwindcss/vite": "^4.1.18"
|
},
|
||||||
}
|
"main": "electron/main.js"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1
packages/Open-Poe-AI
Submodule
1
packages/Open-Poe-AI
Submodule
|
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit cb12973823b15a50329ff34ed28491c73681a2ab
|
||||||
1
packages/Vibe-Workflow
Submodule
1
packages/Vibe-Workflow
Submodule
|
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit 41a2da7d713d4bc92180ef248dad3cec8c3b0bea
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "studio",
|
"name": "studio",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Open Higgsfield AI studio components for Muapi",
|
"description": "Open Generative AI studio components for Muapi",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"module": "src/index.js",
|
"module": "src/index.js",
|
||||||
"files": [
|
"files": [
|
||||||
|
|
@ -14,9 +14,18 @@
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@xyflow/react": "^12.10.2",
|
||||||
"axios": "^1.7.0",
|
"axios": "^1.7.0",
|
||||||
|
"lucide-react": "^1.8.0",
|
||||||
|
"react-hot-toast": "^2.4.1",
|
||||||
"react-icons": "^5.0.1",
|
"react-icons": "^5.0.1",
|
||||||
"react-hot-toast": "^2.4.1"
|
"react-markdown": "^10.1.0",
|
||||||
|
"react-syntax-highlighter": "^16.1.1",
|
||||||
|
"react-toastify": "^11.1.0",
|
||||||
|
"reactflow": "^11.11.4",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
|
"workflow-builder": "file:../Vibe-Workflow/packages/workflow-builder",
|
||||||
|
"ai-agent": "file:../Open-Poe-AI/packages/agents"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": ">=18.0.0",
|
"react": ">=18.0.0",
|
||||||
|
|
|
||||||
295
packages/studio/src/components/AgentStudio.jsx
Normal file
295
packages/studio/src/components/AgentStudio.jsx
Normal file
|
|
@ -0,0 +1,295 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import {
|
||||||
|
getTemplateAgents,
|
||||||
|
getUserAgents,
|
||||||
|
getUserConversations,
|
||||||
|
} from "../muapi.js";
|
||||||
|
|
||||||
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
function timeAgo(dateStr) {
|
||||||
|
if (!dateStr) return "";
|
||||||
|
const utcStr =
|
||||||
|
dateStr.endsWith("Z") || dateStr.includes("+") ? dateStr : dateStr + "Z";
|
||||||
|
const diff = Math.floor((Date.now() - new Date(utcStr)) / 1000);
|
||||||
|
if (diff < 60) return "just now";
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||||
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||||
|
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
|
||||||
|
return new Date(utcStr).toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Agent Card (grid) ───────────────────────────────────────────────────────
|
||||||
|
function AgentCard({ agent, onClick, onEdit }) {
|
||||||
|
return (
|
||||||
|
<div className="group relative aspect-[4/5] rounded-xl cursor-pointer">
|
||||||
|
<div
|
||||||
|
onClick={() => onClick(agent)}
|
||||||
|
className="absolute inset-0 rounded-xl overflow-hidden border border-white/5 bg-[#0a0a0a] transition-all group-hover:border-[#d9ff00]/30 group-hover:scale-[1.02] shadow-2xl"
|
||||||
|
>
|
||||||
|
{agent.icon_url ? (
|
||||||
|
<img
|
||||||
|
src={agent.icon_url}
|
||||||
|
alt={agent.name}
|
||||||
|
className="absolute inset-0 w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-violet-500/10 to-fuchsia-500/10 flex items-center justify-center">
|
||||||
|
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="1" className="opacity-20">
|
||||||
|
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/20 to-transparent" />
|
||||||
|
<div className="absolute inset-x-0 bottom-0 p-4">
|
||||||
|
<div className="text-[10px] font-bold text-[#d9ff00] uppercase tracking-wider mb-1 opacity-80">
|
||||||
|
{agent.category || "AI Assistant"}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-sm font-bold text-white truncate group-hover:text-[#d9ff00] transition-colors">
|
||||||
|
{agent.name || "Unnamed Agent"}
|
||||||
|
</h3>
|
||||||
|
{agent.owner_username && (
|
||||||
|
<p className="text-[9px] text-white/40 mt-1 uppercase tracking-tighter font-black">
|
||||||
|
By {agent.owner_username}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{onEdit && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEdit(agent);
|
||||||
|
}}
|
||||||
|
className="absolute top-3 right-3 w-8 h-8 rounded-full bg-black/60 border border-white/10 flex items-center justify-center text-white opacity-0 group-hover:opacity-100 transition-all hover:bg-[#d9ff00] hover:text-black hover:scale-110 z-10"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
|
||||||
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Conversation Card (My Chats) ────────────────────────────────────────────
|
||||||
|
function ConversationCard({ conv, onClick }) {
|
||||||
|
const displayTitle = conv.title || "New Chat";
|
||||||
|
const agentSlug = conv.agent_slug || conv.agent_id;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={() => onClick(agentSlug, conv.id)}
|
||||||
|
className="group flex flex-col gap-3 bg-white/[0.03] border border-white/5 rounded-xl p-4 hover:border-[#d9ff00]/20 hover:bg-white/5 transition-all cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="relative w-10 h-10 rounded-xl overflow-hidden bg-white/5 border border-white/5 shrink-0">
|
||||||
|
{conv.agent_icon_url ? (
|
||||||
|
<img src={conv.agent_icon_url} alt={conv.agent_name || "Agent"} className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center text-white/20">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-[10px] font-black text-[#d9ff00] uppercase tracking-wider truncate">
|
||||||
|
{conv.agent_name || "Unknown Agent"}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-bold text-white truncate" title={displayTitle}>
|
||||||
|
{displayTitle}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between pt-2 border-t border-white/5 mt-auto text-[10px] text-white/30 font-medium">
|
||||||
|
<span>{timeAgo(conv.updated_at)}</span>
|
||||||
|
{conv.message_count != null && <span>{conv.message_count} msgs</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main Component ──────────────────────────────────────────────────────────
|
||||||
|
const TABS = ["templates", "my-agents", "my-chats"];
|
||||||
|
|
||||||
|
export default function AgentStudio({ apiKey }) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [activeMainTab, setActiveMainTab] = useState("templates");
|
||||||
|
const [agents, setAgents] = useState([]);
|
||||||
|
const [conversations, setConversations] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
// Navigate to the standalone /agents page — AiAgent handles its own routing there
|
||||||
|
const handleSelectAgent = useCallback(
|
||||||
|
(agent) => {
|
||||||
|
const id = agent.agent_id || agent.id;
|
||||||
|
router.push(`/agents/${id}`);
|
||||||
|
},
|
||||||
|
[router]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEditAgent = useCallback(
|
||||||
|
(agent) => {
|
||||||
|
const id = agent.agent_id || agent.id;
|
||||||
|
router.push(`/agents/edit/${id}`);
|
||||||
|
},
|
||||||
|
[router]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreateAgent = useCallback(() => {
|
||||||
|
router.push("/agents/create");
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const handleOpenConversation = useCallback(
|
||||||
|
(agentSlug, convId) => {
|
||||||
|
router.push(`/agents/${agentSlug}/${convId}`);
|
||||||
|
},
|
||||||
|
[router]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!apiKey) return;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setAgents([]);
|
||||||
|
setConversations([]);
|
||||||
|
try {
|
||||||
|
if (activeMainTab === "templates") {
|
||||||
|
const data = await getTemplateAgents(apiKey);
|
||||||
|
if (!cancelled) setAgents(data);
|
||||||
|
} else if (activeMainTab === "my-agents") {
|
||||||
|
const data = await getUserAgents(apiKey);
|
||||||
|
if (!cancelled) setAgents(data);
|
||||||
|
} else if (activeMainTab === "my-chats") {
|
||||||
|
const data = await getUserConversations(apiKey);
|
||||||
|
if (!cancelled) setConversations(data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("AgentStudio load error:", err);
|
||||||
|
if (!cancelled) setError(err.message || "Failed to load.");
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [apiKey, activeMainTab]);
|
||||||
|
|
||||||
|
// ── Render ──────────────────────────────────────────────────────────────────
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col bg-[#030303] text-white">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex-shrink-0 h-16 border-b border-white/5 flex items-center justify-between px-8 bg-black/40">
|
||||||
|
<div className="flex items-center gap-8 h-full">
|
||||||
|
<h2 className="text-sm font-black uppercase tracking-[0.2em] text-[#d9ff00]">
|
||||||
|
Agents
|
||||||
|
</h2>
|
||||||
|
<div className="flex gap-1 bg-white/5 p-1 rounded-xl">
|
||||||
|
{TABS.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
onClick={() => setActiveMainTab(tab)}
|
||||||
|
className={`px-4 py-1.5 text-[10px] font-black uppercase tracking-widest rounded-lg transition-all ${
|
||||||
|
activeMainTab === tab
|
||||||
|
? "bg-white text-black shadow-xl"
|
||||||
|
: "text-white/40 hover:text-white hover:bg-white/5"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.replace(/-/g, " ")}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleCreateAgent}
|
||||||
|
className="px-6 py-2 bg-[#d9ff00] text-black text-[10px] font-black uppercase tracking-widest rounded-lg hover:bg-[#ebff66] transition-all active:scale-95 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<span className="text-sm">+</span>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto custom-scrollbar p-8">
|
||||||
|
{loading ? (
|
||||||
|
<div className="h-full flex items-center justify-center">
|
||||||
|
<div className="w-10 h-10 border-2 border-white/5 border-t-[#d9ff00] rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="h-full flex flex-col items-center justify-center text-white/20 gap-4">
|
||||||
|
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<line x1="12" y1="8" x2="12" y2="12" />
|
||||||
|
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveMainTab(activeMainTab)} // retrigger effect
|
||||||
|
className="text-[10px] text-white/40 hover:text-white border border-white/10 px-4 py-2 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : activeMainTab === "my-chats" ? (
|
||||||
|
// ── My Chats view ─────────────────────────────────────────────────
|
||||||
|
conversations.length === 0 ? (
|
||||||
|
<div className="h-full flex flex-col items-center justify-center text-white/10 gap-4">
|
||||||
|
<svg width="60" height="60" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="0.5">
|
||||||
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-[10px] font-black uppercase tracking-[0.3em]">No chats yet</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveMainTab("templates")}
|
||||||
|
className="text-[10px] text-[#d9ff00] hover:text-white border border-[#d9ff00]/20 hover:border-white/20 px-4 py-2 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Browse Templates
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 max-w-[1600px] mx-auto">
|
||||||
|
{conversations.map((conv) => (
|
||||||
|
<ConversationCard
|
||||||
|
key={conv.id}
|
||||||
|
conv={conv}
|
||||||
|
onClick={handleOpenConversation}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
// ── Agents grid (templates / my-agents) ───────────────────────────
|
||||||
|
agents.length === 0 ? (
|
||||||
|
<div className="h-full flex flex-col items-center justify-center text-white/10 gap-4">
|
||||||
|
<svg width="60" height="60" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="0.5">
|
||||||
|
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-[10px] font-black uppercase tracking-[0.3em]">No agents found</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-6 max-w-[1600px] mx-auto">
|
||||||
|
{agents.map((agent) => (
|
||||||
|
<AgentCard
|
||||||
|
key={agent.agent_id || agent.id}
|
||||||
|
agent={agent}
|
||||||
|
onClick={handleSelectAgent}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
377
packages/studio/src/components/AppsStudio.jsx
Normal file
377
packages/studio/src/components/AppsStudio.jsx
Normal file
|
|
@ -0,0 +1,377 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
FaUserTie, FaImage, FaMagic, FaVideo, FaFileAlt,
|
||||||
|
FaBriefcase, FaHome, FaMicrophone, FaHandSparkles, FaBuilding,
|
||||||
|
FaUserInjured, FaStethoscope, FaCar, FaPaw, FaBalanceScale, FaTruck, FaMapMarkerAlt,
|
||||||
|
FaGithub, FaExternalLinkAlt, FaDollarSign, FaRocket, FaCreditCard
|
||||||
|
} from "react-icons/fa";
|
||||||
|
import { registerAppInterest, getAppInterests } from '../muapi.js';
|
||||||
|
import toast, { Toaster } from 'react-hot-toast';
|
||||||
|
|
||||||
|
const templateApps = [
|
||||||
|
{
|
||||||
|
name: "AI Headshot Studio",
|
||||||
|
description: "Launch a headshot SaaS in minutes. Charge $5–$20 per set, keep all profits. Stripe payments & user accounts included.",
|
||||||
|
icon: FaUserTie,
|
||||||
|
color: "blue",
|
||||||
|
repo: "https://github.com/SamurAIGPT/ai-headshot-generator",
|
||||||
|
hosted: "https://ai-headshot-generator-xi.vercel.app/",
|
||||||
|
thumbnail: "https://cdn.muapi.ai/apps/d9c39378f60e48098f6b6ce657dc18b5.png",
|
||||||
|
isTemplate: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Nano Banana Studio",
|
||||||
|
description: "Your own AI image generation platform, ready to monetize. Add credit packs or subscriptions and start earning from day one.",
|
||||||
|
icon: FaHandSparkles,
|
||||||
|
color: "amber",
|
||||||
|
repo: "https://github.com/SamurAIGPT/nano-banana-generator",
|
||||||
|
hosted: "https://nano-banana-generator-psi.vercel.app",
|
||||||
|
thumbnail: "https://cdn.muapi.ai/data/2/874086171651/Screenshot_2026-04-15_103743.png",
|
||||||
|
isTemplate: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Seedance V2 Studio",
|
||||||
|
description: "Deploy a premium AI art studio and sell access to users. Full Stripe integration lets you collect revenue immediately after launch.",
|
||||||
|
icon: FaMagic,
|
||||||
|
color: "purple",
|
||||||
|
repo: "https://github.com/SamurAIGPT/seedance-2-generator",
|
||||||
|
hosted: "https://seedance-2-generator.vercel.app/",
|
||||||
|
thumbnail: "https://cdn.muapi.ai/apps/4cd1f49d48934d448e7f493f9d5e476e.png",
|
||||||
|
isTemplate: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "AI Clipping Studio",
|
||||||
|
description: "Launch your own AI-powered video clipping SaaS. Download YouTube videos and extract viral highlights with ease.",
|
||||||
|
icon: FaVideo,
|
||||||
|
color: "emerald",
|
||||||
|
repo: "https://github.com/SamurAIGPT/ai-clipping-generator",
|
||||||
|
hosted: "https://ai-clipping-generator.vercel.app/",
|
||||||
|
thumbnail: "https://cdn.muapi.ai/data/2/883345778103/cca8b5bb-25f1-40fe-928e-53dce2c8c928.png",
|
||||||
|
isTemplate: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "EasyVeo Studio",
|
||||||
|
description: "The complete Veo 3.1 video generation suite. Monetize text-to-video, image-to-video, and reference-to-video workflows with ease.",
|
||||||
|
icon: FaVideo,
|
||||||
|
color: "indigo",
|
||||||
|
repo: "https://github.com/SamurAIGPT/veo4-video-generator",
|
||||||
|
hosted: "https://veo4-video-generator.vercel.app/",
|
||||||
|
thumbnail: "https://cdn.muapi.ai/data/2/901343404247/94ac6d86-be4e-4b70-b1e6-96d7e3692604.png",
|
||||||
|
isTemplate: true
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const dummyAppsData = [
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/Pet_Product_Studio.jpg", name: "Pet Product Studio", description: "High-end product photography specifically for pet toys and food.", icon: FaPaw, category: "Lifestyle" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/Resale_Photo_Enhancer.png", name: "Resale Photo Enhancer", description: "Boost sales by elevating low-quality product photos to studio level.", icon: FaImage, category: "Business" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/AI_Recruiter.png", name: "AI Recruiter", description: "Smart candidate screening and interview assistant.", icon: FaBriefcase, category: "Business" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/Talk_to_PDF.png", name: "Talk to PDF", description: "Interactive document chat for deep research and summarization.", icon: FaFileAlt, category: "Productivity" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/Blogger_CMS.png", name: "Blogger CMS", description: "AI-powered content management for high-velocity SEO blogs.", icon: FaBriefcase, category: "Business" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/Amazon_Product_Studio.webp", name: "Amazon Product Studio", description: "Perfect Amazon-ready product shots with AI backdrops.", icon: FaImage, category: "Business" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/AI_Business_Card.webp", name: "AI Business Card", description: "Digital-first business card generator with AI networking.", icon: FaBriefcase, category: "Business" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/MailWise.png", name: "MailWise", description: "Intelligent email drafting and scheduling assistant.", icon: FaBriefcase, category: "Business" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/My_Podcast.webp", name: "My Podcast", description: "Automated podcast editing and show-note generation.", icon: FaMicrophone, category: "Creative" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/EZScribe.png", name: "EZScribe", description: "Instant transcription and meeting minute automation.", icon: FaFileAlt, category: "Productivity" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/AI_Knowledge_Base.png", name: "AI Knowledge Base", description: "Train an AI on your company data for instant support.", icon: FaBriefcase, category: "Business" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/AI_Outbound.webp", name: "AI Outbound", description: "Personalized cold outreach at scale for sales teams.", icon: FaBriefcase, category: "Business" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/AI_Royal_Portrait.png", name: "AI Royal Portrait", description: "Transform your photos into 18th-century royal oil paintings.", icon: FaHandSparkles, category: "Creative" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/AI_MEME.png", name: "AI MEME", description: "Viral-ready meme generation based on trending topics.", icon: FaMagic, category: "Creative" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/AI_Real_Estate_Stager.webp", name: "AI Real Estate Stager", description: "Virtually furnish and stage empty homes for sale.", icon: FaHome, category: "Real Estate" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/AI_Logo.png", name: "AI Logo", description: "Dynamic brand identity and logo generator.", icon: FaHandSparkles, category: "Business" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/OldPhoto.png", name: "OldPhoto", description: "Restore, colorize, and sharpen vintage family photos.", icon: FaImage, category: "Creative" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/AITryOn.png", name: "AITryOn", description: "Virtual fitting room for fashion brands and enthusiasts.", icon: FaHandSparkles, category: "Lifestyle" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/AI_Age_Transformation.webp", name: "AI Age Transformation", description: "Visualize yourself at different stages of life with high fidelity.", icon: FaImage, category: "Lifestyle" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/AI_Professional_Makeup_Generator.webp", name: "AI Professional Makeup Generator", description: "Try on hundreds of makeup looks virtually.", icon: FaHandSparkles, category: "Lifestyle" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/AI_Flash_Cards.webp", name: "AI Flash Cards", description: "Turn any text or PDF into pedagogical flashcards.", icon: FaFileAlt, category: "Education" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/AI_Group_Photo.webp", name: "AI Group Photo", description: "Seamlessly combine individual portraits into a group photo.", icon: FaImage, category: "Creative" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/AI_Tattoo_Try_On.webp", name: "AI Tattoo Try-On", description: "Visualize tattoos on your body before getting inked.", icon: FaHandSparkles, category: "Lifestyle" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/AI_Hair_Style_Simulator.webp", name: "AI Hair Style Simulator", description: "Try on new haircuts and colors with zero commitment.", icon: FaHandSparkles, category: "Lifestyle" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/AI_Kids_to_Adult_Prediction.webp", name: "AI Kids-to-Adult Prediction", description: "Ever wonder what your kid will look like as an adult?", icon: FaImage, category: "Lifestyle" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/AI_Room_Declutter.webp", name: "AI Room Declutter", description: "Instantly clean up messy room photos for listings.", icon: FaHome, category: "Real Estate" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/AI_Fitness_Body_Simulator.webp", name: "AI Fitness Body Simulator", description: "Visualize your fitness goals on your own body.", icon: FaImage, category: "Lifestyle" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/AI_Pet_Portrait.webp", name: "AI Pet Portrait", description: "Elegant, artistic portraits for your beloved pets.", icon: FaPaw, category: "Lifestyle" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/AI_Kissing_Video_Generator.webp", name: "AI Kissing Video Generator", description: "Expressive AI video generation for romantic moments.", icon: FaVideo, category: "Creative" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/Chat_with_PDF.webp", name: "Chat with PDF", description: "Ask questions and extract data from massive PDF files.", icon: FaFileAlt, category: "Productivity" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/AI_Travel_Studio.png", name: "AI Travel Studio", description: "Create stunning travel posters and visuals from prompts.", icon: FaMapMarkerAlt, category: "Lifestyle" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/Prompt_Architect.webp", name: "Prompt Architect", description: "Refine and optimize complex prompts for high-tier AI models.", icon: FaMagic, category: "Creative" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/ClearMark_AI.webp", name: "ClearMark AI", description: "Automated watermark removal and brand cleanup for assets.", icon: FaImage, category: "Business" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/PlantVision_AI.webp", name: "PlantVision AI", description: "Identify plants and generate gardening care guides.", icon: FaHandSparkles, category: "Lifestyle" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/AI_Wedding_Photo.png", name: "AI Wedding Photo", description: "Cinematic wedding photography enhancements and filters.", icon: FaImage, category: "Lifestyle" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/User_Account_Registration_Form.webp", name: "User Account Registration Form", description: "Beautiful, conversion-optimized signup flows.", icon: FaBriefcase, category: "Development" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/Social_Post.webp", name: "Social Post", description: "AI-generated social media scheduling and copy creator.", icon: FaBriefcase, category: "Marketing" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/MagicSelf_AI.webp", name: "MagicSelf AI", description: "The ultimate AI selfie and avatar generation engine.", icon: FaMagic, category: "Creative" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/AI_Resume_Builder.webp", name: "AI Resume Builder", description: "Craft the perfect, ATS-friendly resume in seconds.", icon: FaFileAlt, category: "Productivity" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/GEO_Checker.webp", name: "GEO Checker", description: "AI-powered location tagging and geodata validation.", icon: FaMapMarkerAlt, category: "Business" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/AI_Character_Studio.webp", name: "AI Character Studio", description: "Consistent character design for animators and writers.", icon: FaUserTie, category: "Creative" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/Luxury_Hair_Studio.webp", name: "Luxury Hair Studio", description: "High-end hair visualization for top-tier salons.", icon: FaHandSparkles, category: "Lifestyle" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/ProFlow_Plumbing.webp", name: "ProFlow Plumbing", description: "AI scheduling and diagnostics for plumbing services.", icon: FaHome, category: "Services" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/Solace_AI.webp", name: "Solace AI", description: "Empathetic AI assistant for mental well-being support.", icon: FaHandSparkles, category: "Health" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/ReLive_AI.webp", name: "ReLive AI", description: "Immersive memory and historical visualization engine.", icon: FaHandSparkles, category: "Creative" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/AI_Chiropractic_Service.webp", name: "AI Chiropractic Service", description: "Postural analysis and exercise recommendation AI.", icon: FaUserInjured, category: "Health" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/Tabla___ReserveAI.webp", name: "Tabla - ReserveAI", description: "Intelligent table reservation engine for restaurants.", icon: FaBuilding, category: "Services" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/Dental_ReserveAI.webp", name: "Dental ReserveAI", description: "Smart dental appointment and follow-up management.", icon: FaStethoscope, category: "Health" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/CounselMate.webp", name: "CounselMate", description: "Legal research and document drafting aid for lawyers.", icon: FaBalanceScale, category: "Legal" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/Intelligent_Real_Estate_Agent.webp", name: "Intelligent Real Estate Agent", description: "Automate leads and property matches with AI agents.", icon: FaHome, category: "Real Estate" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/Fixera.webp", name: "Fixera", description: "Home repair diagnosis and pro-finding ecosystem.", icon: FaHome, category: "Services" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/Velora___Yoga_AI.webp", name: "Velora - Yoga AI", description: "Personalized AI yoga and posture guidance engine.", icon: FaHandSparkles, category: "Health" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/Nova_AssuranceAI.webp", name: "Nova AssuranceAI", description: "Smart insurance quote and claim processing assistant.", icon: FaBalanceScale, category: "Legal" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/TurboGlow_Auto_Spa.webp", name: "TurboGlow Auto Spa", description: "AI booking and customization for luxury auto detailing.", icon: FaCar, category: "Services" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/Paws___Pals.webp", name: "Paws & Pals", description: "AI-powered pet care and walking coordination hub.", icon: FaPaw, category: "Lifestyle" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/Vertex_Tax_Strategy.webp", name: "Vertex Tax Strategy", description: "Intelligent tax planning and deduction spotting AI.", icon: FaBalanceScale, category: "Business" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/LedgerSync.webp", name: "LedgerSync", description: "Automated bookkeeping and financial reconciliations.", icon: FaBriefcase, category: "Business" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/Nova_Care_Clinic.webp", name: "Nova Care Clinic", description: "Patient scheduling and medical intake automation.", icon: FaStethoscope, category: "Health" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/Opulent_Drive.webp", name: "Opulent Drive", description: "Luxury car rental and fleet management AI.", icon: FaCar, category: "Services" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/ProFix_Auto.webp", name: "ProFix Auto", description: "Engine diagnostics and preventive maintenance alerts.", icon: FaCar, category: "Services" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/TowMate.webp", name: "TowMate", description: "Smart roadside assistance and dispatch coordination.", icon: FaTruck, category: "Services" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/SwiftLink_Logistics.webp", name: "SwiftLink Logistics", description: "AI route optimization and fleet tracking system.", icon: FaTruck, category: "Services" },
|
||||||
|
{ thumbnail: "https://cdn.muapi.ai/apps/Lumea_Residence.webp", name: "Lumea Residence", description: "Smart home property management and tenant portal.", icon: FaHome, category: "Real Estate" }
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AppsStudio({ apiKey }) {
|
||||||
|
const [selectedApp, setSelectedApp] = useState(null);
|
||||||
|
const [isRequesting, setIsRequesting] = useState(false);
|
||||||
|
const [requestedApps, setRequestedApps] = useState([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (apiKey) {
|
||||||
|
getAppInterests(apiKey)
|
||||||
|
.then(setRequestedApps)
|
||||||
|
.catch(err => console.error("Error fetching interests:", err));
|
||||||
|
}
|
||||||
|
}, [apiKey]);
|
||||||
|
|
||||||
|
const handleRequestAccess = async () => {
|
||||||
|
if (!selectedApp || !apiKey) return;
|
||||||
|
|
||||||
|
setIsRequesting(true);
|
||||||
|
try {
|
||||||
|
await registerAppInterest(apiKey, selectedApp.name);
|
||||||
|
setRequestedApps(prev => [...prev, selectedApp.name]);
|
||||||
|
toast.success("Got it! We'll send you the template details shortly.");
|
||||||
|
setTimeout(() => setSelectedApp(null), 1500);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error("Failed to register interest. Please try again later.");
|
||||||
|
} finally {
|
||||||
|
setIsRequesting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderAppCard = (app, isDummy = false, index = 0) => {
|
||||||
|
// Premium Vibrant Gradients for placeholders
|
||||||
|
const gradients = [
|
||||||
|
"from-blue-600/20 to-indigo-600/20",
|
||||||
|
"from-purple-600/20 to-pink-600/20",
|
||||||
|
"from-amber-500/20 to-orange-600/20",
|
||||||
|
"from-emerald-500/20 to-teal-600/20",
|
||||||
|
"from-rose-500/20 to-red-600/20",
|
||||||
|
"from-cyan-500/20 to-blue-600/20",
|
||||||
|
];
|
||||||
|
const cardGradient = gradients[index % gradients.length];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={app.name}
|
||||||
|
className="group bg-[#0a0a0a] border border-white/5 rounded-lg flex flex-col overflow-hidden transition-all duration-300 hover:border-white/10 hover:bg-[#0f0f0f] hover:shadow-2xl hover:shadow-blue-500/5 hover:-translate-y-1"
|
||||||
|
>
|
||||||
|
{/* Thumbnail Section */}
|
||||||
|
<div className="relative h-44 w-full overflow-hidden bg-white/5">
|
||||||
|
{app.thumbnail ? (
|
||||||
|
<img
|
||||||
|
src={app.thumbnail}
|
||||||
|
alt={app.name}
|
||||||
|
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className={`w-full h-full flex items-center justify-center bg-gradient-to-br ${cardGradient} transition-colors group-hover:scale-110 duration-700`}>
|
||||||
|
<app.icon className={`text-4xl opacity-20 group-hover:opacity-40 transition-opacity text-white`} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent opacity-60 group-hover:opacity-80 transition-opacity" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Section */}
|
||||||
|
<div className="p-5 flex flex-col flex-1 space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-white/5 flex items-center justify-center text-lg text-[#d9ff00] border border-white/5 group-hover:border-white/10 transition-colors">
|
||||||
|
<app.icon />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="text-sm font-bold text-white uppercase tracking-tight truncate">{app.name}</h3>
|
||||||
|
<p className="text-[10px] text-white/40 font-bold uppercase tracking-widest">{app.category || 'Template'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-white/50 leading-relaxed font-medium line-clamp-2 min-h-[2.5rem]">{app.description}</p>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex items-center gap-2 pt-2">
|
||||||
|
{isDummy ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedApp(app)}
|
||||||
|
className="flex-1 py-2 bg-white/5 text-white rounded-md text-[11px] font-bold uppercase tracking-wider flex items-center justify-center gap-2 hover:bg-white/10 transition-all border border-white/5 active:scale-95"
|
||||||
|
>
|
||||||
|
<FaGithub className="text-xs" />
|
||||||
|
Github
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedApp(app)}
|
||||||
|
className="flex-1 py-2 bg-[#d9ff00]/10 text-[#d9ff00] rounded-md text-[11px] font-bold uppercase tracking-wider flex items-center justify-center gap-2 hover:bg-[#d9ff00]/20 transition-all border border-[#d9ff00]/20 active:scale-95"
|
||||||
|
>
|
||||||
|
<FaExternalLinkAlt className="text-[9px]" />
|
||||||
|
Demo
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<a
|
||||||
|
href={app.repo || '#'}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex-1 py-2 bg-white/5 text-white rounded-md text-[11px] font-bold uppercase tracking-wider flex items-center justify-center gap-2 hover:bg-white/10 transition-all border border-white/5 active:scale-95"
|
||||||
|
>
|
||||||
|
<FaGithub className="text-xs" />
|
||||||
|
Github
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={app.hosted || '#'}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex-1 py-2 bg-[#d9ff00]/10 text-[#d9ff00] rounded-md text-[11px] font-bold uppercase tracking-wider flex items-center justify-center gap-2 hover:bg-[#d9ff00]/20 transition-all border border-[#d9ff00]/20 active:scale-95"
|
||||||
|
>
|
||||||
|
<FaExternalLinkAlt className="text-[9px]" />
|
||||||
|
Demo
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full flex flex-col items-center bg-[#030303] overflow-y-auto custom-scrollbar relative">
|
||||||
|
<Toaster position="bottom-right" reverseOrder={false} />
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-10 items-center w-full max-w-7xl pt-12 pb-24 px-6">
|
||||||
|
|
||||||
|
{/* Header Section */}
|
||||||
|
<div className="text-center space-y-6 max-w-3xl">
|
||||||
|
<div className="inline-flex items-center gap-2 px-3 py-1.5 bg-[#d9ff00]/10 border border-[#d9ff00]/20 rounded-full">
|
||||||
|
<FaDollarSign className="text-[#d9ff00] text-xs" />
|
||||||
|
<span className="text-[10px] font-black text-[#d9ff00] uppercase tracking-widest">Revenue-Ready Templates</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-5xl font-black text-white tracking-tighter leading-[0.9]">
|
||||||
|
LAUNCH AN AI APP.<br />START EARNING TODAY.
|
||||||
|
</h1>
|
||||||
|
<p className="text-white/40 text-sm font-medium leading-relaxed max-w-xl mx-auto">
|
||||||
|
Each template is a fully-functional, Stripe-integrated AI SaaS you can deploy in minutes.
|
||||||
|
Charge your users, keep the revenue — muapi handles the AI infrastructure.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Monetization Steps */}
|
||||||
|
<div className="w-full grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
icon: FaRocket,
|
||||||
|
step: "01",
|
||||||
|
title: "Deploy in Minutes",
|
||||||
|
body: "Fork the open-source template, add your muapi key, and push to Vercel. No backend setup needed."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: FaCreditCard,
|
||||||
|
step: "02",
|
||||||
|
title: "Collect Payments",
|
||||||
|
body: "Stripe is pre-wired. Set your own pricing — one-time credits, subscriptions, or pay-per-use."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: FaDollarSign,
|
||||||
|
step: "03",
|
||||||
|
title: "Keep the Revenue",
|
||||||
|
body: "Payments go straight to your Stripe account. You own the product, the brand, and the profits."
|
||||||
|
}
|
||||||
|
].map(({ icon: Icon, step, title, body }) => (
|
||||||
|
<div key={step} className="flex items-start gap-4 bg-[#0a0a0a] border border-white/5 rounded-2xl p-6 hover:border-white/10 transition-colors">
|
||||||
|
<div className="w-12 h-12 shrink-0 rounded-2xl bg-white/5 flex items-center justify-center text-[#d9ff00] border border-white/5">
|
||||||
|
<Icon className="text-lg" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-black text-white/30 uppercase tracking-widest mb-1">Step {step}</p>
|
||||||
|
<h3 className="text-sm font-bold text-white mb-1.5">{title}</h3>
|
||||||
|
<p className="text-xs text-white/40 leading-relaxed font-medium">{body}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Apps Grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 w-full pt-8">
|
||||||
|
{templateApps.map((app, index) => renderAppCard(app, false, index))}
|
||||||
|
{dummyAppsData.map((app, index) => renderAppCard(app, true, index + templateApps.length))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer Accent */}
|
||||||
|
<div className="pt-24 pb-12 flex flex-col items-center gap-4">
|
||||||
|
<div className="inline-flex items-center gap-3 px-4 py-2 bg-white/5 rounded-full border border-white/5">
|
||||||
|
<span className="block w-1.5 h-1.5 rounded-full bg-[#d9ff00] animate-pulse" />
|
||||||
|
<span className="text-[9px] font-black text-white/40 uppercase tracking-widest">Muapi Ecosystem — More templates coming soon</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Get Template Modal */}
|
||||||
|
{selectedApp && (
|
||||||
|
<div className="fixed inset-0 z-[100] flex items-center justify-center px-6">
|
||||||
|
<div className="absolute inset-0 bg-black/80 backdrop-blur-sm animate-fade-in" onClick={() => setSelectedApp(null)} />
|
||||||
|
<div className="relative bg-[#0a0a0a] border border-white/10 w-full max-w-md rounded-2xl p-8 space-y-8 animate-scale-up shadow-2xl">
|
||||||
|
<div className="flex flex-col items-center text-center space-y-4">
|
||||||
|
<div className="w-20 h-20 rounded-[28px] bg-[#d9ff00]/10 border border-[#d9ff00]/20 flex items-center justify-center text-4xl text-[#d9ff00] mb-2">
|
||||||
|
<selectedApp.icon />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-black text-white uppercase tracking-tight">
|
||||||
|
Deploy {selectedApp.name}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm font-medium text-white/40 leading-relaxed px-4">
|
||||||
|
Enter your details and we'll send you the <b>{selectedApp.name}</b> template along with setup instructions so you can deploy and start earning immediately.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<button
|
||||||
|
onClick={handleRequestAccess}
|
||||||
|
disabled={isRequesting}
|
||||||
|
className="w-full py-4 bg-[#d9ff00] text-black rounded-md text-sm font-black uppercase tracking-widest flex items-center justify-center gap-2 hover:bg-[#d9ff00]/90 transition-all shadow-lg active:scale-95 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isRequesting ? 'Sending Details...' : 'Get Template'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedApp(null)}
|
||||||
|
className="w-full py-4 bg-white/5 border border-white/10 text-white/60 rounded-md text-sm font-bold uppercase tracking-widest flex items-center justify-center gap-2 hover:bg-white/10 transition-all"
|
||||||
|
>
|
||||||
|
Maybe Later
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<style jsx global>{`
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes scaleUp {
|
||||||
|
from { opacity: 0; transform: scale(0.95) translateY(10px); }
|
||||||
|
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||||
|
}
|
||||||
|
.animate-fade-in { animation: fadeIn 0.3s ease-out; }
|
||||||
|
.animate-scale-up { animation: scaleUp 0.3s cubic-bezier(0.16, 1, 0.3, 1); }
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
607
packages/studio/src/components/MarketingStudio.jsx
Normal file
607
packages/studio/src/components/MarketingStudio.jsx
Normal file
|
|
@ -0,0 +1,607 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
import { uploadFile, generateMarketingStudioAd } from "../muapi.js";
|
||||||
|
|
||||||
|
const SCROLLBAR_STYLE = `
|
||||||
|
.custom-scrollbar-thin::-webkit-scrollbar {
|
||||||
|
height: 4px;
|
||||||
|
}
|
||||||
|
.custom-scrollbar-thin::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.custom-scrollbar-thin::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.custom-scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(217, 255, 0, 0.3);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ── Icons ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const CheckSvg = () => (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#d9ff00" strokeWidth="4">
|
||||||
|
<polyline points="20 6 9 17 4 12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const PlusSvg = () => (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CloseSvg = () => (
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ProductIcon = () => (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M21 8l-2-2H5L3 8v10a2 2 0 002 2h14a2 2 0 002-2V8z" />
|
||||||
|
<path d="M3 10h18" />
|
||||||
|
<path d="M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const AvatarIcon = () => (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" />
|
||||||
|
<circle cx="12" cy="7" r="4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const RefIcon = () => (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||||
|
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||||
|
<polyline points="21 15 16 10 5 21" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Assets ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const ASSETS = {
|
||||||
|
avatar: [
|
||||||
|
{ id: "aa252283-8591-4d14-91a8-41ce54187992", name: "Priya", url: "https://d3adwkbyhxyrtq.cloudfront.net/web-app/Priya.webp" },
|
||||||
|
{ id: "ba6c9b18-f79c-4dab-9649-88a181d0a038", name: "Elena", url: "https://d3adwkbyhxyrtq.cloudfront.net/web-app/Elena.webp" },
|
||||||
|
{ id: "30e2cadd-987c-4a7a-81c3-094d4fb3a65e", name: "Kai", url: "https://d3adwkbyhxyrtq.cloudfront.net/web-app/Kai.webp" },
|
||||||
|
{ id: "fbed59e1-4b8d-4625-9140-ef2044e0be72", name: "Sora", url: "https://d3adwkbyhxyrtq.cloudfront.net/web-app/Sora.webp" },
|
||||||
|
{ id: "bcd9e6ee-c000-48e6-9f4b-a20fc2a674f7", name: "Minji", url: "https://d3adwkbyhxyrtq.cloudfront.net/web-app/Minji.webp" },
|
||||||
|
{ id: "1da384ed-3856-45e4-bf4c-a496c7aa95ff", name: "Margot", url: "https://d3adwkbyhxyrtq.cloudfront.net/web-app/Margot.webp" },
|
||||||
|
{ id: "b799c8f5-fb6e-4905-b33b-cdefac153ec3", name: "Niko", url: "https://d3adwkbyhxyrtq.cloudfront.net/web-app/Niko.webp" },
|
||||||
|
{ id: "b6971dd4-55fa-4e64-b318-392b16504284", name: "Jin", url: "https://d3adwkbyhxyrtq.cloudfront.net/web-app/Jin.webp" }
|
||||||
|
],
|
||||||
|
ugc: [
|
||||||
|
{ id: 1, name: "UGC", url: "https://d3adwkbyhxyrtq.cloudfront.net/web-app/ugc.mp4" },
|
||||||
|
{ id: 2, name: "Tutorial", url: "https://d3adwkbyhxyrtq.cloudfront.net/web-app/ugc_how_to.mp4" },
|
||||||
|
{ id: 3, name: "Unboxing", url: "https://d3adwkbyhxyrtq.cloudfront.net/web-app/ugc_unboxing.mp4" },
|
||||||
|
{ id: 4, name: "Hyper Motion", url: "https://d3adwkbyhxyrtq.cloudfront.net/web-app/hyper-motion-mini.mp4" },
|
||||||
|
{ id: 5, name: "Product Review", url: "https://d3adwkbyhxyrtq.cloudfront.net/web-app/product_review.mp4" },
|
||||||
|
{ id: 6, name: "TV Spot", url: "https://d3adwkbyhxyrtq.cloudfront.net/web-app/tv-spot-mini.mp4" }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const OPTIONS = {
|
||||||
|
ratio: ["9:16", "3:4", "4:3", "16:9", "1:1"],
|
||||||
|
res: ["720p", "1080p"],
|
||||||
|
duration: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Components ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function UploadSlot({ icon, url, progress, label, onUpload, onClear, multiple = false, images = [] }) {
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative group/slot flex items-center">
|
||||||
|
<div
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
title={`Upload ${label}`}
|
||||||
|
className={`relative w-10 h-10 rounded-full border transition-all flex items-center justify-center cursor-pointer ${
|
||||||
|
url ? 'border-primary/40 bg-primary/5' : 'border-white/5 bg-white/5 hover:bg-white/10 hover:border-white/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
multiple={multiple}
|
||||||
|
onChange={(e) => onUpload(e)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{progress > 0 && progress < 100 ? (
|
||||||
|
<div className="absolute inset-0 bg-black/60 rounded-full flex items-center justify-center z-10">
|
||||||
|
<span className="text-[8px] font-black text-primary">{progress}%</span>
|
||||||
|
</div>
|
||||||
|
) : url ? (
|
||||||
|
<div className="w-full h-full rounded-full overflow-hidden border border-black/20">
|
||||||
|
<img src={url} className="w-full h-full object-cover" alt={label} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-white/40 group-hover:text-primary transition-colors">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Clear Button (Single) */}
|
||||||
|
{url && !multiple && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onClear(); }}
|
||||||
|
className="absolute -top-1 -right-1 w-4 h-4 bg-red-500 text-white rounded-full flex items-center justify-center opacity-0 group-hover/slot:opacity-100 transition-opacity shadow-lg"
|
||||||
|
>
|
||||||
|
<CloseSvg />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Dropdown({ isOpen, title, items, selectedId, onSelect, onClose, isVideo = false }) {
|
||||||
|
const ref = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
const handler = (e) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target)) onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener("click", handler);
|
||||||
|
return () => window.removeEventListener("click", handler);
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className="absolute bottom-[calc(100%+12px)] left-0 z-50 bg-[#0a0a0a] rounded p-4 shadow-4xl border border-white/10 w-[420px] animate-fade-in-up"
|
||||||
|
>
|
||||||
|
<div className="text-[10px] font-black text-white/40 uppercase tracking-widest mb-4 px-1">{title}</div>
|
||||||
|
<div className="grid grid-cols-3 gap-3 max-h-[300px] overflow-y-auto custom-scrollbar pr-1">
|
||||||
|
{items.map(item => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => onSelect(item)}
|
||||||
|
className={`relative rounded overflow-hidden border-2 transition-all group cursor-pointer ${
|
||||||
|
selectedId === item.id || selectedId === item.url ? 'border-primary shadow-glow' : 'border-white/5 hover:border-white/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isVideo ? (
|
||||||
|
<video src={item.url} autoPlay loop muted className="w-full aspect-[3/4] object-cover group-hover:scale-105 transition-all duration-500" />
|
||||||
|
) : (
|
||||||
|
<img src={item.url} className="w-full aspect-square object-cover group-hover:scale-105 transition-all duration-500" alt={item.name} />
|
||||||
|
)}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent flex items-end p-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<span className="text-[9px] font-black text-white uppercase tracking-tight">{item.name}</span>
|
||||||
|
</div>
|
||||||
|
{(selectedId === item.id || selectedId === item.url) && (
|
||||||
|
<div className="absolute top-1.5 right-1.5 w-4 h-4 bg-primary rounded-full flex items-center justify-center shadow-lg">
|
||||||
|
<CheckSvg />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SimpleDropdown({ isOpen, title, options, selected, onSelect, onClose }) {
|
||||||
|
const ref = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
const handler = (e) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target)) onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener("click", handler);
|
||||||
|
return () => window.removeEventListener("click", handler);
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className="absolute bottom-[calc(100%+12px)] left-0 z-50 bg-[#0a0a0a] rounded p-1 max-h-[200px] overflow-y-auto custom-scrollbar shadow-3xl border border-white/10 min-w-[140px] animate-fade-in-up"
|
||||||
|
>
|
||||||
|
<div className="text-[10px] font-black text-white/40 uppercase tracking-widest mb-2 px-3 pt-2">{title}</div>
|
||||||
|
{options.map(opt => (
|
||||||
|
<button
|
||||||
|
key={opt}
|
||||||
|
onClick={() => { onSelect(opt); onClose(); }}
|
||||||
|
className={`w-full text-left px-4 py-2 rounded text-xs font-bold transition-all flex items-center justify-between ${
|
||||||
|
selected === opt ? 'bg-primary text-black' : 'text-white/60 hover:bg-white/5 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>{opt}</span>
|
||||||
|
{selected === opt && <CheckSvg />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main Component ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function MarketingStudio({ apiKey, droppedFiles, onFilesHandled }) {
|
||||||
|
const PERSIST_KEY = "hg_marketing_studio_persistent";
|
||||||
|
|
||||||
|
const [prompt, setPrompt] = useState("");
|
||||||
|
const [productImage, setProductImage] = useState(null);
|
||||||
|
const [avatarImage, setAvatarImage] = useState(null);
|
||||||
|
const [additionalImages, setAdditionalImages] = useState([]);
|
||||||
|
|
||||||
|
const [params, setParams] = useState({
|
||||||
|
ratio: "9:16",
|
||||||
|
format: ASSETS.ugc[0].name,
|
||||||
|
videoUrl: ASSETS.ugc[0].url,
|
||||||
|
res: "1080p",
|
||||||
|
duration: 5
|
||||||
|
});
|
||||||
|
|
||||||
|
const [history, setHistory] = useState([]);
|
||||||
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
|
const [dropdown, setDropdown] = useState(null); // 'format' | 'avatar' | 'ratio' | 'res' | 'duration'
|
||||||
|
const [uploadProgress, setUploadProgress] = useState({ product: 0, avatar: 0, additional: 0 });
|
||||||
|
const [fullscreenUrl, setFullscreenUrl] = useState(null);
|
||||||
|
|
||||||
|
const textareaRef = useRef(null);
|
||||||
|
|
||||||
|
// ── Persistence ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(PERSIST_KEY);
|
||||||
|
if (stored) {
|
||||||
|
const data = JSON.parse(stored);
|
||||||
|
if (data.prompt) setPrompt(data.prompt);
|
||||||
|
if (data.params) setParams(data.params);
|
||||||
|
if (data.productImage) setProductImage(data.productImage);
|
||||||
|
if (data.avatarImage) setAvatarImage(data.avatarImage);
|
||||||
|
if (data.additionalImages) setAdditionalImages(data.additionalImages);
|
||||||
|
if (data.history) setHistory(data.history);
|
||||||
|
}
|
||||||
|
} catch (err) { console.warn("Load failed", err); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
const state = { prompt, params, productImage, avatarImage, additionalImages, history };
|
||||||
|
localStorage.setItem(PERSIST_KEY, JSON.stringify(state));
|
||||||
|
}, 500);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [prompt, params, productImage, avatarImage, additionalImages, history]);
|
||||||
|
|
||||||
|
// ── Handlers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const downloadFile = async (url, filename) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url);
|
||||||
|
const blob = await response.blob();
|
||||||
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = blobUrl;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(blobUrl);
|
||||||
|
} catch {
|
||||||
|
window.open(url, "_blank");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpload = async (e, target) => {
|
||||||
|
const files = Array.from(e.target.files);
|
||||||
|
if (!files.length) return;
|
||||||
|
|
||||||
|
if (target === 'additional') {
|
||||||
|
const remaining = 6 - additionalImages.length;
|
||||||
|
const toUpload = files.slice(0, remaining);
|
||||||
|
for (const file of toUpload) {
|
||||||
|
try {
|
||||||
|
const url = await uploadFile(apiKey, file, (pct) => setUploadProgress(p => ({ ...p, additional: pct })));
|
||||||
|
setAdditionalImages(prev => [...prev, url].slice(0, 6));
|
||||||
|
} catch (err) { alert(err.message); }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const file = files[0];
|
||||||
|
try {
|
||||||
|
const url = await uploadFile(apiKey, file, (pct) => setUploadProgress(p => ({ ...p, [target]: pct })));
|
||||||
|
if (target === 'product') setProductImage(url);
|
||||||
|
else setAvatarImage(url);
|
||||||
|
} catch (err) { alert(err.message); }
|
||||||
|
}
|
||||||
|
setUploadProgress(p => ({ ...p, [target]: 0 }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
if (!prompt.trim()) return alert("Please enter an ad script.");
|
||||||
|
if (!productImage) return alert("Please upload a product image.");
|
||||||
|
|
||||||
|
setIsGenerating(true);
|
||||||
|
try {
|
||||||
|
const result = await generateMarketingStudioAd(apiKey, {
|
||||||
|
prompt,
|
||||||
|
aspect_ratio: params.ratio,
|
||||||
|
duration: params.duration,
|
||||||
|
resolution: params.res,
|
||||||
|
images_list: [productImage, avatarImage, ...additionalImages].filter(Boolean),
|
||||||
|
video_files: params.videoUrl ? [params.videoUrl] : []
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result?.url) {
|
||||||
|
const entry = {
|
||||||
|
id: Date.now(),
|
||||||
|
url: result.url,
|
||||||
|
prompt,
|
||||||
|
format: params.format,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
setHistory(prev => [entry, ...prev]);
|
||||||
|
setFullscreenUrl(result.url);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert("Generation failed: " + err.message);
|
||||||
|
} finally {
|
||||||
|
setIsGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTextareaInput = (e) => {
|
||||||
|
const el = e.target;
|
||||||
|
el.style.height = "auto";
|
||||||
|
el.style.height = Math.min(el.scrollHeight, 250) + "px";
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Render ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full flex flex-col items-center justify-center bg-app-bg relative p-4 md:p-6 overflow-hidden">
|
||||||
|
<style>{SCROLLBAR_STYLE}</style>
|
||||||
|
|
||||||
|
{/* ── MAIN CONTENT AREA ── */}
|
||||||
|
<div className="flex-1 overflow-y-auto custom-scrollbar p-6 pb-40">
|
||||||
|
{history.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 animate-fade-in-up">
|
||||||
|
{history.map(entry => (
|
||||||
|
<div key={entry.id} className="relative group rounded-lg overflow-hidden border border-white/10 bg-[#0a0a0a] shadow-xl hover:border-primary/50 transition-all duration-300 flex flex-col">
|
||||||
|
<video
|
||||||
|
src={entry.url}
|
||||||
|
className="w-full aspect-video object-cover cursor-pointer hover:opacity-80 transition-opacity"
|
||||||
|
onClick={() => setFullscreenUrl(entry.url)}
|
||||||
|
muted loop onMouseOver={e => e.target.play()} onMouseOut={e => { e.target.pause(); e.target.currentTime = 0; }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Actions Overlay */}
|
||||||
|
<div className="absolute top-2 right-2 flex flex-col gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); downloadFile(entry.url, `marketing-ad-${entry.id}.mp4`); }}
|
||||||
|
className="p-2 bg-black/60 backdrop-blur-md rounded-full text-white hover:bg-primary hover:text-black transition-all border border-white/10"
|
||||||
|
title="Download"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||||
|
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 bg-black/80 backdrop-blur-sm border-t border-white/5 flex flex-col gap-1.5 flex-1">
|
||||||
|
<p className="text-white/60 text-[10px] line-clamp-2 leading-relaxed font-medium">{entry.prompt}</p>
|
||||||
|
<div className="flex items-center justify-between mt-auto">
|
||||||
|
<span className="text-[9px] font-black text-primary px-2 py-0.5 bg-primary/10 rounded border border-primary/20 uppercase tracking-tighter">
|
||||||
|
{entry.format}
|
||||||
|
</span>
|
||||||
|
<span className="text-[9px] text-white/30 font-bold">{new Date(entry.timestamp).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-full flex flex-col items-center justify-center animate-fade-in-up transition-all duration-700">
|
||||||
|
<div className="mb-12 relative group">
|
||||||
|
<div className="absolute inset-0 bg-primary/10 blur-[120px] rounded-full opacity-30 group-hover:opacity-60 transition-opacity duration-1000" />
|
||||||
|
<div className="relative w-24 h-24 md:w-32 md:h-32 bg-white/[0.02] rounded-[2rem] flex items-center justify-center border border-white/[0.05] overflow-hidden backdrop-blur-sm">
|
||||||
|
<div className="w-16 h-16 bg-primary/5 rounded-2xl flex items-center justify-center border border-primary/10 relative z-10 transition-transform duration-500 group-hover:scale-110 shadow-inner">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#d9ff00" strokeWidth="1.5">
|
||||||
|
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
|
||||||
|
<line x1="8" y1="21" x2="16" y2="21" />
|
||||||
|
<line x1="12" y1="17" x2="12" y2="21" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl sm:text-5xl md:text-6xl font-extrabold text-white tracking-tight mb-4 text-center px-4">
|
||||||
|
<span className="text-white/40 font-medium uppercase tracking-widest">START CREATING WITH</span>
|
||||||
|
<br />
|
||||||
|
<span className="text-white uppercase tracking-tight">MARKETING STUDIO</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-white/40 text-sm md:text-base font-medium tracking-wide text-center max-w-lg leading-relaxed px-6">
|
||||||
|
Describe your scene, upload your product, and watch high-converting AI video ads come to life.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── BOTTOM PROMPT BAR ── */}
|
||||||
|
<div style={{ animationDelay: "0.2s" }} className="absolute bottom-4 w-full max-w-[95%] lg:max-w-4xl z-40 animate-fade-in-up">
|
||||||
|
<div className="bg-[#0a0a0a]/80 backdrop-blur-3xl rounded-lg border border-white/10 p-4 flex flex-col gap-2 shadow-4xl">
|
||||||
|
{additionalImages.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{additionalImages.map((img, idx) => (
|
||||||
|
<div key={idx} className="relative group/img flex-shrink-0">
|
||||||
|
<img src={img} className="w-9 h-9 rounded-full object-cover border border-white/10" />
|
||||||
|
<button
|
||||||
|
onClick={() => setAdditionalImages(prev => prev.filter((_, i) => i !== idx))}
|
||||||
|
className="absolute -top-1 -right-1 w-3.5 h-3.5 bg-black/80 text-white rounded-full flex items-center justify-center opacity-0 group-hover/img:opacity-100 transition-opacity border border-white/10"
|
||||||
|
>
|
||||||
|
<CloseSvg />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Top Row: Full-width Textarea */}
|
||||||
|
<div className="w-full relative">
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={prompt}
|
||||||
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
|
onInput={handleTextareaInput}
|
||||||
|
placeholder="Describe your ad script... Use @image1 for product, @image2 for avatar."
|
||||||
|
rows={1}
|
||||||
|
className="w-full bg-transparent border-none text-white text-sm placeholder:text-white/20 focus:outline-none resize-none pt-1 leading-relaxed min-h-[44px] max-h-[300px] custom-scrollbar font-medium"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Row: Uploads + Controls + Generate */}
|
||||||
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 pt-3 border-t border-white/[0.05]">
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
|
||||||
|
{/* Asset Uploads Group */}
|
||||||
|
<div className="flex items-center gap-1.5 pr-3 border-r border-white/10">
|
||||||
|
<UploadSlot
|
||||||
|
label="Product"
|
||||||
|
icon={<ProductIcon />}
|
||||||
|
url={productImage}
|
||||||
|
progress={uploadProgress.product}
|
||||||
|
onUpload={(e) => handleUpload(e, 'product')}
|
||||||
|
onClear={() => setProductImage(null)}
|
||||||
|
/>
|
||||||
|
<UploadSlot
|
||||||
|
label="Avatar"
|
||||||
|
icon={<AvatarIcon />}
|
||||||
|
url={avatarImage}
|
||||||
|
progress={uploadProgress.avatar}
|
||||||
|
onUpload={(e) => handleUpload(e, 'avatar')}
|
||||||
|
onClear={() => setAvatarImage(null)}
|
||||||
|
/>
|
||||||
|
<UploadSlot
|
||||||
|
label="References"
|
||||||
|
icon={<RefIcon />}
|
||||||
|
url={additionalImages[0]}
|
||||||
|
progress={uploadProgress.additional}
|
||||||
|
multiple
|
||||||
|
images={additionalImages}
|
||||||
|
onUpload={(e) => handleUpload(e, 'additional')}
|
||||||
|
onClear={(idx) => {
|
||||||
|
if (idx !== undefined) {
|
||||||
|
setAdditionalImages(prev => prev.filter((_, i) => i !== idx));
|
||||||
|
} else {
|
||||||
|
setAdditionalImages([]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Format Button */}
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setDropdown(dropdown === 'format' ? null : 'format'); }}
|
||||||
|
className={`flex items-center gap-2 px-3 py-2 bg-white/[0.03] hover:bg-white/[0.08] rounded border transition-all group whitespace-nowrap ${dropdown === 'format' ? 'border-primary/50' : 'border-white/5'}`}
|
||||||
|
>
|
||||||
|
<div className="w-4 h-4 bg-primary/10 rounded flex items-center justify-center border border-primary/20">
|
||||||
|
<span className="text-[8px] font-black text-primary uppercase">U</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-bold text-white/70 group-hover:text-primary transition-colors">{params.format}</span>
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="4" className="opacity-20 group-hover:opacity-100 transition-opacity"><path d="M6 9l6 6 6-6" /></svg>
|
||||||
|
</button>
|
||||||
|
<Dropdown
|
||||||
|
isOpen={dropdown === 'format'}
|
||||||
|
title="Video Format Presets"
|
||||||
|
items={ASSETS.ugc}
|
||||||
|
selectedId={params.format}
|
||||||
|
onSelect={(item) => setParams({ ...params, format: item.name, videoUrl: item.url })}
|
||||||
|
onClose={() => setDropdown(null)}
|
||||||
|
isVideo
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Avatar Preset Button */}
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setDropdown(dropdown === 'avatar' ? null : 'avatar'); }}
|
||||||
|
className={`flex items-center gap-2 px-3 py-2 bg-white/[0.03] hover:bg-white/[0.08] rounded border transition-all group whitespace-nowrap ${dropdown === 'avatar' ? 'border-primary/50' : 'border-white/5'}`}
|
||||||
|
>
|
||||||
|
<div className="w-4 h-4 rounded-full overflow-hidden border border-white/20 shadow-inner">
|
||||||
|
<img src={avatarImage || ASSETS.avatar[0].url} className="w-full h-full object-cover" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-bold text-white/70 group-hover:text-primary transition-colors">
|
||||||
|
{ASSETS.avatar.find(a => a.url === avatarImage)?.name || "Select Avatar"}
|
||||||
|
</span>
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="4" className="opacity-20 group-hover:opacity-100 transition-opacity"><path d="M6 9l6 6 6-6" /></svg>
|
||||||
|
</button>
|
||||||
|
<Dropdown
|
||||||
|
isOpen={dropdown === 'avatar'}
|
||||||
|
title="Avatar Presets"
|
||||||
|
items={ASSETS.avatar}
|
||||||
|
selectedId={avatarImage}
|
||||||
|
onSelect={(item) => setAvatarImage(item.url)}
|
||||||
|
onClose={() => setDropdown(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Simple Controls */}
|
||||||
|
{['ratio', 'res', 'duration'].map(key => (
|
||||||
|
<div key={key} className="relative">
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setDropdown(dropdown === key ? null : key); }}
|
||||||
|
className={`px-3 py-2 bg-white/[0.03] hover:bg-white/[0.08] rounded border transition-all text-sm font-bold ${dropdown === key ? 'border-primary/50 text-primary' : 'border-white/5 text-white/70'}`}
|
||||||
|
>
|
||||||
|
{key === 'duration' ? `${params[key]}s` : params[key]}
|
||||||
|
</button>
|
||||||
|
<SimpleDropdown
|
||||||
|
isOpen={dropdown === key}
|
||||||
|
title={key === 'res' ? 'Resolution' : key.toUpperCase()}
|
||||||
|
options={OPTIONS[key]}
|
||||||
|
selected={params[key]}
|
||||||
|
onSelect={(val) => setParams({ ...params, [key]: val })}
|
||||||
|
onClose={() => setDropdown(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={isGenerating}
|
||||||
|
className="bg-primary text-black px-8 py-2.5 rounded font-bold text-base hover:bg-[#e5ff33] hover:scale-[1.02] active:scale-[0.98] transition-all flex items-center justify-center gap-3 shadow-glow disabled:opacity-50 disabled:grayscale z-10"
|
||||||
|
>
|
||||||
|
{isGenerating ? (
|
||||||
|
<>
|
||||||
|
<div className="w-3 h-3 border-2 border-black/20 border-t-black rounded-full animate-spin" />
|
||||||
|
Generating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span>Launch</span>
|
||||||
|
<div className="flex items-center gap-1 border-l border-black/10 pl-3">
|
||||||
|
<span className="text-[10px] opacity-70">{params.res === '1080p' ? params.duration * 0.675 : params.duration * 0.3}</span>
|
||||||
|
<span className="text-[8px] font-black opacity-40">$</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fullscreen Preview */}
|
||||||
|
{fullscreenUrl && (
|
||||||
|
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/95 backdrop-blur-sm animate-fade-in" onClick={() => setFullscreenUrl(null)}>
|
||||||
|
<button className="absolute top-6 right-6 p-3 bg-white/10 hover:bg-white/20 rounded-full text-white border border-white/10 transition-colors shadow-2xl"><CloseSvg /></button>
|
||||||
|
<video src={fullscreenUrl} controls autoPlay className="max-w-[95vw] max-h-[95vh] rounded-lg shadow-4xl animate-scale-up" onClick={e => e.stopPropagation()} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
162
packages/studio/src/components/McpCliStudio.jsx
Normal file
162
packages/studio/src/components/McpCliStudio.jsx
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { FaGithub, FaTerminal, FaPlug, FaStar, FaExternalLinkAlt } from 'react-icons/fa';
|
||||||
|
|
||||||
|
const FEATURES = [
|
||||||
|
{
|
||||||
|
tag: 'CLI',
|
||||||
|
title: 'muapi-cli',
|
||||||
|
icon: FaTerminal,
|
||||||
|
description:
|
||||||
|
'Generate images, videos, and audio from the terminal across 14+ AI models. Dual interface — colored human output plus JSON for agents (--output-json, --jq filtering). Async workflows, file uploads, credit tracking.',
|
||||||
|
code: `npm install -g muapi-cli
|
||||||
|
muapi auth login
|
||||||
|
muapi image generate "a cyberpunk city" \\
|
||||||
|
--model flux-dev`,
|
||||||
|
href: 'https://github.com/SamurAIGPT/muapi-cli',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: 'MCP',
|
||||||
|
title: 'muapi-mcp-server',
|
||||||
|
icon: FaPlug,
|
||||||
|
description:
|
||||||
|
'Connect Claude, Cursor, Windsurf, and any MCP-compatible assistant to 100+ generative models. Hosted endpoint — no install. 19 structured tools with input/output schemas, async polling, and account management.',
|
||||||
|
code: `claude mcp add --transport http muapi \\
|
||||||
|
https://api.muapi.ai/mcp \\
|
||||||
|
--header "Authorization: Bearer YOUR_KEY"`,
|
||||||
|
href: 'https://github.com/SamurAIGPT/muapi-mcp-server',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: 'Skills',
|
||||||
|
title: 'Generative Media Skills',
|
||||||
|
icon: FaStar,
|
||||||
|
description:
|
||||||
|
'Multimodal toolkit for Claude Code, Cursor, and Gemini CLI. Cinema Director, Nano-Banana, UI Designer, Logo Creator, Seedance 2, AI Clipping, and YouTube Shorts presets. Agent-native with JSON outputs and semantic exit codes.',
|
||||||
|
code: `npx skills add SamurAIGPT/Generative-Media-Skills --all`,
|
||||||
|
href: 'https://github.com/SamurAIGPT/Generative-Media-Skills',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const QUICK_STEPS = [
|
||||||
|
{ num: '1', title: 'Install the CLI', code: 'npm install -g muapi-cli' },
|
||||||
|
{ num: '2', title: 'Sign in', code: 'muapi auth login' },
|
||||||
|
{ num: '3', title: 'Add the skills', code: 'npx skills add SamurAIGPT/Generative-Media-Skills' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const EXAMPLES = [
|
||||||
|
{ title: 'Image generation', code: 'muapi image generate "a serene mountain lake at sunrise" \\\n --model flux-dev --download ./outputs' },
|
||||||
|
{ title: 'Text-to-video', code: 'muapi video generate "a dog running on a beach" \\\n --model kling-master' },
|
||||||
|
{ title: 'Audio creation', code: 'muapi audio create "upbeat lo-fi hip hop for studying"' },
|
||||||
|
{ title: 'Run a skill', code: 'bash library/visual/nano-banana/scripts/\\\n generate-nano-art.sh --file image.jpg --view' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function CodeBlock({ children, className = '' }) {
|
||||||
|
return (
|
||||||
|
<pre
|
||||||
|
className={`text-[11.5px] font-mono text-[#d9ff00] bg-black/50 border border-white/5 rounded-md px-3 py-2 overflow-x-auto whitespace-pre ${className}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function McpCliStudio() {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full overflow-y-auto bg-[#050505] text-white">
|
||||||
|
<div className="max-w-5xl mx-auto px-6 py-12 flex flex-col gap-12">
|
||||||
|
|
||||||
|
{/* Hero */}
|
||||||
|
<section className="flex flex-col items-center text-center gap-4">
|
||||||
|
<div className="px-3 py-1 rounded-full border border-white/10 bg-white/5 text-[11px] font-bold uppercase tracking-widest text-white/60">
|
||||||
|
For developers & AI agents
|
||||||
|
</div>
|
||||||
|
<h1 className="text-4xl md:text-5xl font-bold tracking-tight">MCP & CLI</h1>
|
||||||
|
<p className="text-white/60 text-base md:text-lg max-w-2xl">
|
||||||
|
Use Open Higgsfield AI from your terminal, your IDE, or any MCP-compatible
|
||||||
|
assistant. Generate cinematic images, videos, and audio across 100+ models —
|
||||||
|
without leaving your workflow.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Quick start */}
|
||||||
|
<section className="rounded-2xl border border-white/5 bg-white/[0.02] p-6 md:p-8 flex flex-col gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[11px] font-bold uppercase tracking-widest text-white/50">Quick start</span>
|
||||||
|
<div className="flex-1 h-px bg-white/5" />
|
||||||
|
</div>
|
||||||
|
<div className="grid md:grid-cols-3 gap-4">
|
||||||
|
{QUICK_STEPS.map((step) => (
|
||||||
|
<div
|
||||||
|
key={step.num}
|
||||||
|
className="rounded-xl border border-white/5 bg-white/[0.02] p-4 flex flex-col gap-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-6 h-6 rounded-full bg-white text-black text-xs font-bold flex items-center justify-center">
|
||||||
|
{step.num}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-bold">{step.title}</span>
|
||||||
|
</div>
|
||||||
|
<CodeBlock className="text-[11.5px]">{step.code}</CodeBlock>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Feature cards */}
|
||||||
|
<section className="grid md:grid-cols-3 gap-4">
|
||||||
|
{FEATURES.map((f) => {
|
||||||
|
const Icon = f.icon;
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={f.title}
|
||||||
|
href={f.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="rounded-2xl border border-white/5 bg-white/[0.02] p-6 flex flex-col gap-3 hover:bg-white/[0.04] hover:border-white/10 transition-colors group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-white/5 flex items-center justify-center text-white">
|
||||||
|
<Icon className="text-lg" />
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-widest text-white/50">{f.tag}</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-bold">{f.title}</h3>
|
||||||
|
<p className="text-[13px] text-white/60 leading-relaxed">{f.description}</p>
|
||||||
|
<CodeBlock>{f.code}</CodeBlock>
|
||||||
|
<div className="mt-auto flex items-center gap-1.5 text-[12px] font-bold text-white/50 group-hover:text-white transition-colors">
|
||||||
|
<FaGithub className="text-sm" />
|
||||||
|
<span>View on GitHub</span>
|
||||||
|
<FaExternalLinkAlt className="text-[10px]" />
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Examples */}
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[11px] font-bold uppercase tracking-widest text-white/50">Examples</span>
|
||||||
|
<div className="flex-1 h-px bg-white/5" />
|
||||||
|
</div>
|
||||||
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
|
{EXAMPLES.map((ex) => (
|
||||||
|
<div
|
||||||
|
key={ex.title}
|
||||||
|
className="rounded-xl border border-white/5 bg-white/[0.02] p-4 flex flex-col gap-2"
|
||||||
|
>
|
||||||
|
<span className="text-[12px] font-bold text-white/80">{ex.title}</span>
|
||||||
|
<CodeBlock>{ex.code}</CodeBlock>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p className="text-center text-xs text-white/40 pb-4">
|
||||||
|
Open-source · MIT licensed · Works with Claude, Cursor, Windsurf, and Gemini CLI
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
980
packages/studio/src/components/WorkflowStudio.jsx
Normal file
980
packages/studio/src/components/WorkflowStudio.jsx
Normal file
|
|
@ -0,0 +1,980 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import {
|
||||||
|
getTemplateWorkflows,
|
||||||
|
getUserWorkflows,
|
||||||
|
getPublishedWorkflows,
|
||||||
|
createWorkflow,
|
||||||
|
updateWorkflowName,
|
||||||
|
deleteWorkflow,
|
||||||
|
getWorkflowInputs,
|
||||||
|
executeWorkflow,
|
||||||
|
getAllNodeSchemas,
|
||||||
|
getWorkflowData,
|
||||||
|
} from "../muapi.js";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
const WorkflowUI = dynamic(() => import("./WorkflowUI"), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="w-12 h-12 border-4 border-white/5 border-t-[#d9ff00] rounded-full animate-spin" />
|
||||||
|
<div className="text-[10px] font-black text-white/20 uppercase tracking-widest">
|
||||||
|
Loading Builder...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
function WorkflowCard({ workflow, onClick, activeTab, onRename, onDelete }) {
|
||||||
|
const [showOptions, setShowOptions] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={() => onClick(workflow)}
|
||||||
|
className="group relative aspect-[3/4] rounded-lg overflow-hidden cursor-pointer border border-white/5 bg-[#0a0a0a] transition-all hover:border-[#d9ff00]/30 hover:scale-[1.02] shadow-2xl"
|
||||||
|
>
|
||||||
|
{workflow.thumbnail ? (
|
||||||
|
<img
|
||||||
|
src={workflow.thumbnail}
|
||||||
|
alt={workflow.name}
|
||||||
|
className="absolute inset-0 w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-blue-500/10 to-purple-500/10 flex items-center justify-center">
|
||||||
|
<svg
|
||||||
|
width="40"
|
||||||
|
height="40"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth="1"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="opacity-20"
|
||||||
|
>
|
||||||
|
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/20 to-transparent" />
|
||||||
|
|
||||||
|
{/* Options Dropdown for My Workflows */}
|
||||||
|
{activeTab === 'my-workflows' && (
|
||||||
|
<div
|
||||||
|
className="absolute top-2 right-2 z-30"
|
||||||
|
onClick={(e) => { e.stopPropagation(); }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowOptions(!showOptions)}
|
||||||
|
onBlur={() => setTimeout(() => setShowOptions(false), 200)}
|
||||||
|
className="w-8 h-8 rounded-full bg-black/40 backdrop-blur-md border border-white/10 flex items-center justify-center text-white/60 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||||
|
<circle cx="12" cy="5" r="1" /><circle cx="12" cy="12" r="1" /><circle cx="12" cy="19" r="1" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showOptions && (
|
||||||
|
<div className="absolute top-10 right-0 w-32 bg-[#111] border border-white/10 rounded-lg shadow-2xl py-1 animate-in fade-in zoom-in duration-200">
|
||||||
|
<button
|
||||||
|
onClick={() => onRename(workflow)}
|
||||||
|
className="w-full px-4 py-2 text-left text-[11px] font-bold text-white/70 hover:text-[#d9ff00] hover:bg-white/5 transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||||
|
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7" />
|
||||||
|
<path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||||
|
</svg>
|
||||||
|
Rename
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onDelete(workflow.id)}
|
||||||
|
className="w-full px-4 py-2 text-left text-[11px] font-bold text-red-500 hover:bg-red-500/10 transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||||
|
<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" />
|
||||||
|
</svg>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Community Profile Info */}
|
||||||
|
{activeTab === 'published' && workflow.user_name && (
|
||||||
|
<div className="absolute top-2 left-2 z-20 flex items-center gap-2 bg-black/40 backdrop-blur-md px-2 py-1 rounded-full border border-white/10">
|
||||||
|
<img src={workflow.user_profile || "/user_profile.png"} alt="profile" className="w-4 h-4 rounded-full" />
|
||||||
|
<span className="text-[9px] font-black text-white/80 uppercase tracking-widest">{workflow.user_name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="absolute inset-x-0 bottom-0 p-4">
|
||||||
|
<div className="text-[10px] font-bold text-[#d9ff00] uppercase tracking-wider mb-1 opacity-80">
|
||||||
|
{workflow.category || "General"}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-sm font-bold text-white truncate group-hover:text-[#d9ff00] transition-colors">
|
||||||
|
{workflow.name || "Untitled Flow"}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WorkflowStudio({ apiKey, isHeaderVisible = true, onToggleHeader }) {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const slug = params?.slug || [];
|
||||||
|
const idFromParams = params?.id; // exists on /workflow/[id]/[tab] route
|
||||||
|
const tabFromParams = params?.tab; // exists on /workflow/[id]/[tab] route
|
||||||
|
|
||||||
|
// Robustly extract ID and Tab from either route structure
|
||||||
|
const getWorkflowInfo = useCallback(() => {
|
||||||
|
// Priority 1: Dedicated /workflow/[id]/[tab] route
|
||||||
|
if (idFromParams) {
|
||||||
|
return { id: idFromParams, tab: tabFromParams || null };
|
||||||
|
}
|
||||||
|
// Priority 2: Catch-all /studio/[[...slug]] route
|
||||||
|
const wfIndex = slug.findIndex(s => s === 'workflows' || s === 'workflow');
|
||||||
|
if (wfIndex === -1) return { id: null, tab: null };
|
||||||
|
return {
|
||||||
|
id: slug[wfIndex + 1] || null,
|
||||||
|
tab: slug[wfIndex + 2] || null
|
||||||
|
};
|
||||||
|
}, [slug, idFromParams, tabFromParams]);
|
||||||
|
|
||||||
|
const { id: urlWorkflowId, tab: urlTab } = getWorkflowInfo();
|
||||||
|
|
||||||
|
const [workflows, setWorkflows] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [selectedWorkflow, setSelectedWorkflow] = useState(null);
|
||||||
|
const [activeSubTab, setActiveSubTab] = useState("playground"); // 'playground' | 'builder'
|
||||||
|
const [activeMainTab, setActiveMainTab] = useState("templates"); // 'templates' | 'my-workflows' | 'published'
|
||||||
|
const [renamingWorkflow, setRenamingWorkflow] = useState(null);
|
||||||
|
const [newWorkflowName, setNewWorkflowName] = useState("");
|
||||||
|
const [isDeletingId, setIsDeletingId] = useState(null);
|
||||||
|
const [inputSchema, setInputSchema] = useState(null);
|
||||||
|
const [nodeSchemas, setNodeSchemas] = useState(null);
|
||||||
|
const [workflowDef, setWorkflowDef] = useState(null);
|
||||||
|
const [formData, setFormData] = useState({});
|
||||||
|
const [isExecuting, setIsExecuting] = useState(false);
|
||||||
|
const [result, setResult] = useState(null);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
|
||||||
|
// Handlers defined early so they can be used in effects
|
||||||
|
const handleSelectWorkflow = useCallback(
|
||||||
|
async (wf, fromUrl = false) => {
|
||||||
|
setSelectedWorkflow(wf);
|
||||||
|
setResult(null);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const targetTab = urlTab || "playground";
|
||||||
|
setActiveSubTab(targetTab);
|
||||||
|
|
||||||
|
if (!fromUrl) {
|
||||||
|
// Always route to /workflow/[id] so the builder library's useParams().id resolves correctly
|
||||||
|
router.push(`/workflow/${wf.id}/${targetTab}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[router, urlTab],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Dedicated data fetching effect for the active workflow
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedWorkflow?.id || !apiKey) return;
|
||||||
|
|
||||||
|
async function loadWorkflowDetails() {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const wfId = selectedWorkflow.id;
|
||||||
|
|
||||||
|
// Fetch everything in parallel with allSettled so one failure doesn't block the others
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
getWorkflowInputs(apiKey, wfId),
|
||||||
|
getAllNodeSchemas(apiKey, wfId),
|
||||||
|
getWorkflowData(apiKey, wfId)
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Process Input Schema
|
||||||
|
if (results[0].status === 'fulfilled') {
|
||||||
|
const response = results[0].value;
|
||||||
|
const schema = response.input_data || response;
|
||||||
|
setInputSchema(schema);
|
||||||
|
|
||||||
|
const initial = {};
|
||||||
|
Object.entries(schema.properties || {}).forEach(([key, prop]) => {
|
||||||
|
initial[key] =
|
||||||
|
prop.default ||
|
||||||
|
(Array.isArray(prop.examples) ? prop.examples[0] : prop.examples) ||
|
||||||
|
"";
|
||||||
|
});
|
||||||
|
setFormData(initial);
|
||||||
|
} else {
|
||||||
|
console.warn("Input schema not available for this workflow:", results[0].reason);
|
||||||
|
setInputSchema(null);
|
||||||
|
setFormData({});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process Builder State
|
||||||
|
const nodes = results[1].status === 'fulfilled' ? results[1].value : [];
|
||||||
|
const def = results[2].status === 'fulfilled' ? results[2].value : { nodes: [], edges: [] };
|
||||||
|
|
||||||
|
setNodeSchemas(nodes);
|
||||||
|
setWorkflowDef(def);
|
||||||
|
|
||||||
|
if (results[1].status === 'rejected' || results[2].status === 'rejected') {
|
||||||
|
console.error("Builder components failed to load:", results[1].reason, results[2].reason);
|
||||||
|
if (!nodes.length && !def.nodes?.length) {
|
||||||
|
setError("Failed to load full builder data. Some features may be disabled.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Critical error loading pulse details:", err);
|
||||||
|
setError("Critical error loading builder: " + err.message);
|
||||||
|
setNodeSchemas([]);
|
||||||
|
setWorkflowDef({ nodes: [], edges: [] });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadWorkflowDetails();
|
||||||
|
}, [selectedWorkflow?.id, apiKey]);
|
||||||
|
|
||||||
|
const handleCreateWorkflow = useCallback(
|
||||||
|
async (fromUrl = false) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
if (!fromUrl) {
|
||||||
|
const payload = {
|
||||||
|
workflow_id: null,
|
||||||
|
name: "Untitled Workflow",
|
||||||
|
edges: [],
|
||||||
|
data: { nodes: [] },
|
||||||
|
};
|
||||||
|
const response = await createWorkflow(apiKey, payload);
|
||||||
|
// Route to /workflow/[id] so useParams().id works in the builder library
|
||||||
|
router.push(`/workflow/${response.workflow_id}/builder`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize state for the new flow
|
||||||
|
setSelectedWorkflow({ id: null, name: "Untitled Workflow" });
|
||||||
|
setNodeSchemas([]);
|
||||||
|
setWorkflowDef({ nodes: [], edges: [] });
|
||||||
|
setActiveSubTab("builder");
|
||||||
|
} catch (err) {
|
||||||
|
setError("Failed to initialize workflow: " + err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[apiKey, router],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeleteWorkflow = async (wfId) => {
|
||||||
|
if (!confirm("Are you sure you want to delete this workflow?")) return;
|
||||||
|
setIsDeletingId(wfId);
|
||||||
|
try {
|
||||||
|
await deleteWorkflow(apiKey, wfId);
|
||||||
|
setWorkflows((prev) => prev.filter((w) => w.id !== wfId));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Delete failed:", err);
|
||||||
|
alert("Failed to delete workflow");
|
||||||
|
} finally {
|
||||||
|
setIsDeletingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRenameWorkflow = async (e) => {
|
||||||
|
e?.preventDefault();
|
||||||
|
if (!renamingWorkflow || !newWorkflowName.trim()) return;
|
||||||
|
|
||||||
|
const wfId = renamingWorkflow.id;
|
||||||
|
try {
|
||||||
|
await updateWorkflowName(apiKey, wfId, newWorkflowName);
|
||||||
|
setWorkflows((prev) =>
|
||||||
|
prev.map((w) => (w.id === wfId ? { ...w, name: newWorkflowName } : w)),
|
||||||
|
);
|
||||||
|
if (selectedWorkflow?.id === wfId) {
|
||||||
|
setSelectedWorkflow({ ...selectedWorkflow, name: newWorkflowName });
|
||||||
|
}
|
||||||
|
setRenamingWorkflow(null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Rename failed:", err);
|
||||||
|
alert("Failed to rename workflow");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// KEY FIX: If the user is on /studio/workflows/[id], redirect to /workflow/[id]
|
||||||
|
// so the builder library's useParams().id resolves correctly, preventing duplicate creation.
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== 'undefined' && urlWorkflowId && urlWorkflowId !== 'new') {
|
||||||
|
const path = window.location.pathname;
|
||||||
|
if (path.startsWith('/studio/workflows/')) {
|
||||||
|
const tab = urlTab || 'builder';
|
||||||
|
router.replace(`/workflow/${urlWorkflowId}/${tab}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [urlWorkflowId, urlTab, router]);
|
||||||
|
|
||||||
|
// 1. Sync state with URL on mount or URL change
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading) return;
|
||||||
|
|
||||||
|
if (urlWorkflowId) {
|
||||||
|
if (urlWorkflowId === "new") {
|
||||||
|
if (!selectedWorkflow || selectedWorkflow.id !== null) {
|
||||||
|
handleCreateWorkflow(true);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const found = workflows.find((wf) => wf.id === urlWorkflowId);
|
||||||
|
if (found) {
|
||||||
|
if (!selectedWorkflow || selectedWorkflow.id !== urlWorkflowId) {
|
||||||
|
handleSelectWorkflow(found, true);
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
!selectedWorkflow ||
|
||||||
|
selectedWorkflow.id !== urlWorkflowId
|
||||||
|
) {
|
||||||
|
// Fallback for deep-linking: attempt to open even if not in the current tab's list
|
||||||
|
// handleSelectWorkflow fetches official name/data anyway
|
||||||
|
handleSelectWorkflow(
|
||||||
|
{ id: urlWorkflowId, name: "Loading..." },
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (selectedWorkflow) {
|
||||||
|
setSelectedWorkflow(null);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
urlWorkflowId,
|
||||||
|
workflows,
|
||||||
|
loading,
|
||||||
|
selectedWorkflow,
|
||||||
|
handleCreateWorkflow,
|
||||||
|
handleSelectWorkflow,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Handle reload on exit to clear builder CSS
|
||||||
|
useEffect(() => {
|
||||||
|
const fromBuilder = sessionStorage.getItem("fromWorkflowBuilder");
|
||||||
|
if (fromBuilder && (!urlWorkflowId || activeSubTab !== "builder")) {
|
||||||
|
sessionStorage.removeItem("fromWorkflowBuilder");
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
}, [urlWorkflowId, activeSubTab]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadWorkflows() {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
let data = [];
|
||||||
|
if (activeMainTab === "templates") {
|
||||||
|
data = await getTemplateWorkflows(apiKey);
|
||||||
|
} else if (activeMainTab === "my-workflows") {
|
||||||
|
data = await getUserWorkflows(apiKey);
|
||||||
|
} else if (activeMainTab === "published") {
|
||||||
|
data = await getPublishedWorkflows(apiKey);
|
||||||
|
}
|
||||||
|
setWorkflows(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load workflows:", err);
|
||||||
|
setError("Failed to load workflows list.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadWorkflows();
|
||||||
|
}, [apiKey, activeMainTab]);
|
||||||
|
|
||||||
|
const handleRun = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (isExecuting) return;
|
||||||
|
|
||||||
|
setIsExecuting(true);
|
||||||
|
setError(null);
|
||||||
|
setResult(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const inputs = {};
|
||||||
|
Object.entries(formData).forEach(([key, value]) => {
|
||||||
|
if (!value) return;
|
||||||
|
if (key.startsWith("text")) inputs[key] = { prompt: value };
|
||||||
|
else if (key.startsWith("image")) inputs[key] = { image_url: value };
|
||||||
|
else if (key.startsWith("video")) inputs[key] = { video_url: value };
|
||||||
|
else inputs[key] = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await executeWorkflow(apiKey, selectedWorkflow.id, inputs);
|
||||||
|
setResult(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Execution failed:", err);
|
||||||
|
setError(err.message || "Execution failed");
|
||||||
|
} finally {
|
||||||
|
setIsExecuting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading && !selectedWorkflow) {
|
||||||
|
return (
|
||||||
|
<div className="h-full flex items-center justify-center">
|
||||||
|
<div className="animate-spin text-[#d9ff00] text-3xl">◌</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedWorkflow) {
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col bg-[#030303] text-white">
|
||||||
|
{/* Immersive Sub-header / Floating Toggle */}
|
||||||
|
{isHeaderVisible ? (
|
||||||
|
<div className="flex-shrink-0 h-14 border-b border-white/5 flex items-center justify-between px-6 bg-black/40 z-30">
|
||||||
|
<div className="flex items-center gap-8 h-full">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push("/studio/workflows")}
|
||||||
|
className="flex items-center gap-2 text-xs font-bold text-white/50 hover:text-white transition-colors"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M19 12H5M12 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
All Workflows
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="h-4 w-[1px] bg-white/10" />
|
||||||
|
|
||||||
|
<div className="flex h-full">
|
||||||
|
<div className="flex bg-white/5 p-1 rounded-lg my-auto">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setActiveSubTab("playground");
|
||||||
|
if (selectedWorkflow?.id) router.push(`/workflow/${selectedWorkflow.id}/playground`);
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
className={`px-4 py-1.5 text-[10px] font-black uppercase tracking-widest rounded-md transition-all ${
|
||||||
|
activeSubTab === "playground"
|
||||||
|
? "bg-[#d9ff00] text-black shadow-[0_0_15px_rgba(217,255,0,0.2)]"
|
||||||
|
: "text-white/40 hover:text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Playground
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setActiveSubTab("builder");
|
||||||
|
if (selectedWorkflow?.id) router.push(`/workflow/${selectedWorkflow.id}/builder`);
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
className={`px-4 py-1.5 text-[10px] font-black uppercase tracking-widest rounded-md transition-all ${
|
||||||
|
activeSubTab === "builder"
|
||||||
|
? "bg-[#d9ff00] text-black shadow-[0_0_15px_rgba(217,255,0,0.2)]"
|
||||||
|
: "text-white/40 hover:text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Full Workflow
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-[11px] font-black text-[#d9ff00] uppercase tracking-widest">
|
||||||
|
{selectedWorkflow.name}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => onToggleHeader?.(false)}
|
||||||
|
className="p-1.5 bg-white/5 hover:bg-white/10 rounded-md transition-colors text-white/40 hover:text-white"
|
||||||
|
title="Enter Zen Mode"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||||
|
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Floating Immersive Mode Controller */
|
||||||
|
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-[100] flex items-center gap-4 px-4 py-2 bg-black/60 backdrop-blur-xl border border-white/10 rounded-full shadow-2xl animate-fade-in-down">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push("/studio/workflows")}
|
||||||
|
className="p-1.5 text-white/40 hover:text-white transition-colors"
|
||||||
|
title="Back to All Workflows"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="h-4 w-[1px] bg-white/10" />
|
||||||
|
|
||||||
|
<div className="flex bg-white/5 p-1 rounded-lg">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveSubTab("playground")}
|
||||||
|
type="button"
|
||||||
|
className={`px-3 py-1 text-[9px] font-black uppercase tracking-widest rounded-md transition-all ${
|
||||||
|
activeSubTab === "playground" ? "bg-[#d9ff00] text-black" : "text-white/40"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Play
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveSubTab("builder")}
|
||||||
|
type="button"
|
||||||
|
className={`px-3 py-1 text-[9px] font-black uppercase tracking-widest rounded-md transition-all ${
|
||||||
|
activeSubTab === "builder" ? "bg-[#d9ff00] text-black" : "text-white/40"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Builder
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-4 w-[1px] bg-white/10" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => onToggleHeader?.(true)}
|
||||||
|
className="px-3 py-1 bg-white/10 hover:bg-white/20 text-[9px] font-black text-white uppercase tracking-widest rounded-lg transition-colors flex items-center gap-2"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3"><path d="M4 14h6v6M20 10h-6V4M10 20l-7-7M14 4l7 7"/></svg>
|
||||||
|
Exit Zen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-hidden flex flex-col lg:flex-row">
|
||||||
|
{activeSubTab === "playground" ? (
|
||||||
|
<>
|
||||||
|
{/* Controls Panel */}
|
||||||
|
<div className="w-full lg:w-[400px] border-r border-white/5 flex flex-col bg-black/20">
|
||||||
|
<div className="p-6 overflow-y-auto flex-1 custom-scrollbar">
|
||||||
|
<form onSubmit={handleRun} className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-black text-white/30 uppercase tracking-widest mb-4">
|
||||||
|
Configuration
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{inputSchema &&
|
||||||
|
Object.entries(inputSchema.properties || {}).map(
|
||||||
|
([key, prop]) => (
|
||||||
|
<div key={key} className="space-y-2">
|
||||||
|
<label className="block text-[11px] font-bold text-white/80 uppercase tracking-wider">
|
||||||
|
{prop.title || key}
|
||||||
|
</label>
|
||||||
|
{prop.type === "string" && !prop.enum ? (
|
||||||
|
<textarea
|
||||||
|
value={formData[key] || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
[key]: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-lg p-3 text-sm text-white focus:outline-none focus:border-[#d9ff00]/50 transition-colors min-h-[80px] resize-none"
|
||||||
|
placeholder={
|
||||||
|
prop.description || `Enter ${key}...`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : prop.enum ? (
|
||||||
|
<select
|
||||||
|
value={formData[key] || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
[key]: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-lg p-3 text-sm text-white focus:outline-none focus:border-[#d9ff00]/50 transition-colors"
|
||||||
|
>
|
||||||
|
{prop.enum.map((opt) => (
|
||||||
|
<option
|
||||||
|
key={opt}
|
||||||
|
value={opt}
|
||||||
|
className="bg-black"
|
||||||
|
>
|
||||||
|
{opt}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData[key] || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
[key]: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-lg p-3 text-sm text-white focus:outline-none focus:border-[#d9ff00]/50 transition-colors"
|
||||||
|
placeholder={
|
||||||
|
prop.description || `Enter ${key}...`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isExecuting || !selectedWorkflow.id}
|
||||||
|
className="w-full py-4 bg-[#d9ff00] text-black text-xs font-black uppercase tracking-[0.2em] rounded-xl hover:bg-white transition-all transform hover:scale-[1.02] active:scale-95 disabled:opacity-50 disabled:grayscale shadow-[0_0_30px_rgba(217,255,0,0.15)] flex items-center justify-center gap-3 mt-8"
|
||||||
|
>
|
||||||
|
{isExecuting ? (
|
||||||
|
<>
|
||||||
|
<div className="w-4 h-4 border-2 border-black/20 border-t-black rounded-full animate-spin" />
|
||||||
|
<span>Generating...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="3"
|
||||||
|
>
|
||||||
|
<path d="M5 3l14 9-14 9V3z" />
|
||||||
|
</svg>
|
||||||
|
<span>Run Workflow</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{!selectedWorkflow.id && (
|
||||||
|
<p className="text-[10px] text-white/30 text-center mt-4">
|
||||||
|
Save your workflow first to enable execution.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview Panel */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-8 lg:p-12 bg-[#050505] flex items-center justify-center min-h-[500px]">
|
||||||
|
{error && (
|
||||||
|
<div className="w-full max-w-md p-6 bg-red-500/10 border border-red-500/20 rounded-2xl flex flex-col items-center gap-4 animate-shake">
|
||||||
|
<div className="w-12 h-12 bg-red-500/20 rounded-full flex items-center justify-center text-red-500">
|
||||||
|
<svg
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2.5"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<line x1="12" y1="8" x2="12" y2="12" />
|
||||||
|
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<span className="text-[10px] font-black text-red-500 uppercase tracking-widest block mb-1">
|
||||||
|
Execution Error
|
||||||
|
</span>
|
||||||
|
<p className="text-white/60 text-sm leading-relaxed">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isExecuting && !result && !error && (
|
||||||
|
<div className="flex flex-col items-center gap-6 opacity-40">
|
||||||
|
<div className="w-20 h-20 bg-white/5 rounded-3xl flex items-center justify-center text-white/20">
|
||||||
|
<svg
|
||||||
|
width="40"
|
||||||
|
height="40"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
>
|
||||||
|
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-white/40 max-w-[200px] mx-auto text-center font-medium">
|
||||||
|
Configure parameters and run the workflow to see results.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isExecuting && (
|
||||||
|
<div className="flex flex-col items-center gap-6 animate-fade-in">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="w-24 h-24 border-[3px] border-white/5 border-t-[#d9ff00] rounded-full animate-spin shadow-[0_0_40px_rgba(217,255,0,0.1)]" />
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center text-[#d9ff00]">
|
||||||
|
<svg
|
||||||
|
width="32"
|
||||||
|
height="32"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2.5"
|
||||||
|
className="animate-pulse"
|
||||||
|
>
|
||||||
|
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<div className="text-[10px] font-black text-[#d9ff00] uppercase tracking-[0.3em] animate-pulse">
|
||||||
|
Running Pipeline
|
||||||
|
</div>
|
||||||
|
<div className="text-[13px] text-white/40 font-medium">
|
||||||
|
Processing nodes and generating assets...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<div className="w-full max-w-4xl space-y-8 animate-fade-in-up">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h3 className="text-xs font-black text-white/30 uppercase tracking-widest">
|
||||||
|
Workflow Results
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1 bg-green-500/10 text-green-500 rounded-full text-[10px] font-bold border border-green-500/20">
|
||||||
|
<div className="w-1 h-1 bg-green-500 rounded-full animate-pulse" />{" "}
|
||||||
|
COMPLETED
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{result.outputs?.map((out, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="group relative bg-white/5 border border-white/10 rounded-2xl overflow-hidden hover:border-[#d9ff00]/30 transition-all shadow-2xl"
|
||||||
|
>
|
||||||
|
{out.type === "image_url" ? (
|
||||||
|
<img
|
||||||
|
src={out.value}
|
||||||
|
className="w-full aspect-square object-cover"
|
||||||
|
alt="Output"
|
||||||
|
/>
|
||||||
|
) : out.type === "video_url" ? (
|
||||||
|
<video
|
||||||
|
src={out.value}
|
||||||
|
controls
|
||||||
|
className="w-full aspect-square object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="p-6 min-h-[200px] flex items-center justify-center italic text-white/60">
|
||||||
|
{out.value}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="absolute inset-x-0 bottom-0 p-4 bg-gradient-to-t from-black/80 to-transparent translate-y-full group-hover:translate-y-0 transition-transform">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[10px] font-black text-[#d9ff00] uppercase tracking-widest">
|
||||||
|
{out.id}
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
href={out.value}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="w-8 h-8 rounded-lg bg-white/10 flex items-center justify-center hover:bg-[#d9ff00] hover:text-black transition-colors"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2.5"
|
||||||
|
>
|
||||||
|
<path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6M15 3h6v6M10 14L21 3" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 relative bg-[#050505]">
|
||||||
|
{nodeSchemas && workflowDef ? (
|
||||||
|
<WorkflowUI
|
||||||
|
workflowId={selectedWorkflow?.id}
|
||||||
|
initialNodeSchemas={nodeSchemas}
|
||||||
|
initialWorkflowData={{
|
||||||
|
...workflowDef,
|
||||||
|
// Inject ID to prevent builder from assuming this is a new unsaved flow
|
||||||
|
workflow_id: selectedWorkflow?.id
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="w-12 h-12 border-4 border-white/5 border-t-[#d9ff00] rounded-full animate-spin" />
|
||||||
|
<div className="text-[10px] font-black text-white/20 uppercase tracking-widest">
|
||||||
|
Loading Builder...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render main workflow list
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full flex flex-col p-8 overflow-y-auto custom-scrollbar">
|
||||||
|
<div className="max-w-7xl mx-auto w-full">
|
||||||
|
<div className="flex flex-col gap-6 mb-12">
|
||||||
|
<div className="flex items-end justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-white mb-2 tracking-tight">
|
||||||
|
Workflows
|
||||||
|
</h1>
|
||||||
|
<p className="text-white/40 text-sm font-medium">
|
||||||
|
Create and manage your asynchronous AI processing pipelines
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleCreateWorkflow()}
|
||||||
|
className="px-6 py-3 bg-[#d9ff00] text-black text-xs font-black uppercase tracking-widest rounded-lg hover:bg-white transition-all transform hover:scale-105 active:scale-95 shadow-[0_0_20px_rgba(217,255,0,0.3)] flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="3"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||||
|
</svg>
|
||||||
|
Create Workflow
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 border-b border-white/5">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveMainTab("templates")}
|
||||||
|
className={`px-6 py-4 text-xs font-black uppercase tracking-[0.2em] transition-all border-b-2 ${
|
||||||
|
activeMainTab === "templates"
|
||||||
|
? "text-[#d9ff00] border-[#d9ff00]"
|
||||||
|
: "text-white/30 border-transparent hover:text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Templates
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveMainTab("my-workflows")}
|
||||||
|
className={`px-6 py-4 text-xs font-black uppercase tracking-[0.2em] transition-all border-b-2 ${
|
||||||
|
activeMainTab === "my-workflows"
|
||||||
|
? "text-[#d9ff00] border-[#d9ff00]"
|
||||||
|
: "text-white/30 border-transparent hover:text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
My Workflows
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveMainTab("published")}
|
||||||
|
className={`px-6 py-4 text-xs font-black uppercase tracking-[0.2em] transition-all border-b-2 ${
|
||||||
|
activeMainTab === "published"
|
||||||
|
? "text-[#d9ff00] border-[#d9ff00]"
|
||||||
|
: "text-white/30 border-transparent hover:text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Community
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="py-20 flex items-center justify-center">
|
||||||
|
<div className="w-10 h-10 border-4 border-white/5 border-t-[#d9ff00] rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-6">
|
||||||
|
{workflows.map((wf) => (
|
||||||
|
<WorkflowCard
|
||||||
|
key={wf.id}
|
||||||
|
workflow={wf}
|
||||||
|
onClick={handleSelectWorkflow}
|
||||||
|
activeTab={activeMainTab}
|
||||||
|
onRename={(wf) => {
|
||||||
|
setRenamingWorkflow(wf);
|
||||||
|
setNewWorkflowName(wf.name);
|
||||||
|
}}
|
||||||
|
onDelete={handleDeleteWorkflow}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{!loading && workflows.length === 0 && (
|
||||||
|
<div className="col-span-full py-24 text-center border-2 border-dashed border-white/5 rounded-2xl bg-white/[0.02]">
|
||||||
|
<div className="text-white/20 text-sm font-medium italic">
|
||||||
|
No workflows found in this section.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rename Modal */}
|
||||||
|
{renamingWorkflow && (
|
||||||
|
<div className="fixed inset-0 z-[100] flex items-center justify-center p-6">
|
||||||
|
<div className="absolute inset-0 bg-black/80 backdrop-blur-md" onClick={() => setRenamingWorkflow(null)} />
|
||||||
|
<form
|
||||||
|
onSubmit={handleRenameWorkflow}
|
||||||
|
className="relative w-full max-w-sm bg-[#0a0a0a] border border-white/10 rounded-2xl p-8 shadow-2xl animate-in fade-in zoom-in duration-300"
|
||||||
|
>
|
||||||
|
<h3 className="text-xl font-bold text-white mb-2">Rename Workflow</h3>
|
||||||
|
<p className="text-white/40 text-sm mb-6">Enter a new descriptive name for your pipeline.</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] font-black text-[#d9ff00] uppercase tracking-widest">Workflow Name</label>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
type="text"
|
||||||
|
value={newWorkflowName}
|
||||||
|
onChange={(e) => setNewWorkflowName(e.target.value)}
|
||||||
|
placeholder="e.g. Cinematic Video Flow"
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-sm text-white focus:outline-none focus:border-[#d9ff00]/50 transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRenamingWorkflow(null)}
|
||||||
|
className="flex-1 px-4 py-3 text-xs font-black text-white/40 uppercase tracking-widest hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="flex-1 bg-[#d9ff00] text-black px-4 py-3 rounded-xl text-xs font-black uppercase tracking-widest hover:bg-white transition-all transform hover:scale-105 active:scale-95"
|
||||||
|
>
|
||||||
|
Save Name
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
packages/studio/src/components/WorkflowUI.jsx
Normal file
26
packages/studio/src/components/WorkflowUI.jsx
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { WorkflowBuilder } from "workflow-builder";
|
||||||
|
import "reactflow/dist/style.css";
|
||||||
|
import "react-toastify/dist/ReactToastify.css";
|
||||||
|
import "workflow-builder/dist/tailwind.css";
|
||||||
|
|
||||||
|
const WorkflowUI = ({ workflowId, initialNodeSchemas, initialWorkflowData }) => {
|
||||||
|
useEffect(() => {
|
||||||
|
sessionStorage.setItem("fromWorkflowBuilder", "true");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full bg-black">
|
||||||
|
<WorkflowBuilder
|
||||||
|
workflowId={workflowId}
|
||||||
|
initialNodeSchemas={initialNodeSchemas}
|
||||||
|
initialWorkflowData={initialWorkflowData}
|
||||||
|
costType="dollars"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WorkflowUI;
|
||||||
|
|
@ -4,3 +4,9 @@ export { default as ImageStudio } from './components/ImageStudio';
|
||||||
export { default as VideoStudio } from './components/VideoStudio';
|
export { default as VideoStudio } from './components/VideoStudio';
|
||||||
export { default as LipSyncStudio } from './components/LipSyncStudio';
|
export { default as LipSyncStudio } from './components/LipSyncStudio';
|
||||||
export { default as CinemaStudio } from './components/CinemaStudio';
|
export { default as CinemaStudio } from './components/CinemaStudio';
|
||||||
|
export { default as MarketingStudio } from './components/MarketingStudio';
|
||||||
|
export { default as WorkflowStudio } from './components/WorkflowStudio';
|
||||||
|
export { default as AgentStudio } from './components/AgentStudio';
|
||||||
|
export { default as AppsStudio } from './components/AppsStudio';
|
||||||
|
export { default as McpCliStudio } from './components/McpCliStudio';
|
||||||
|
export * from './muapi';
|
||||||
|
|
|
||||||
|
|
@ -1823,6 +1823,50 @@ export const t2iModels = [
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "gpt-image-2",
|
||||||
|
"name": "Gpt Image 2",
|
||||||
|
"endpoint": "gpt-image-2-text-to-image",
|
||||||
|
"family": "gpt-2",
|
||||||
|
"inputs": {
|
||||||
|
"prompt": {
|
||||||
|
"examples": [
|
||||||
|
"A photorealistic product photo of a luxury watch resting on a slab of black marble, dramatic cinematic lighting with a soft rim glow, ultra-detailed metallic textures, shallow depth of field, studio quality."
|
||||||
|
],
|
||||||
|
"description": "Text prompt describing the image. Up to 20,000 characters supported.",
|
||||||
|
"type": "string",
|
||||||
|
"title": "Prompt",
|
||||||
|
"name": "prompt"
|
||||||
|
},
|
||||||
|
"aspect_ratio": {
|
||||||
|
"enum": [
|
||||||
|
"auto",
|
||||||
|
"1:1",
|
||||||
|
"16:9",
|
||||||
|
"9:16",
|
||||||
|
"4:3",
|
||||||
|
"3:4"
|
||||||
|
],
|
||||||
|
"title": "Aspect Ratio",
|
||||||
|
"name": "aspect_ratio",
|
||||||
|
"type": "string",
|
||||||
|
"description": "Aspect ratio of the output image.",
|
||||||
|
"default": "auto"
|
||||||
|
},
|
||||||
|
"resolution": {
|
||||||
|
"enum": [
|
||||||
|
"1K",
|
||||||
|
"2K",
|
||||||
|
"4K"
|
||||||
|
],
|
||||||
|
"title": "Resolution",
|
||||||
|
"name": "resolution",
|
||||||
|
"type": "string",
|
||||||
|
"description": "The target resolution of the generated image.",
|
||||||
|
"default": "2K"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "wan2.6-text-to-image",
|
"id": "wan2.6-text-to-image",
|
||||||
"name": "Wan2.6 Text To Image",
|
"name": "Wan2.6 Text To Image",
|
||||||
|
|
@ -2089,6 +2133,50 @@ export const t2iModels = [
|
||||||
"default": "basic"
|
"default": "basic"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "minimax-image-01",
|
||||||
|
"name": "MiniMax Image 01",
|
||||||
|
"endpoint": "minimax-image-01",
|
||||||
|
"family": "minimax",
|
||||||
|
"inputs": {
|
||||||
|
"prompt": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Prompt",
|
||||||
|
"name": "prompt",
|
||||||
|
"description": "Text prompt describing the image to generate (max 1500 characters).",
|
||||||
|
"examples": [
|
||||||
|
"A serene mountain lake at sunset with golden reflections on the water, surrounded by pine forests and snow-capped peaks, photorealistic, 8k."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"aspect_ratio": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Aspect Ratio",
|
||||||
|
"name": "aspect_ratio",
|
||||||
|
"description": "Aspect ratio of the output image.",
|
||||||
|
"enum": [
|
||||||
|
"16:9",
|
||||||
|
"9:16",
|
||||||
|
"1:1",
|
||||||
|
"4:3",
|
||||||
|
"3:4",
|
||||||
|
"3:2",
|
||||||
|
"2:3",
|
||||||
|
"21:9"
|
||||||
|
],
|
||||||
|
"default": "1:1"
|
||||||
|
},
|
||||||
|
"num_images": {
|
||||||
|
"type": "int",
|
||||||
|
"title": "Number of images",
|
||||||
|
"name": "num_images",
|
||||||
|
"description": "Number of images to generate in a single request.",
|
||||||
|
"default": 1,
|
||||||
|
"minValue": 1,
|
||||||
|
"maxValue": 4,
|
||||||
|
"step": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -2271,6 +2359,16 @@ export const t2vModels = [
|
||||||
"resolution": { "enum": ["1080p"], "title": "Resolution", "name": "resolution", "type": "string", "description": "The resolution of the generated video.", "default": "1080p" }
|
"resolution": { "enum": ["1080p"], "title": "Resolution", "name": "resolution", "type": "string", "description": "The resolution of the generated video.", "default": "1080p" }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "veo3.1-lite-text-to-video",
|
||||||
|
"name": "Veo 3.1 Lite",
|
||||||
|
"inputs": {
|
||||||
|
"prompt": { "type": "string", "title": "Prompt", "name": "prompt", "description": "Text prompt describing the video." },
|
||||||
|
"aspect_ratio": { "enum": ["16:9", "9:16"], "title": "Aspect Ratio", "name": "aspect_ratio", "type": "string", "description": "Aspect ratio of the output video.", "default": "16:9" },
|
||||||
|
"duration": { "enum": [8], "title": "Duration", "name": "duration", "type": "int", "description": "The duration of the generated video in seconds", "default": 8 },
|
||||||
|
"resolution": { "enum": ["1080p"], "title": "Resolution", "name": "resolution", "type": "string", "description": "The resolution of the generated video.", "default": "1080p" }
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "runway-text-to-video",
|
"id": "runway-text-to-video",
|
||||||
"name": "Runway Gen-3",
|
"name": "Runway Gen-3",
|
||||||
|
|
@ -3748,7 +3846,7 @@ export const i2iModels = [
|
||||||
{
|
{
|
||||||
"id": "higgsfield-soul-image-to-image",
|
"id": "higgsfield-soul-image-to-image",
|
||||||
"name": "Higgsfield Soul Image To Image",
|
"name": "Higgsfield Soul Image To Image",
|
||||||
"endpoint": "higgsfield-soul-image-to-image",
|
"endpoint": "hf-soul-image-to-image",
|
||||||
"family": "higgsfield",
|
"family": "higgsfield",
|
||||||
"imageField": "image_url",
|
"imageField": "image_url",
|
||||||
"hasPrompt": true,
|
"hasPrompt": true,
|
||||||
|
|
@ -4499,6 +4597,53 @@ export const i2iModels = [
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "gpt-image-2-edit",
|
||||||
|
"name": "Gpt Image 2 Edit",
|
||||||
|
"endpoint": "gpt-image-2-image-to-image",
|
||||||
|
"family": "gpt-2",
|
||||||
|
"imageField": "images_list",
|
||||||
|
"hasPrompt": true,
|
||||||
|
"maxImages": 16,
|
||||||
|
"inputs": {
|
||||||
|
"prompt": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Prompt",
|
||||||
|
"name": "prompt",
|
||||||
|
"description": "Text prompt describing the transformation. Up to 20,000 characters supported.",
|
||||||
|
"examples": [
|
||||||
|
"Transform these product photos into a professional lifestyle scene with warm cinematic lighting, soft natural shadows, and a clean modern background; keep brand details and proportions unchanged."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"aspect_ratio": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Aspect Ratio",
|
||||||
|
"name": "aspect_ratio",
|
||||||
|
"description": "Aspect ratio of the output image.",
|
||||||
|
"enum": [
|
||||||
|
"auto",
|
||||||
|
"1:1",
|
||||||
|
"16:9",
|
||||||
|
"9:16",
|
||||||
|
"4:3",
|
||||||
|
"3:4"
|
||||||
|
],
|
||||||
|
"default": "auto"
|
||||||
|
},
|
||||||
|
"resolution": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Resolution",
|
||||||
|
"name": "resolution",
|
||||||
|
"description": "The target resolution of the generated image.",
|
||||||
|
"enum": [
|
||||||
|
"1K",
|
||||||
|
"2K",
|
||||||
|
"4K"
|
||||||
|
],
|
||||||
|
"default": "2K"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "gpt-image-1.5-edit",
|
"id": "gpt-image-1.5-edit",
|
||||||
"name": "Gpt Image 1.5 Edit",
|
"name": "Gpt Image 1.5 Edit",
|
||||||
|
|
@ -5438,6 +5583,7 @@ export const i2vModels = [
|
||||||
"endpoint": "kling-v2.1-master-i2v",
|
"endpoint": "kling-v2.1-master-i2v",
|
||||||
"family": "kling-v2.1",
|
"family": "kling-v2.1",
|
||||||
"imageField": "image_url",
|
"imageField": "image_url",
|
||||||
|
"lastImageField": "last_image",
|
||||||
"hasPrompt": true,
|
"hasPrompt": true,
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"prompt": {
|
"prompt": {
|
||||||
|
|
@ -5479,6 +5625,7 @@ export const i2vModels = [
|
||||||
"endpoint": "kling-v2.1-standard-i2v",
|
"endpoint": "kling-v2.1-standard-i2v",
|
||||||
"family": "kling-v2.1",
|
"family": "kling-v2.1",
|
||||||
"imageField": "image_url",
|
"imageField": "image_url",
|
||||||
|
"lastImageField": "last_image",
|
||||||
"hasPrompt": true,
|
"hasPrompt": true,
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"prompt": {
|
"prompt": {
|
||||||
|
|
@ -5520,6 +5667,7 @@ export const i2vModels = [
|
||||||
"endpoint": "kling-v2.1-pro-i2v",
|
"endpoint": "kling-v2.1-pro-i2v",
|
||||||
"family": "kling-v2.1",
|
"family": "kling-v2.1",
|
||||||
"imageField": "image_url",
|
"imageField": "image_url",
|
||||||
|
"lastImageField": "last_image",
|
||||||
"hasPrompt": true,
|
"hasPrompt": true,
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"prompt": {
|
"prompt": {
|
||||||
|
|
@ -5561,6 +5709,7 @@ export const i2vModels = [
|
||||||
"endpoint": "wan2.2-image-to-video",
|
"endpoint": "wan2.2-image-to-video",
|
||||||
"family": "wan2.2",
|
"family": "wan2.2",
|
||||||
"imageField": "image_url",
|
"imageField": "image_url",
|
||||||
|
"lastImageField": "last_image",
|
||||||
"hasPrompt": true,
|
"hasPrompt": true,
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"prompt": {
|
"prompt": {
|
||||||
|
|
@ -5787,6 +5936,7 @@ export const i2vModels = [
|
||||||
"endpoint": "minimax-hailuo-02-standard-i2v",
|
"endpoint": "minimax-hailuo-02-standard-i2v",
|
||||||
"family": "minimax-2",
|
"family": "minimax-2",
|
||||||
"imageField": "image_url",
|
"imageField": "image_url",
|
||||||
|
"lastImageField": "end_image_url",
|
||||||
"hasPrompt": true,
|
"hasPrompt": true,
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"prompt": {
|
"prompt": {
|
||||||
|
|
@ -5828,6 +5978,7 @@ export const i2vModels = [
|
||||||
"endpoint": "minimax-hailuo-02-pro-i2v",
|
"endpoint": "minimax-hailuo-02-pro-i2v",
|
||||||
"family": "minimax-2",
|
"family": "minimax-2",
|
||||||
"imageField": "image_url",
|
"imageField": "image_url",
|
||||||
|
"lastImageField": "end_image_url",
|
||||||
"hasPrompt": true,
|
"hasPrompt": true,
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"prompt": {
|
"prompt": {
|
||||||
|
|
@ -5927,6 +6078,7 @@ export const i2vModels = [
|
||||||
"endpoint": "seedance-lite-i2v",
|
"endpoint": "seedance-lite-i2v",
|
||||||
"family": "bytedance",
|
"family": "bytedance",
|
||||||
"imageField": "image_url",
|
"imageField": "image_url",
|
||||||
|
"lastImageField": "last_image",
|
||||||
"hasPrompt": true,
|
"hasPrompt": true,
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"prompt": {
|
"prompt": {
|
||||||
|
|
@ -6434,7 +6586,7 @@ export const i2vModels = [
|
||||||
{
|
{
|
||||||
"id": "higgsfield-dop-image-to-video",
|
"id": "higgsfield-dop-image-to-video",
|
||||||
"name": "Higgsfield Dop Image To Video",
|
"name": "Higgsfield Dop Image To Video",
|
||||||
"endpoint": "higgsfield-dop-image-to-video",
|
"endpoint": "hf-dop-image-to-video",
|
||||||
"family": "higgsfield",
|
"family": "higgsfield",
|
||||||
"imageField": "image_url",
|
"imageField": "image_url",
|
||||||
"hasPrompt": true,
|
"hasPrompt": true,
|
||||||
|
|
@ -6608,6 +6760,7 @@ export const i2vModels = [
|
||||||
"endpoint": "veo3.1-image-to-video",
|
"endpoint": "veo3.1-image-to-video",
|
||||||
"family": "veo3.1",
|
"family": "veo3.1",
|
||||||
"imageField": "image_url",
|
"imageField": "image_url",
|
||||||
|
"lastImageField": "last_image",
|
||||||
"hasPrompt": true,
|
"hasPrompt": true,
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"prompt": {
|
"prompt": {
|
||||||
|
|
@ -6658,6 +6811,7 @@ export const i2vModels = [
|
||||||
"endpoint": "veo3.1-fast-image-to-video",
|
"endpoint": "veo3.1-fast-image-to-video",
|
||||||
"family": "veo3.1",
|
"family": "veo3.1",
|
||||||
"imageField": "image_url",
|
"imageField": "image_url",
|
||||||
|
"lastImageField": "last_image",
|
||||||
"hasPrompt": true,
|
"hasPrompt": true,
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"prompt": {
|
"prompt": {
|
||||||
|
|
@ -6702,6 +6856,54 @@ export const i2vModels = [
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "veo3.1-lite-image-to-video",
|
||||||
|
"name": "Veo3.1 Lite Image To Video",
|
||||||
|
"endpoint": "veo3.1-lite-image-to-video",
|
||||||
|
"family": "veo3.1",
|
||||||
|
"imageField": "image_url",
|
||||||
|
"lastImageField": "last_image",
|
||||||
|
"hasPrompt": true,
|
||||||
|
"inputs": {
|
||||||
|
"prompt": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Prompt",
|
||||||
|
"name": "prompt",
|
||||||
|
"description": "Text prompt describing the video."
|
||||||
|
},
|
||||||
|
"aspect_ratio": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Aspect Ratio",
|
||||||
|
"name": "aspect_ratio",
|
||||||
|
"description": "Aspect ratio of the output video.",
|
||||||
|
"enum": [
|
||||||
|
"16:9",
|
||||||
|
"9:16"
|
||||||
|
],
|
||||||
|
"default": "16:9"
|
||||||
|
},
|
||||||
|
"duration": {
|
||||||
|
"type": "int",
|
||||||
|
"title": "Duration",
|
||||||
|
"name": "duration",
|
||||||
|
"description": "The duration of the generated video in seconds",
|
||||||
|
"enum": [
|
||||||
|
8
|
||||||
|
],
|
||||||
|
"default": 8
|
||||||
|
},
|
||||||
|
"resolution": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Resolution",
|
||||||
|
"name": "resolution",
|
||||||
|
"description": "The resolution of the generated video.",
|
||||||
|
"enum": [
|
||||||
|
"1080p"
|
||||||
|
],
|
||||||
|
"default": "1080p"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "veo3.1-reference-to-video",
|
"id": "veo3.1-reference-to-video",
|
||||||
"name": "Veo3.1 Reference To Video",
|
"name": "Veo3.1 Reference To Video",
|
||||||
|
|
@ -7241,6 +7443,7 @@ export const i2vModels = [
|
||||||
"endpoint": "kling-o1-image-to-video",
|
"endpoint": "kling-o1-image-to-video",
|
||||||
"family": "kling-o1",
|
"family": "kling-o1",
|
||||||
"imageField": "image_url",
|
"imageField": "image_url",
|
||||||
|
"lastImageField": "last_image",
|
||||||
"hasPrompt": true,
|
"hasPrompt": true,
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"prompt": {
|
"prompt": {
|
||||||
|
|
@ -7561,6 +7764,7 @@ export const i2vModels = [
|
||||||
"endpoint": "kling-o1-standard-image-to-video",
|
"endpoint": "kling-o1-standard-image-to-video",
|
||||||
"family": "kling-o1",
|
"family": "kling-o1",
|
||||||
"imageField": "image_url",
|
"imageField": "image_url",
|
||||||
|
"lastImageField": "last_image",
|
||||||
"hasPrompt": true,
|
"hasPrompt": true,
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"prompt": {
|
"prompt": {
|
||||||
|
|
@ -7633,6 +7837,7 @@ export const i2vModels = [
|
||||||
"endpoint": "seedance-v1.5-pro-i2v",
|
"endpoint": "seedance-v1.5-pro-i2v",
|
||||||
"family": "seedance-v1.5-pro",
|
"family": "seedance-v1.5-pro",
|
||||||
"imageField": "image_url",
|
"imageField": "image_url",
|
||||||
|
"lastImageField": "last_image",
|
||||||
"hasPrompt": true,
|
"hasPrompt": true,
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"prompt": {
|
"prompt": {
|
||||||
|
|
@ -7703,6 +7908,7 @@ export const i2vModels = [
|
||||||
"endpoint": "seedance-v1.5-pro-i2v-fast",
|
"endpoint": "seedance-v1.5-pro-i2v-fast",
|
||||||
"family": "seedance-v1.5-pro",
|
"family": "seedance-v1.5-pro",
|
||||||
"imageField": "image_url",
|
"imageField": "image_url",
|
||||||
|
"lastImageField": "last_image",
|
||||||
"hasPrompt": true,
|
"hasPrompt": true,
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"prompt": {
|
"prompt": {
|
||||||
|
|
@ -7813,6 +8019,7 @@ export const i2vModels = [
|
||||||
"endpoint": "kling-v3.0-pro-image-to-video",
|
"endpoint": "kling-v3.0-pro-image-to-video",
|
||||||
"family": "kling-v3.0",
|
"family": "kling-v3.0",
|
||||||
"imageField": "image_url",
|
"imageField": "image_url",
|
||||||
|
"lastImageField": "last_image",
|
||||||
"hasPrompt": true,
|
"hasPrompt": true,
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"prompt": {
|
"prompt": {
|
||||||
|
|
@ -7849,6 +8056,7 @@ export const i2vModels = [
|
||||||
"endpoint": "kling-v3.0-standard-image-to-video",
|
"endpoint": "kling-v3.0-standard-image-to-video",
|
||||||
"family": "kling-v3.0",
|
"family": "kling-v3.0",
|
||||||
"imageField": "image_url",
|
"imageField": "image_url",
|
||||||
|
"lastImageField": "last_image",
|
||||||
"hasPrompt": true,
|
"hasPrompt": true,
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"prompt": {
|
"prompt": {
|
||||||
|
|
@ -8020,6 +8228,37 @@ export const v2vModels = [
|
||||||
"videoField": "video_url",
|
"videoField": "video_url",
|
||||||
"hasPrompt": false,
|
"hasPrompt": false,
|
||||||
"description": "Remove watermarks, logos, captions, and unwanted text from videos."
|
"description": "Remove watermarks, logos, captions, and unwanted text from videos."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "kling-v2.6-std-motion-control",
|
||||||
|
"name": "Kling 2.6 Std Motion Control",
|
||||||
|
"endpoint": "kling-v2.6-std-motion-control",
|
||||||
|
"family": "kling",
|
||||||
|
"videoField": "video_url",
|
||||||
|
"imageField": "image_url",
|
||||||
|
"hasPrompt": true,
|
||||||
|
"promptRequired": true,
|
||||||
|
"description": "Kling v2.6 Pro Motion Control allows precise control over camera movement, subject motion, and scene dynamics during video generation."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "kling-v3.0-std-motion-control",
|
||||||
|
"name": "Kling 3.0 Std Motion Control",
|
||||||
|
"endpoint": "kling-v3.0-std-motion-control",
|
||||||
|
"family": "kling",
|
||||||
|
"videoField": "video_url",
|
||||||
|
"imageField": "image_url",
|
||||||
|
"hasPrompt": true,
|
||||||
|
"description": "Kling V3.0 Standard Motion Control allows for precise control over the camera and subject movement in generated videos."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "kling-v3.0-pro-motion-control",
|
||||||
|
"name": "Kling 3.0 Pro Motion Control",
|
||||||
|
"endpoint": "kling-v3.0-pro-motion-control",
|
||||||
|
"family": "kling",
|
||||||
|
"videoField": "video_url",
|
||||||
|
"imageField": "image_url",
|
||||||
|
"hasPrompt": true,
|
||||||
|
"description": "Kling V3.0 Pro Motion Control provides the highest level of detail and control for video generation."
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { getModelById, getVideoModelById, getI2IModelById, getI2VModelById, getV2VModelById, getLipSyncModelById } from './models.js';
|
import { getModelById, getVideoModelById, getI2IModelById, getI2VModelById, getV2VModelById, getLipSyncModelById } from './models.js';
|
||||||
|
|
||||||
const BASE_URL = 'https://api.muapi.ai';
|
const BASE_URL = 'https://api.muapi.ai';
|
||||||
|
const PROXY_WF_BASE = '/api/workflow';
|
||||||
|
|
||||||
async function pollForResult(requestId, key, maxAttempts = 900, interval = 2000) {
|
async function pollForResult(requestId, key, maxAttempts = 900, interval = 2000) {
|
||||||
const pollUrl = `${BASE_URL}/api/v1/predictions/${requestId}/result`;
|
const pollUrl = `${BASE_URL}/api/v1/predictions/${requestId}/result`;
|
||||||
|
|
@ -53,8 +54,14 @@ export async function generateImage(apiKey, params) {
|
||||||
if (params.aspect_ratio) payload.aspect_ratio = params.aspect_ratio;
|
if (params.aspect_ratio) payload.aspect_ratio = params.aspect_ratio;
|
||||||
if (params.resolution) payload.resolution = params.resolution;
|
if (params.resolution) payload.resolution = params.resolution;
|
||||||
if (params.quality) payload.quality = params.quality;
|
if (params.quality) payload.quality = params.quality;
|
||||||
if (params.image_url) { payload.image_url = params.image_url; payload.strength = params.strength || 0.6; }
|
if (params.image_url) {
|
||||||
else payload.image_url = null;
|
payload.image_url = params.image_url;
|
||||||
|
payload.strength = params.strength || 0.6;
|
||||||
|
} else if (params.images_list) {
|
||||||
|
payload.images_list = params.images_list;
|
||||||
|
} else {
|
||||||
|
payload.image_url = null;
|
||||||
|
}
|
||||||
if (params.seed && params.seed !== -1) payload.seed = params.seed;
|
if (params.seed && params.seed !== -1) payload.seed = params.seed;
|
||||||
return submitAndPoll(endpoint, payload, apiKey, params.onRequestId, 60);
|
return submitAndPoll(endpoint, payload, apiKey, params.onRequestId, 60);
|
||||||
}
|
}
|
||||||
|
|
@ -100,6 +107,10 @@ export async function generateI2V(apiKey, params) {
|
||||||
if (imageField === 'images_list') payload.images_list = [params.image_url];
|
if (imageField === 'images_list') payload.images_list = [params.image_url];
|
||||||
else payload[imageField] = params.image_url;
|
else payload[imageField] = params.image_url;
|
||||||
}
|
}
|
||||||
|
const lastImageField = modelInfo?.lastImageField;
|
||||||
|
if (lastImageField && params.last_image) {
|
||||||
|
payload[lastImageField] = params.last_image;
|
||||||
|
}
|
||||||
if (params.aspect_ratio) payload.aspect_ratio = params.aspect_ratio;
|
if (params.aspect_ratio) payload.aspect_ratio = params.aspect_ratio;
|
||||||
if (params.duration) payload.duration = params.duration;
|
if (params.duration) payload.duration = params.duration;
|
||||||
if (params.resolution) payload.resolution = params.resolution;
|
if (params.resolution) payload.resolution = params.resolution;
|
||||||
|
|
@ -108,6 +119,32 @@ export async function generateI2V(apiKey, params) {
|
||||||
return submitAndPoll(endpoint, payload, apiKey, params.onRequestId, 900);
|
return submitAndPoll(endpoint, payload, apiKey, params.onRequestId, 900);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function generateMarketingStudioAd(apiKey, params) {
|
||||||
|
const endpoint = params.resolution === '1080p' ? 'sd-2-vip-omni-reference-1080p' : 'seedance-2-vip-omni-reference';
|
||||||
|
const payload = {
|
||||||
|
prompt: params.prompt,
|
||||||
|
aspect_ratio: params.aspect_ratio || '16:9',
|
||||||
|
duration: params.duration || 5,
|
||||||
|
images_list: params.images_list || [],
|
||||||
|
video_files: params.video_files || []
|
||||||
|
};
|
||||||
|
return submitAndPoll(endpoint, payload, apiKey, params.onRequestId, 900);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processV2V(apiKey, params) {
|
||||||
|
const modelInfo = getV2VModelById(params.model);
|
||||||
|
const endpoint = modelInfo?.endpoint || params.model;
|
||||||
|
const videoField = modelInfo?.videoField || 'video_url';
|
||||||
|
const payload = { [videoField]: params.video_url };
|
||||||
|
if (modelInfo?.imageField && params.image_url) {
|
||||||
|
payload[modelInfo.imageField] = params.image_url;
|
||||||
|
}
|
||||||
|
if (modelInfo?.hasPrompt && params.prompt) {
|
||||||
|
payload.prompt = params.prompt;
|
||||||
|
}
|
||||||
|
return submitAndPoll(endpoint, payload, apiKey, params.onRequestId, 900);
|
||||||
|
}
|
||||||
|
|
||||||
export async function processLipSync(apiKey, params) {
|
export async function processLipSync(apiKey, params) {
|
||||||
const modelInfo = getLipSyncModelById(params.model);
|
const modelInfo = getLipSyncModelById(params.model);
|
||||||
const endpoint = modelInfo?.endpoint || params.model;
|
const endpoint = modelInfo?.endpoint || params.model;
|
||||||
|
|
@ -115,27 +152,484 @@ export async function processLipSync(apiKey, params) {
|
||||||
if (params.audio_url) payload.audio_url = params.audio_url;
|
if (params.audio_url) payload.audio_url = params.audio_url;
|
||||||
if (params.image_url) payload.image_url = params.image_url;
|
if (params.image_url) payload.image_url = params.image_url;
|
||||||
if (params.video_url) payload.video_url = params.video_url;
|
if (params.video_url) payload.video_url = params.video_url;
|
||||||
if (params.prompt) payload.prompt = params.prompt;
|
if (modelInfo?.hasPrompt) payload.prompt = params.prompt || '';
|
||||||
if (params.resolution) payload.resolution = params.resolution;
|
if (params.resolution) payload.resolution = params.resolution;
|
||||||
if (params.seed !== undefined && params.seed !== -1) payload.seed = params.seed;
|
if (params.seed !== undefined && params.seed !== -1) payload.seed = params.seed;
|
||||||
return submitAndPoll(endpoint, payload, apiKey, params.onRequestId, 900);
|
return submitAndPoll(endpoint, payload, apiKey, params.onRequestId, 900);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function uploadFile(apiKey, file) {
|
export function uploadFile(apiKey, file, onProgress) {
|
||||||
const url = `${BASE_URL}/api/v1/upload_file`;
|
return new Promise((resolve, reject) => {
|
||||||
const formData = new FormData();
|
const url = `${BASE_URL}/api/v1/upload_file`;
|
||||||
formData.append('file', file);
|
const formData = new FormData();
|
||||||
const response = await fetch(url, {
|
formData.append('file', file);
|
||||||
method: 'POST',
|
|
||||||
headers: { 'x-api-key': apiKey },
|
const xhr = new XMLHttpRequest();
|
||||||
body: formData
|
xhr.open('POST', url);
|
||||||
|
xhr.setRequestHeader('x-api-key', apiKey);
|
||||||
|
|
||||||
|
if (onProgress) {
|
||||||
|
xhr.upload.onprogress = (event) => {
|
||||||
|
if (event.lengthComputable) {
|
||||||
|
const percentComplete = Math.round((event.loaded / event.total) * 100);
|
||||||
|
onProgress(percentComplete);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
xhr.onload = () => {
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(xhr.responseText);
|
||||||
|
const fileUrl = data.url || data.file_url || data.data?.url;
|
||||||
|
if (!fileUrl) {
|
||||||
|
reject(new Error('No URL returned from file upload'));
|
||||||
|
} else {
|
||||||
|
resolve(fileUrl);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
reject(new Error('Failed to parse upload response'));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let detail = xhr.statusText;
|
||||||
|
try {
|
||||||
|
const errObj = JSON.parse(xhr.responseText);
|
||||||
|
detail = errObj.detail || detail;
|
||||||
|
} catch (e) {
|
||||||
|
// fallback to statusText
|
||||||
|
}
|
||||||
|
reject(new Error(`File upload failed: ${xhr.status} - ${detail}`));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onerror = () => reject(new Error('Network error during file upload'));
|
||||||
|
xhr.send(formData);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserBalance(apiKey) {
|
||||||
|
const response = await fetch(`${BASE_URL}/api/v1/account/balance`, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-api-key': apiKey
|
||||||
|
}
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errText = await response.text();
|
const errText = await response.text();
|
||||||
throw new Error(`File upload failed: ${response.status} - ${errText.slice(0, 100)}`);
|
throw new Error(`Failed to fetch balance: ${response.status} - ${errText.slice(0, 100)}`);
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTemplateWorkflows(apiKey) {
|
||||||
|
const response = await fetch(`${BASE_URL}/workflow/get-template-workflows`, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-api-key': apiKey
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const errText = await response.text();
|
||||||
|
throw new Error(`Failed to fetch template workflows: ${response.status} - ${errText.slice(0, 100)}`);
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getUserWorkflows(apiKey) {
|
||||||
|
const response = await fetch(`${BASE_URL}/workflow/get-workflow-defs`, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-api-key': apiKey
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const errText = await response.text();
|
||||||
|
throw new Error(`Failed to fetch user workflows: ${response.status} - ${errText.slice(0, 100)}`);
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getPublishedWorkflows(apiKey) {
|
||||||
|
const response = await fetch(`${BASE_URL}/workflow/get-published-workflows`, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-api-key': apiKey
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const errText = await response.text();
|
||||||
|
throw new Error(`Failed to fetch published workflows: ${response.status} - ${errText.slice(0, 100)}`);
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Agents — uses direct URL → https://api.muapi.ai/agents/...
|
||||||
|
export async function getTemplateAgents(apiKey) {
|
||||||
|
const response = await fetch(`${BASE_URL}/agents/templates/agents`, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-api-key': apiKey
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const errText = await response.text();
|
||||||
|
throw new Error(`Failed to fetch template agents: ${response.status} - ${errText.slice(0, 100)}`);
|
||||||
}
|
}
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const fileUrl = data.url || data.file_url || data.data?.url;
|
return Array.isArray(data) ? data : (data.agents || data.items || []);
|
||||||
if (!fileUrl) throw new Error('No URL returned from file upload');
|
};
|
||||||
return fileUrl;
|
|
||||||
|
export async function getUserAgents(apiKey) {
|
||||||
|
const response = await fetch(`${BASE_URL}/agents/user/agents`, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-api-key': apiKey
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const errText = await response.text();
|
||||||
|
throw new Error(`Failed to fetch user agents: ${response.status} - ${errText.slice(0, 100)}`);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
return Array.isArray(data) ? data : (data.agents || data.items || []);
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getPublishedAgents(apiKey) {
|
||||||
|
// MuAPI: GET /agents/featured/agents
|
||||||
|
const response = await fetch(`${BASE_URL}/agents/featured/agents`, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-api-key': apiKey
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const errText = await response.text();
|
||||||
|
throw new Error(`Failed to fetch featured agents: ${response.status} - ${errText.slice(0, 100)}`);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
return Array.isArray(data) ? data : (data.agents || data.items || []);
|
||||||
|
};
|
||||||
|
|
||||||
|
// GET /agents/user/conversations — returns the user's chat history across all agents
|
||||||
|
export async function getUserConversations(apiKey) {
|
||||||
|
const response = await fetch(`${BASE_URL}/agents/user/conversations`, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-api-key': apiKey
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const errText = await response.text();
|
||||||
|
throw new Error(`Failed to fetch conversations: ${response.status} - ${errText.slice(0, 100)}`);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function createWorkflow(apiKey, payload) {
|
||||||
|
const response = await fetch(`${BASE_URL}/workflow/create`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-api-key': apiKey
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const errText = await response.text();
|
||||||
|
throw new Error(`Failed to create workflow: ${response.status} - ${errText.slice(0, 100)}`);
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function updateWorkflowName(apiKey, workflowId, name) {
|
||||||
|
const response = await fetch(`${BASE_URL}/workflow/update-name/${workflowId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-api-key': apiKey
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ name })
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const errText = await response.text();
|
||||||
|
throw new Error(`Failed to rename workflow: ${response.status} - ${errText.slice(0, 100)}`);
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function deleteWorkflow(apiKey, workflowId) {
|
||||||
|
const response = await fetch(`${BASE_URL}/workflow/delete-workflow-def/${workflowId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-api-key': apiKey
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const errText = await response.text();
|
||||||
|
throw new Error(`Failed to delete workflow: ${response.status} - ${errText.slice(0, 100)}`);
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getWorkflowInputs(apiKey, workflowId) {
|
||||||
|
const response = await fetch(`${BASE_URL}/workflow/${workflowId}/api-inputs`, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-api-key': apiKey
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const errText = await response.text();
|
||||||
|
throw new Error(`Failed to fetch workflow inputs: ${response.status} - ${errText.slice(0, 100)}`);
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function executeWorkflow(apiKey, workflowId, inputs) {
|
||||||
|
const response = await fetch(`${BASE_URL}/workflow/${workflowId}/api-execute`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-api-key': apiKey
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ inputs })
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const errText = await response.text();
|
||||||
|
throw new Error(`Failed to execute workflow: ${response.status} - ${errText.slice(0, 100)}`);
|
||||||
|
}
|
||||||
|
const submitData = await response.json();
|
||||||
|
const runId = submitData.run_id || submitData.id;
|
||||||
|
if (!runId) return submitData;
|
||||||
|
|
||||||
|
// Poll for results
|
||||||
|
return await pollWorkflowResult(runId, apiKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function pollWorkflowResult(runId, apiKey, maxAttempts = 900, interval = 2000) {
|
||||||
|
const pollUrl = `${BASE_URL}/workflow/run/${runId}/api-outputs`;
|
||||||
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, interval));
|
||||||
|
try {
|
||||||
|
const response = await fetch(pollUrl, {
|
||||||
|
headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey }
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status >= 500) continue;
|
||||||
|
throw new Error(`Poll Failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
const status = data.status?.toLowerCase();
|
||||||
|
if (status === 'completed' || status === 'succeeded' || status === 'success') return data;
|
||||||
|
if (status === 'failed' || status === 'error') throw new Error(`Workflow failed: ${data.error || 'Unknown error'}`);
|
||||||
|
} catch (error) {
|
||||||
|
if (attempt === maxAttempts) throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('Workflow timed out after polling.');
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getAllNodeSchemas(apiKey, workflowId) {
|
||||||
|
const response = await fetch(`${BASE_URL}/workflow/${workflowId}/node-schemas`, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-api-key': apiKey
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const errText = await response.text();
|
||||||
|
throw new Error(`Failed to fetch node schemas: ${response.status} - ${errText.slice(0, 100)}`);
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getWorkflowData(apiKey, workflowId) {
|
||||||
|
const response = await fetch(`${BASE_URL}/workflow/get-workflow-def/${workflowId}`, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-api-key': apiKey
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const errText = await response.text();
|
||||||
|
throw new Error(`Failed to fetch workflow data: ${response.status} - ${errText.slice(0, 100)}`);
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getNodeSchemas(apiKey, workflowId) {
|
||||||
|
const response = await fetch(`${BASE_URL}/workflow/${workflowId}/api-node-schemas`, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-api-key': apiKey
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const errText = await response.text();
|
||||||
|
throw new Error(`Failed to fetch node schemas: ${response.status} - ${errText.slice(0, 100)}`);
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runSingleNode(apiKey, workflowId, nodeId, payload) {
|
||||||
|
const response = await fetch(`${BASE_URL}/workflow/${workflowId}/node/${nodeId}/run`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-api-key': apiKey
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const errText = await response.text();
|
||||||
|
throw new Error(`Failed to run single node: ${response.status} - ${errText.slice(0, 100)}`);
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteNodeRun(apiKey, nodeRunId) {
|
||||||
|
const response = await fetch(`${BASE_URL}/workflow/node-run/${nodeRunId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-api-key': apiKey
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const errText = await response.text();
|
||||||
|
throw new Error(`Failed to delete node run: ${response.status} - ${errText.slice(0, 100)}`);
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getNodeStatus(apiKey, runId) {
|
||||||
|
const response = await fetch(`${BASE_URL}/workflow/run/${runId}/status`, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-api-key': apiKey
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const errText = await response.text();
|
||||||
|
throw new Error(`Failed to get node status: ${response.status} - ${errText.slice(0, 100)}`);
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle proxy requests centralizing communication logic with MuAPI.
|
||||||
|
* This is used by the server-side entry points.
|
||||||
|
*/
|
||||||
|
export async function handleProxyRequest(prefix, path, method, headers, body, apiKey) {
|
||||||
|
const url = `${BASE_URL}/${prefix}/${path}`;
|
||||||
|
|
||||||
|
const finalHeaders = new Headers(headers);
|
||||||
|
finalHeaders.delete('host');
|
||||||
|
finalHeaders.delete('connection');
|
||||||
|
finalHeaders.delete('content-length'); // Let fetch recalculate this for safety
|
||||||
|
|
||||||
|
if (apiKey) {
|
||||||
|
finalHeaders.set('x-api-key', apiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: finalHeaders,
|
||||||
|
body: (method !== 'GET' && method !== 'HEAD') ? body : undefined,
|
||||||
|
redirect: 'follow',
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentType = response.headers.get('Content-Type') || 'application/json';
|
||||||
|
const buffer = await response.arrayBuffer();
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: response.status,
|
||||||
|
contentType,
|
||||||
|
data: buffer
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`MuAPI Proxy error for ${url}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A centralized handler for Next.js API routes or middleware.
|
||||||
|
*/
|
||||||
|
export async function handleServerSideProxy(prefix, request, params, apiKey) {
|
||||||
|
try {
|
||||||
|
const slug = await params;
|
||||||
|
const pathSegments = slug.path || [];
|
||||||
|
const path = pathSegments.join('/');
|
||||||
|
|
||||||
|
const method = request.method;
|
||||||
|
let body = null;
|
||||||
|
if (method !== 'GET' && method !== 'HEAD') {
|
||||||
|
body = await request.arrayBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { search } = new URL(request.url);
|
||||||
|
const pathWithSearch = search ? `${path}${search}` : path;
|
||||||
|
|
||||||
|
return await handleProxyRequest(
|
||||||
|
prefix,
|
||||||
|
pathWithSearch,
|
||||||
|
method,
|
||||||
|
request.headers,
|
||||||
|
body,
|
||||||
|
apiKey
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Server proxy failed:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function calculateDynamicCost(apiKey, taskName, payload) {
|
||||||
|
const response = await fetch(`${BASE_URL}/api/v1/app/calculate_dynamic_cost`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-api-key': apiKey
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ task_name: taskName, payload })
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const errText = await response.text();
|
||||||
|
throw new Error(`Failed to calculate dynamic cost: ${response.status} - ${errText.slice(0, 100)}`);
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerAppInterest(apiKey, appName) {
|
||||||
|
const response = await fetch(`${BASE_URL}/app/interest`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-api-key': apiKey
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ app_name: appName })
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const errText = await response.text();
|
||||||
|
throw new Error(`Failed to register interest: ${response.status} - ${errText.slice(0, 100)}`);
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAppInterests(apiKey) {
|
||||||
|
const response = await fetch(`${BASE_URL}/app/interests`, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-api-key': apiKey
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const errText = await response.text();
|
||||||
|
throw new Error(`Failed to fetch interests: ${response.status} - ${errText.slice(0, 100)}`);
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ module.exports = {
|
||||||
'panel-bg': '#0a0a0a',
|
'panel-bg': '#0a0a0a',
|
||||||
'card-bg': '#111111',
|
'card-bg': '#111111',
|
||||||
primary: '#d9ff00',
|
primary: '#d9ff00',
|
||||||
|
secondary: '#a1a1aa',
|
||||||
|
muted: '#52525b',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
# Open Higgsfield AI: Technical Documentation & Context
|
# Open Generative AI: Technical Documentation & Context
|
||||||
|
|
||||||
This document serves as a comprehensive knowledge base for the Open Higgsfield AI project. It details the architecture, key components, API integration patterns, and state management strategies used in the application.
|
This document serves as a comprehensive knowledge base for the Open Generative AI project. It details the architecture, key components, API integration patterns, and state management strategies used in the application.
|
||||||
|
|
||||||
## 1. Project Vision & Overview
|
## 1. Project Vision & Overview
|
||||||
|
|
||||||
**Open Higgsfield AI** is an ambitious open-source project dedicated to **replicating the full functionality of the Higgsfield platform**.
|
**Open Generative AI** is an ambitious open-source project dedicated to **replicating the full functionality of the Higgsfield platform**.
|
||||||
|
|
||||||
- **Core Goal:** To build a feature-complete, self-hosted alternative to Higgsfield, starting with **Image Generation** (Nano) and expanding into **Video Generation** (Cinema) and other creative tools.
|
- **Core Goal:** To build a feature-complete, self-hosted alternative to Higgsfield, starting with **Image Generation** (Nano) and expanding into **Video Generation** (Cinema) and other creative tools.
|
||||||
- **Current State:** The Image Studio ("Nano Banana Pro" interface) is fully operational, featuring a premium dark-mode UI, history management, and multi-model support via the [Muapi.ai](https://muapi.ai) engine.
|
- **Current State:** The Image Studio ("Nano Banana Pro" interface) is fully operational, featuring a premium dark-mode UI, history management, and multi-model support via the [Muapi.ai](https://muapi.ai) engine.
|
||||||
- **Future Direction:** The architecture is designed to scale for video generation, model training interfaces, and advanced editing tools, mirroring the evolving capabilities of Higgsfield.
|
- **Future Direction:** The architecture is designed to scale for video generation, model training interfaces, and advanced editing tools, mirroring the evolving capabilities of Higgsfield.
|
||||||
|
|
||||||
- **Stack:** Vite, Vanilla JavaScript, Tailwind CSS v4.
|
- **Stack:** Vite, Vanilla JavaScript, Tailwind CSS v4.
|
||||||
- **Repository:** `https://github.com/Anil-matcha/Open-Higgsfield-AI`
|
- **Repository:** `https://github.com/Anil-matcha/Open-Generative-AI`
|
||||||
- **Primary Branch:** `main`
|
- **Primary Branch:** `main`
|
||||||
|
|
||||||
## 2. Architecture & File Structure
|
## 2. Architecture & File Structure
|
||||||
|
|
|
||||||
179
scripts/test_minimax_provider.js
Normal file
179
scripts/test_minimax_provider.js
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
/**
|
||||||
|
* Test script for MiniMax provider integration.
|
||||||
|
*
|
||||||
|
* Verifies that the MiniMax Image 01 model is correctly registered in models.js
|
||||||
|
* and that the model definition has the expected structure.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node scripts/test_minimax_provider.js
|
||||||
|
*
|
||||||
|
* Set MUAPI_KEY env var to run the live API smoke test:
|
||||||
|
* MUAPI_KEY=your_key node scripts/test_minimax_provider.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync } from "fs";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import { dirname, join } from "path";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const ROOT = join(__dirname, "..");
|
||||||
|
|
||||||
|
// ── 1. Model registration check ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
const modelsContent = readFileSync(
|
||||||
|
join(ROOT, "src", "lib", "models.js"),
|
||||||
|
"utf-8"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Extract the t2iModels JSON array via a simple regex
|
||||||
|
const t2iMatch = modelsContent.match(/export const t2iModels = (\[[\s\S]*?\]);/);
|
||||||
|
if (!t2iMatch) {
|
||||||
|
console.error("FAIL: Could not parse t2iModels from src/lib/models.js");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let t2iModels;
|
||||||
|
try {
|
||||||
|
t2iModels = JSON.parse(t2iMatch[1]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("FAIL: t2iModels is not valid JSON:", err.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const minimaxModel = t2iModels.find((m) => m.id === "minimax-image-01");
|
||||||
|
|
||||||
|
if (!minimaxModel) {
|
||||||
|
console.error(
|
||||||
|
'FAIL: "minimax-image-01" not found in t2iModels.\n' +
|
||||||
|
"Expected it to be registered in src/lib/models.js."
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
const required = ["id", "name", "endpoint", "family", "inputs"];
|
||||||
|
for (const field of required) {
|
||||||
|
if (!minimaxModel[field]) {
|
||||||
|
console.error(`FAIL: minimax-image-01 is missing required field: ${field}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (minimaxModel.family !== "minimax") {
|
||||||
|
console.error(
|
||||||
|
`FAIL: expected family "minimax", got "${minimaxModel.family}"`
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!minimaxModel.inputs.prompt) {
|
||||||
|
console.error("FAIL: minimax-image-01 inputs missing 'prompt' field");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!minimaxModel.inputs.aspect_ratio?.enum?.includes("1:1")) {
|
||||||
|
console.error(
|
||||||
|
"FAIL: minimax-image-01 aspect_ratio enum does not include '1:1'"
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("PASS: minimax-image-01 is correctly registered in t2iModels");
|
||||||
|
console.log(
|
||||||
|
` endpoint=${minimaxModel.endpoint} family=${minimaxModel.family}`
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
` aspect ratios: ${minimaxModel.inputs.aspect_ratio.enum.join(", ")}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── 2. models_dump.json check ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const dump = JSON.parse(
|
||||||
|
readFileSync(join(ROOT, "models_dump.json"), "utf-8")
|
||||||
|
);
|
||||||
|
const dumpEntry = dump.t2i?.find((m) => m.id === "minimax-image-01");
|
||||||
|
if (!dumpEntry) {
|
||||||
|
console.error(
|
||||||
|
'FAIL: "minimax-image-01" not found in models_dump.json t2i section'
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log("PASS: minimax-image-01 found in models_dump.json");
|
||||||
|
|
||||||
|
// ── 3. Live API smoke test (optional) ────────────────────────────────────────
|
||||||
|
|
||||||
|
const apiKey = process.env.MUAPI_KEY;
|
||||||
|
if (!apiKey) {
|
||||||
|
console.log(
|
||||||
|
"\nINFO: Skipping live API test (set MUAPI_KEY env var to enable)."
|
||||||
|
);
|
||||||
|
console.log("\nAll checks passed.");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\nRunning live API smoke test against muapi.ai …");
|
||||||
|
|
||||||
|
const MUAPI_BASE = "https://api.muapi.ai";
|
||||||
|
|
||||||
|
async function testMiniMaxImageGeneration() {
|
||||||
|
const endpoint = minimaxModel.endpoint;
|
||||||
|
const url = `${MUAPI_BASE}/api/v1/${endpoint}`;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
prompt: "A simple test: a red apple on a white background.",
|
||||||
|
aspect_ratio: "1:1",
|
||||||
|
num_images: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`POST ${url}`);
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", "x-api-key": apiKey },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
console.error(`FAIL: API returned ${res.status}: ${text.slice(0, 200)}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
const requestId = data.request_id || data.id;
|
||||||
|
if (!requestId) {
|
||||||
|
console.error("FAIL: No request_id in response:", JSON.stringify(data));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(`PASS: Generation queued — request_id=${requestId}`);
|
||||||
|
|
||||||
|
// Poll for result (max 60 s)
|
||||||
|
const pollUrl = `${MUAPI_BASE}/api/v1/predictions/${requestId}/result`;
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
await new Promise((r) => setTimeout(r, 2000));
|
||||||
|
const poll = await fetch(pollUrl, {
|
||||||
|
headers: { "Content-Type": "application/json", "x-api-key": apiKey },
|
||||||
|
});
|
||||||
|
if (!poll.ok) continue;
|
||||||
|
const result = await poll.json();
|
||||||
|
const status = result.status?.toLowerCase();
|
||||||
|
if (status === "completed" || status === "succeeded" || status === "success") {
|
||||||
|
const imageUrl =
|
||||||
|
result.outputs?.[0] || result.url || result.output?.url;
|
||||||
|
console.log(`PASS: Generation complete — image URL: ${imageUrl}`);
|
||||||
|
console.log("\nAll checks passed.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (status === "failed" || status === "error") {
|
||||||
|
console.error("FAIL: Generation failed:", result.error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(` Polling … status=${status}`);
|
||||||
|
}
|
||||||
|
console.error("FAIL: Timed out waiting for generation result.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
testMiniMaxImageGeneration().catch((err) => {
|
||||||
|
console.error("FAIL: Unexpected error:", err.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
24
src/components/AgentStudio.js
Normal file
24
src/components/AgentStudio.js
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
export function AgentStudio() {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.className = 'w-full h-full flex flex-col items-center justify-center bg-app-bg text-white gap-4';
|
||||||
|
|
||||||
|
const icon = document.createElement('div');
|
||||||
|
icon.innerHTML = `<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" opacity="0.4">
|
||||||
|
<path d="M12 2a4 4 0 0 1 4 4 4 4 0 0 1-4 4 4 4 0 0 1-4-4 4 4 0 0 1 4-4"/>
|
||||||
|
<path d="M8 14a6 6 0 0 0-6 6h20a6 6 0 0 0-6-6H8z"/>
|
||||||
|
<path d="M9 9h.01M15 9h.01"/>
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
const title = document.createElement('p');
|
||||||
|
title.textContent = 'Agent Studio';
|
||||||
|
title.className = 'text-lg font-bold opacity-60';
|
||||||
|
|
||||||
|
const sub = document.createElement('p');
|
||||||
|
sub.textContent = 'Available in the web app at open-generative-ai.com';
|
||||||
|
sub.className = 'text-sm opacity-40';
|
||||||
|
|
||||||
|
container.appendChild(icon);
|
||||||
|
container.appendChild(title);
|
||||||
|
container.appendChild(sub);
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
@ -3,30 +3,30 @@ import { CAMERA_MAP, LENS_MAP, FOCAL_PERSPECTIVE, APERTURE_EFFECT } from '../lib
|
||||||
|
|
||||||
const ASSET_URLS = {
|
const ASSET_URLS = {
|
||||||
// CAMERA
|
// CAMERA
|
||||||
"Modular 8K Digital": "/assets/cinema/modular_8k_digital.webp",
|
"Modular 8K Digital": "./assets/cinema/modular_8k_digital.webp",
|
||||||
"Full-Frame Cine Digital": "/assets/cinema/full_frame_cine_digital.webp",
|
"Full-Frame Cine Digital": "./assets/cinema/full_frame_cine_digital.webp",
|
||||||
"Grand Format 70mm Film": "/assets/cinema/grand_format_70mm_film.webp",
|
"Grand Format 70mm Film": "./assets/cinema/grand_format_70mm_film.webp",
|
||||||
"Studio Digital S35": "/assets/cinema/studio_digital_s35.webp",
|
"Studio Digital S35": "./assets/cinema/studio_digital_s35.webp",
|
||||||
"Classic 16mm Film": "/assets/cinema/classic_16mm_film.webp",
|
"Classic 16mm Film": "./assets/cinema/classic_16mm_film.webp",
|
||||||
"Premium Large Format Digital": "/assets/cinema/premium_large_format_digital.webp",
|
"Premium Large Format Digital": "./assets/cinema/premium_large_format_digital.webp",
|
||||||
|
|
||||||
// LENS
|
// LENS
|
||||||
"Creative Tilt Lens": "/assets/cinema/creative_tilt_lens.webp",
|
"Creative Tilt Lens": "./assets/cinema/creative_tilt_lens.webp",
|
||||||
"Compact Anamorphic": "/assets/cinema/compact_anamorphic.webp",
|
"Compact Anamorphic": "./assets/cinema/compact_anamorphic.webp",
|
||||||
"Extreme Macro": "/assets/cinema/extreme_macro.webp",
|
"Extreme Macro": "./assets/cinema/extreme_macro.webp",
|
||||||
"70s Cinema Prime": "/assets/cinema/70s_cinema_prime.webp",
|
"70s Cinema Prime": "./assets/cinema/70s_cinema_prime.webp",
|
||||||
"Classic Anamorphic": "/assets/cinema/classic_anamorphic.webp",
|
"Classic Anamorphic": "./assets/cinema/classic_anamorphic.webp",
|
||||||
"Premium Modern Prime": "/assets/cinema/premium_modern_prime.webp",
|
"Premium Modern Prime": "./assets/cinema/premium_modern_prime.webp",
|
||||||
"Warm Cinema Prime": "/assets/cinema/warm_cinema_prime.webp",
|
"Warm Cinema Prime": "./assets/cinema/warm_cinema_prime.webp",
|
||||||
"Swirl Bokeh Portrait": "/assets/cinema/swirl_bokeh_portrait.webp",
|
"Swirl Bokeh Portrait": "./assets/cinema/swirl_bokeh_portrait.webp",
|
||||||
"Vintage Prime": "/assets/cinema/vintage_prime.webp",
|
"Vintage Prime": "./assets/cinema/vintage_prime.webp",
|
||||||
"Halation Diffusion": "/assets/cinema/halation_diffusion.webp",
|
"Halation Diffusion": "./assets/cinema/halation_diffusion.webp",
|
||||||
"Clinical Sharp Prime": "/assets/cinema/clinical_sharp_prime.webp",
|
"Clinical Sharp Prime": "./assets/cinema/clinical_sharp_prime.webp",
|
||||||
|
|
||||||
// APERTURE
|
// APERTURE
|
||||||
"f/1.4": "/assets/cinema/f_1_4.webp",
|
"f/1.4": "./assets/cinema/f_1_4.webp",
|
||||||
"f/4": "/assets/cinema/f_4.webp",
|
"f/4": "./assets/cinema/f_4.webp",
|
||||||
"f/11": "/assets/cinema/f_11.webp"
|
"f/11": "./assets/cinema/f_11.webp"
|
||||||
};
|
};
|
||||||
|
|
||||||
export function CameraControls(onChange) {
|
export function CameraControls(onChange) {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
|
|
||||||
import { muapi } from '../lib/muapi.js';
|
import { muapi } from '../lib/muapi.js';
|
||||||
import { CameraControls } from './CameraControls.js';
|
import { CameraControls } from './CameraControls.js';
|
||||||
import { buildNanoBananaPrompt, CAMERA_MAP, LENS_MAP } from '../lib/promptUtils.js';
|
import { buildNanoBananaPrompt, CAMERA_MAP, LENS_MAP, FOCAL_PERSPECTIVE, APERTURE_EFFECT } from '../lib/promptUtils.js';
|
||||||
import { AuthModal } from './AuthModal.js';
|
import { AuthModal } from './AuthModal.js';
|
||||||
|
|
||||||
export function CinemaStudio() {
|
export function CinemaStudio() {
|
||||||
|
|
@ -17,6 +17,9 @@ export function CinemaStudio() {
|
||||||
focal: 35,
|
focal: 35,
|
||||||
aperture: "f/1.4"
|
aperture: "f/1.4"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Camera builder panel state
|
||||||
|
let showCameraBuilder = false;
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// 1. HERO SECTION (Empty State)
|
// 1. HERO SECTION (Empty State)
|
||||||
|
|
@ -180,6 +183,13 @@ export function CinemaStudio() {
|
||||||
createDropdown(['1K', '2K', '4K'], resBtn.dataset.value, (val) => { updateResBtn(val); }, resBtn);
|
createDropdown(['1K', '2K', '4K'], resBtn.dataset.value, (val) => { updateResBtn(val); }, resBtn);
|
||||||
};
|
};
|
||||||
settingsToolbar.appendChild(resBtn);
|
settingsToolbar.appendChild(resBtn);
|
||||||
|
|
||||||
|
// Camera Builder Toggle Button
|
||||||
|
const cameraBuilderBtn = document.createElement('button');
|
||||||
|
cameraBuilderBtn.className = 'flex items-center gap-1.5 px-3 py-1.5 text-[10px] font-bold text-white/50 hover:text-white transition-colors bg-white/5 hover:bg-white/10 rounded-lg border border-white/5';
|
||||||
|
cameraBuilderBtn.setAttribute('data-tooltip', 'Quick camera builder');
|
||||||
|
cameraBuilderBtn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="6" width="20" height="12" rx="2"/><circle cx="12" cy="12" r="3"/></svg> Builder`;
|
||||||
|
settingsToolbar.appendChild(cameraBuilderBtn);
|
||||||
|
|
||||||
leftColumn.appendChild(settingsToolbar);
|
leftColumn.appendChild(settingsToolbar);
|
||||||
promptBar.appendChild(leftColumn);
|
promptBar.appendChild(leftColumn);
|
||||||
|
|
@ -193,6 +203,7 @@ export function CinemaStudio() {
|
||||||
const summaryCard = document.createElement('button');
|
const summaryCard = document.createElement('button');
|
||||||
// Removed 'hidden' class, added 'flex' and refined width constraints for mobile
|
// Removed 'hidden' class, added 'flex' and refined width constraints for mobile
|
||||||
summaryCard.className = 'flex flex-col items-start justify-center px-4 py-2 bg-[#2a2a2a] rounded-xl border border-white/5 hover:border-white/20 transition-colors text-left flex-1 min-w-[100px] md:min-w-[140px] max-w-[240px] h-[56px] relative group overflow-hidden';
|
summaryCard.className = 'flex flex-col items-start justify-center px-4 py-2 bg-[#2a2a2a] rounded-xl border border-white/5 hover:border-white/20 transition-colors text-left flex-1 min-w-[100px] md:min-w-[140px] max-w-[240px] h-[56px] relative group overflow-hidden';
|
||||||
|
summaryCard.setAttribute('data-tooltip', 'Open camera settings');
|
||||||
|
|
||||||
// Dot indicator
|
// Dot indicator
|
||||||
const dot = document.createElement('div');
|
const dot = document.createElement('div');
|
||||||
|
|
@ -224,6 +235,7 @@ export function CinemaStudio() {
|
||||||
// Generate Button
|
// Generate Button
|
||||||
const generateBtn = document.createElement('button');
|
const generateBtn = document.createElement('button');
|
||||||
generateBtn.className = 'h-[56px] px-8 bg-[#d9ff00] text-black rounded-xl font-black text-xs uppercase hover:bg-white transition-colors shadow-lg disabled:opacity-50 disabled:cursor-not-allowed';
|
generateBtn.className = 'h-[56px] px-8 bg-[#d9ff00] text-black rounded-xl font-black text-xs uppercase hover:bg-white transition-colors shadow-lg disabled:opacity-50 disabled:cursor-not-allowed';
|
||||||
|
generateBtn.setAttribute('data-tooltip', 'Generate cinema shot');
|
||||||
generateBtn.innerHTML = `GENERATE ✨`;
|
generateBtn.innerHTML = `GENERATE ✨`;
|
||||||
|
|
||||||
rightGroup.appendChild(summaryCard);
|
rightGroup.appendChild(summaryCard);
|
||||||
|
|
@ -233,6 +245,114 @@ export function CinemaStudio() {
|
||||||
promptBarWrapper.appendChild(promptBar);
|
promptBarWrapper.appendChild(promptBar);
|
||||||
container.appendChild(promptBarWrapper);
|
container.appendChild(promptBarWrapper);
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 3B. CAMERA BUILDER PANEL (Collapsible)
|
||||||
|
// ==========================================
|
||||||
|
const cameraBuilderPanel = document.createElement('div');
|
||||||
|
cameraBuilderPanel.className = 'absolute bottom-8 left-4 right-4 md:left-1/2 md:-translate-x-1/2 md:w-full md:max-w-4xl z-20';
|
||||||
|
cameraBuilderPanel.style.display = 'none'; // Hidden by default
|
||||||
|
|
||||||
|
const builderCard = document.createElement('div');
|
||||||
|
builderCard.className = 'bg-[#1a1a1a] border border-white/10 rounded-2xl p-4 shadow-3xl';
|
||||||
|
|
||||||
|
builderCard.innerHTML = `
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h4 class="text-xs font-bold text-white">Camera Builder</h4>
|
||||||
|
<button id="close-builder-btn" class="text-white/40 hover:text-white transition-colors">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label class="text-[10px] font-bold text-muted uppercase">Camera</label>
|
||||||
|
<select id="builder-camera" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-xs text-white focus:outline-none focus:border-primary/50">
|
||||||
|
${Object.keys(CAMERA_MAP).map(c => `<option value="${c}" ${c === currentSettings.camera ? 'selected' : ''}>${c}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label class="text-[10px] font-bold text-muted uppercase">Lens</label>
|
||||||
|
<select id="builder-lens" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-xs text-white focus:outline-none focus:border-primary/50">
|
||||||
|
${Object.keys(LENS_MAP).map(l => `<option value="${l}" ${l === currentSettings.lens ? 'selected' : ''}>${l}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label class="text-[10px] font-bold text-muted uppercase">Focal</label>
|
||||||
|
<select id="builder-focal" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-xs text-white focus:outline-none focus:border-primary/50">
|
||||||
|
${Object.keys(FOCAL_PERSPECTIVE).map(f => `<option value="${f}" ${f === currentSettings.focal ? 'selected' : ''}>${f}mm</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label class="text-[10px] font-bold text-muted uppercase">Aperture</label>
|
||||||
|
<select id="builder-aperture" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-xs text-white focus:outline-none focus:border-primary/50">
|
||||||
|
${Object.keys(APERTURE_EFFECT).map(a => `<option value="${a}" ${a === currentSettings.aperture ? 'selected' : ''}>${a}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-[10px] font-bold text-muted uppercase">Preview</label>
|
||||||
|
<div id="builder-preview" class="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2.5 text-white text-xs min-h-[40px]"></div>
|
||||||
|
<button id="apply-builder-btn" class="px-4 py-2 bg-primary text-black rounded-lg text-xs font-bold hover:shadow-glow transition-all">
|
||||||
|
Use This Setup
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
cameraBuilderPanel.appendChild(builderCard);
|
||||||
|
container.appendChild(cameraBuilderPanel);
|
||||||
|
|
||||||
|
// Camera Builder toggle logic
|
||||||
|
cameraBuilderBtn.onclick = () => {
|
||||||
|
showCameraBuilder = !showCameraBuilder;
|
||||||
|
cameraBuilderPanel.style.display = showCameraBuilder ? 'block' : 'none';
|
||||||
|
if (showCameraBuilder) updateBuilderPreview();
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeBuilderBtn = cameraBuilderPanel.querySelector('#close-builder-btn');
|
||||||
|
if (closeBuilderBtn) closeBuilderBtn.onclick = () => {
|
||||||
|
showCameraBuilder = false;
|
||||||
|
cameraBuilderPanel.style.display = 'none';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update builder preview
|
||||||
|
const updateBuilderPreview = () => {
|
||||||
|
const camera = builderCard.querySelector('#builder-camera')?.value || currentSettings.camera;
|
||||||
|
const lens = builderCard.querySelector('#builder-lens')?.value || currentSettings.lens;
|
||||||
|
const focal = parseInt(builderCard.querySelector('#builder-focal')?.value || currentSettings.focal);
|
||||||
|
const aperture = builderCard.querySelector('#builder-aperture')?.value || currentSettings.aperture;
|
||||||
|
|
||||||
|
const preview = buildNanoBananaPrompt('', camera, lens, focal, aperture);
|
||||||
|
const previewEl = builderCard.querySelector('#builder-preview');
|
||||||
|
if (previewEl) {
|
||||||
|
previewEl.textContent = preview || 'Select camera settings to see preview...';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Builder event listeners
|
||||||
|
const builderCamera = builderCard.querySelector('#builder-camera');
|
||||||
|
const builderLens = builderCard.querySelector('#builder-lens');
|
||||||
|
const builderFocal = builderCard.querySelector('#builder-focal');
|
||||||
|
const builderAperture = builderCard.querySelector('#builder-aperture');
|
||||||
|
|
||||||
|
if (builderCamera) builderCamera.onchange = updateBuilderPreview;
|
||||||
|
if (builderLens) builderLens.onchange = updateBuilderPreview;
|
||||||
|
if (builderFocal) builderFocal.onchange = updateBuilderPreview;
|
||||||
|
if (builderAperture) builderAperture.onchange = updateBuilderPreview;
|
||||||
|
|
||||||
|
const applyBuilderBtn = builderCard.querySelector('#apply-builder-btn');
|
||||||
|
if (applyBuilderBtn) {
|
||||||
|
applyBuilderBtn.onclick = () => {
|
||||||
|
currentSettings.camera = builderCamera?.value || currentSettings.camera;
|
||||||
|
currentSettings.lens = builderLens?.value || currentSettings.lens;
|
||||||
|
currentSettings.focal = parseInt(builderFocal?.value || currentSettings.focal);
|
||||||
|
currentSettings.aperture = builderAperture?.value || currentSettings.aperture;
|
||||||
|
updateSummaryCard();
|
||||||
|
showCameraBuilder = false;
|
||||||
|
cameraBuilderPanel.style.display = 'none';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// 3. HISTORY SIDEBAR
|
// 3. HISTORY SIDEBAR
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ export function Header(navigate) {
|
||||||
|
|
||||||
const menu = document.createElement('nav');
|
const menu = document.createElement('nav');
|
||||||
menu.className = 'hidden lg:flex items-center gap-6 text-[13px] font-bold text-secondary';
|
menu.className = 'hidden lg:flex items-center gap-6 text-[13px] font-bold text-secondary';
|
||||||
const items = ['Explore', 'Image', 'Video', 'Lip Sync', 'Edit', 'Character', 'Contests', 'Vibe Motion', 'Cinema Studio', 'AI Influencer', 'Apps', 'Assist', 'Community'];
|
const items = ['Image', 'Video', 'Lip Sync', 'Cinema Studio', 'Workflows', 'Agents', 'MCP & CLI'];
|
||||||
|
|
||||||
items.forEach(item => {
|
items.forEach(item => {
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
|
|
@ -41,10 +41,6 @@ export function Header(navigate) {
|
||||||
link.appendChild(dot);
|
link.appendChild(dot);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item === 'Contests') {
|
|
||||||
link.innerHTML += ' <span class="bg-primary/10 text-primary text-[8px] px-1.5 py-0.5 rounded-full ml-1 border border-primary/20">New</span>';
|
|
||||||
}
|
|
||||||
|
|
||||||
link.onclick = () => {
|
link.onclick = () => {
|
||||||
// Remove active state from all
|
// Remove active state from all
|
||||||
Array.from(menu.children).forEach(child => child.classList.remove('text-white'));
|
Array.from(menu.children).forEach(child => child.classList.remove('text-white'));
|
||||||
|
|
@ -55,6 +51,9 @@ export function Header(navigate) {
|
||||||
else if (item === 'Video') navigate('video');
|
else if (item === 'Video') navigate('video');
|
||||||
else if (item === 'Lip Sync') navigate('lipsync');
|
else if (item === 'Lip Sync') navigate('lipsync');
|
||||||
else if (item === 'Cinema Studio') navigate('cinema');
|
else if (item === 'Cinema Studio') navigate('cinema');
|
||||||
|
else if (item === 'Workflows') navigate('workflows');
|
||||||
|
else if (item === 'Agents') navigate('agents');
|
||||||
|
else if (item === 'MCP & CLI') navigate('mcp-cli');
|
||||||
};
|
};
|
||||||
|
|
||||||
menu.appendChild(link);
|
menu.appendChild(link);
|
||||||
|
|
@ -66,19 +65,21 @@ export function Header(navigate) {
|
||||||
const rightPart = document.createElement('div');
|
const rightPart = document.createElement('div');
|
||||||
rightPart.className = 'flex items-center gap-4';
|
rightPart.className = 'flex items-center gap-4';
|
||||||
|
|
||||||
const keyBtn = document.createElement('button');
|
const settingsBtn = document.createElement('button');
|
||||||
keyBtn.className = 'p-2 text-secondary hover:text-white transition-colors';
|
settingsBtn.className = 'flex items-center gap-2 px-3 py-1.5 rounded-md border border-white/10 bg-white/5 text-[13px] font-bold text-white/80 hover:text-white hover:bg-white/10 hover:border-white/20 transition-colors';
|
||||||
keyBtn.title = 'Update API Key';
|
settingsBtn.title = 'Settings — API key, local models, preferences';
|
||||||
keyBtn.innerHTML = `
|
settingsBtn.innerHTML = `
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3m-3-3l-2.25-2.25"/>
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<span>Settings</span>
|
||||||
`;
|
`;
|
||||||
keyBtn.onclick = () => {
|
settingsBtn.onclick = () => {
|
||||||
document.body.appendChild(SettingsModal());
|
document.body.appendChild(SettingsModal());
|
||||||
};
|
};
|
||||||
|
|
||||||
rightPart.appendChild(keyBtn);
|
rightPart.appendChild(settingsBtn);
|
||||||
|
|
||||||
navBar.appendChild(leftPart);
|
navBar.appendChild(leftPart);
|
||||||
navBar.appendChild(rightPart);
|
navBar.appendChild(rightPart);
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,24 @@ import {
|
||||||
i2iModels, getAspectRatiosForI2IModel, getResolutionsForI2IModel, getQualityFieldForI2IModel,
|
i2iModels, getAspectRatiosForI2IModel, getResolutionsForI2IModel, getQualityFieldForI2IModel,
|
||||||
getMaxImagesForI2IModel
|
getMaxImagesForI2IModel
|
||||||
} from '../lib/models.js';
|
} from '../lib/models.js';
|
||||||
|
import { localAI, isLocalAIAvailable } from '../lib/localInferenceClient.js';
|
||||||
|
import { LOCAL_MODEL_CATALOG, getLocalModelById } from '../lib/localModels.js';
|
||||||
|
import { ENHANCE_TAGS, QUICK_PROMPTS } from '../lib/promptUtils.js';
|
||||||
import { AuthModal } from './AuthModal.js';
|
import { AuthModal } from './AuthModal.js';
|
||||||
import { createUploadPicker } from './UploadPicker.js';
|
import { createUploadPicker } from './UploadPicker.js';
|
||||||
import { savePendingJob, removePendingJob, getPendingJobs } from '../lib/pendingJobs.js';
|
import { savePendingJob, removePendingJob, getPendingJobs } from '../lib/pendingJobs.js';
|
||||||
|
|
||||||
|
function createInlineInstructions(type) {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'w-full text-center text-white/30 text-sm flex flex-col items-center gap-2 py-2';
|
||||||
|
const icon = type === 'image' ? '🖼️' : '🎬';
|
||||||
|
el.innerHTML = `
|
||||||
|
<p>${icon} Enter a prompt above and click <span class="text-primary font-semibold">Generate</span> to create your ${type}.</p>
|
||||||
|
<p class="text-xs text-white/20">Tip: Be descriptive — include style, lighting, mood, and subject for best results.</p>
|
||||||
|
`;
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
export function ImageStudio() {
|
export function ImageStudio() {
|
||||||
const container = document.createElement('div');
|
const container = document.createElement('div');
|
||||||
container.className = 'w-full h-full flex flex-col items-center justify-center bg-app-bg relative p-4 md:p-6 overflow-y-auto custom-scrollbar overflow-x-hidden';
|
container.className = 'w-full h-full flex flex-col items-center justify-center bg-app-bg relative p-4 md:p-6 overflow-y-auto custom-scrollbar overflow-x-hidden';
|
||||||
|
|
@ -21,6 +35,33 @@ export function ImageStudio() {
|
||||||
let uploadedImageUrls = []; // array of uploaded image URLs (multi-image support)
|
let uploadedImageUrls = []; // array of uploaded image URLs (multi-image support)
|
||||||
let imageMode = false; // false = t2i models, true = i2i models
|
let imageMode = false; // false = t2i models, true = i2i models
|
||||||
|
|
||||||
|
// Local inference state — only image-capable models surface here.
|
||||||
|
// sd.cpp uses type='sd1'|'sdxl'|'z-image'; Wan2GP image models use type='image'.
|
||||||
|
// Wan2GP video models (type='video') are hidden from ImageStudio.
|
||||||
|
const LOCAL_IMAGE_MODELS = LOCAL_MODEL_CATALOG.filter(m => m.type !== 'video');
|
||||||
|
let useLocalModel = false;
|
||||||
|
let selectedLocalModel = LOCAL_IMAGE_MODELS[0]?.id || null;
|
||||||
|
let localGenProgress = 0; // 0–1
|
||||||
|
|
||||||
|
// Advanced parameters state
|
||||||
|
let negativePrompt = '';
|
||||||
|
let guidanceScale = 7.5;
|
||||||
|
let steps = 25;
|
||||||
|
let seed = -1;
|
||||||
|
let showAdvanced = false;
|
||||||
|
let selectedStyle = 'None';
|
||||||
|
let batchCount = 1;
|
||||||
|
|
||||||
|
// New advanced controls
|
||||||
|
let customWidth = 0; // 0 means use default (aspect ratio based)
|
||||||
|
let customHeight = 0;
|
||||||
|
let referenceStrength = 50; // 0-100, for style reference models
|
||||||
|
let selectedLora = ''; // LoRA model ID from Civitai
|
||||||
|
let loraWeight = 1.0;
|
||||||
|
|
||||||
|
// Quick tools panel state
|
||||||
|
let showToolsPanel = false;
|
||||||
|
|
||||||
const getCurrentModels = () => imageMode ? i2iModels : t2iModels;
|
const getCurrentModels = () => imageMode ? i2iModels : t2iModels;
|
||||||
const getCurrentAspectRatios = (id) => imageMode ? getAspectRatiosForI2IModel(id) : getAspectRatiosForModel(id);
|
const getCurrentAspectRatios = (id) => imageMode ? getAspectRatiosForI2IModel(id) : getAspectRatiosForModel(id);
|
||||||
const getCurrentResolutions = (id) => imageMode ? getResolutionsForI2IModel(id) : getResolutionsForModel(id);
|
const getCurrentResolutions = (id) => imageMode ? getResolutionsForI2IModel(id) : getResolutionsForModel(id);
|
||||||
|
|
@ -71,6 +112,8 @@ export function ImageStudio() {
|
||||||
// --- Image Upload Picker (Image-to-Image) ---
|
// --- Image Upload Picker (Image-to-Image) ---
|
||||||
const picker = createUploadPicker({
|
const picker = createUploadPicker({
|
||||||
anchorContainer: container,
|
anchorContainer: container,
|
||||||
|
uploadFn: (file) => useLocalModel ? URL.createObjectURL(file) : muapi.uploadFile(file),
|
||||||
|
requireApiKey: () => !useLocalModel,
|
||||||
onSelect: ({ url, urls }) => {
|
onSelect: ({ url, urls }) => {
|
||||||
uploadedImageUrls = urls || [url];
|
uploadedImageUrls = urls || [url];
|
||||||
if (!imageMode) {
|
if (!imageMode) {
|
||||||
|
|
@ -127,10 +170,11 @@ export function ImageStudio() {
|
||||||
const controlsLeft = document.createElement('div');
|
const controlsLeft = document.createElement('div');
|
||||||
controlsLeft.className = 'flex items-center gap-1.5 md:gap-2.5 relative overflow-x-auto no-scrollbar pb-1 md:pb-0';
|
controlsLeft.className = 'flex items-center gap-1.5 md:gap-2.5 relative overflow-x-auto no-scrollbar pb-1 md:pb-0';
|
||||||
|
|
||||||
const createControlBtn = (icon, label, id) => {
|
const createControlBtn = (icon, label, id, tooltip) => {
|
||||||
const btn = document.createElement('button');
|
const btn = document.createElement('button');
|
||||||
btn.id = id;
|
btn.id = id;
|
||||||
btn.className = 'flex items-center gap-1.5 md:gap-2.5 px-3 md:px-4 py-2 md:py-2.5 bg-white/5 hover:bg-white/10 rounded-xl md:rounded-2xl transition-all border border-white/5 group whitespace-nowrap';
|
btn.className = 'flex items-center gap-1.5 md:gap-2.5 px-3 md:px-4 py-2 md:py-2.5 bg-white/5 hover:bg-white/10 rounded-xl md:rounded-2xl transition-all border border-white/5 group whitespace-nowrap';
|
||||||
|
if (tooltip) btn.setAttribute('data-tooltip', tooltip);
|
||||||
btn.innerHTML = `
|
btn.innerHTML = `
|
||||||
${icon}
|
${icon}
|
||||||
<span id="${id}-label" class="text-xs font-bold text-white group-hover:text-primary transition-colors">${label}</span>
|
<span id="${id}-label" class="text-xs font-bold text-white group-hover:text-primary transition-colors">${label}</span>
|
||||||
|
|
@ -143,26 +187,73 @@ export function ImageStudio() {
|
||||||
<div class="w-5 h-5 bg-primary rounded-md flex items-center justify-center shadow-lg shadow-primary/20">
|
<div class="w-5 h-5 bg-primary rounded-md flex items-center justify-center shadow-lg shadow-primary/20">
|
||||||
<span class="text-[10px] font-black text-black">G</span>
|
<span class="text-[10px] font-black text-black">G</span>
|
||||||
</div>
|
</div>
|
||||||
`, selectedModelName, 'model-btn');
|
`, selectedModelName, 'model-btn', 'Select AI generation model');
|
||||||
|
|
||||||
const arBtn = createControlBtn(`
|
const arBtn = createControlBtn(`
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="opacity-60 text-secondary"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="opacity-60 text-secondary"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/></svg>
|
||||||
`, selectedAr, 'ar-btn');
|
`, selectedAr, 'ar-btn', 'Change aspect ratio');
|
||||||
|
|
||||||
const qualityBtn = createControlBtn(`
|
const qualityBtn = createControlBtn(`
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="opacity-60 text-secondary"><path d="M6 2L3 6v15a2 2 0 002 2h14a2 2 0 002-2V6l-3-4H6z"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="opacity-60 text-secondary"><path d="M6 2L3 6v15a2 2 0 002 2h14a2 2 0 002-2V6l-3-4H6z"/></svg>
|
||||||
`, '720p', 'quality-btn');
|
`, '720p', 'quality-btn', 'Set output quality');
|
||||||
|
|
||||||
|
// Local / API source toggle (only shown in Electron)
|
||||||
|
let localToggleBtn = null;
|
||||||
|
if (isLocalAIAvailable()) {
|
||||||
|
localToggleBtn = document.createElement('button');
|
||||||
|
localToggleBtn.id = 'local-toggle-btn';
|
||||||
|
localToggleBtn.className = 'flex items-center gap-1.5 px-3 py-2 rounded-xl transition-all border text-xs font-bold whitespace-nowrap';
|
||||||
|
const updateLocalToggleStyle = () => {
|
||||||
|
if (useLocalModel) {
|
||||||
|
localToggleBtn.className = 'flex items-center gap-1.5 px-3 py-2 rounded-xl transition-all border text-xs font-bold whitespace-nowrap bg-primary/20 border-primary/40 text-primary';
|
||||||
|
localToggleBtn.textContent = '⚡ Local';
|
||||||
|
} else {
|
||||||
|
localToggleBtn.className = 'flex items-center gap-1.5 px-3 py-2 rounded-xl transition-all border text-xs font-bold whitespace-nowrap bg-white/5 border-white/5 text-white/60 hover:bg-white/10';
|
||||||
|
localToggleBtn.textContent = '☁ API';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
updateLocalToggleStyle();
|
||||||
|
localToggleBtn.onclick = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
useLocalModel = !useLocalModel;
|
||||||
|
updateLocalToggleStyle();
|
||||||
|
// Reflect active model in the button label
|
||||||
|
if (useLocalModel) {
|
||||||
|
const lm = getLocalModelById(selectedLocalModel);
|
||||||
|
if (lm) document.getElementById('model-btn-label').textContent = lm.name;
|
||||||
|
} else {
|
||||||
|
document.getElementById('model-btn-label').textContent = selectedModelName;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
controlsLeft.appendChild(localToggleBtn);
|
||||||
|
}
|
||||||
|
|
||||||
controlsLeft.appendChild(modelBtn);
|
controlsLeft.appendChild(modelBtn);
|
||||||
controlsLeft.appendChild(arBtn);
|
controlsLeft.appendChild(arBtn);
|
||||||
controlsLeft.appendChild(qualityBtn);
|
controlsLeft.appendChild(qualityBtn);
|
||||||
|
|
||||||
|
// Advanced options toggle button
|
||||||
|
const advancedBtn = createControlBtn(`
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="opacity-60 text-secondary"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06a1.65 1.65 0 001.82-.33 1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06a1.65 1.65 0 00-1.82.33A1.65 1.65 0 0019.4 9a1.65 1.65 0 00-1.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
|
||||||
|
`, 'Advanced', 'advanced-btn', 'Show advanced options');
|
||||||
|
controlsLeft.appendChild(advancedBtn);
|
||||||
|
|
||||||
|
// Quick Tools toggle button
|
||||||
|
const toolsBtn = createControlBtn(`
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="opacity-60 text-secondary"><path d="M14.7 6.3a1 1 0 000 1.4l1.6 1.6a1 1 0 001.4 0l3.77-3.77a6 6 0 01-7.94 7.94l-6.91 6.91a2.12 2.12 0 01-3-3l6.91-6.91a6 6 0 017.94-7.94l-3.76 3.76z"/></svg>
|
||||||
|
`, 'Tools', 'tools-btn', 'Quick starters & prompt enhancer');
|
||||||
|
controlsLeft.appendChild(toolsBtn);
|
||||||
// Show quality button if the default model has quality/resolution options
|
// Show quality button if the default model has quality/resolution options
|
||||||
const _initResolutions = getResolutionsForModel(defaultModel.id);
|
const _initResolutions = getResolutionsForModel(defaultModel.id);
|
||||||
qualityBtn.style.display = _initResolutions.length > 0 ? 'flex' : 'none';
|
qualityBtn.style.display = _initResolutions.length > 0 ? 'flex' : 'none';
|
||||||
if (_initResolutions.length > 0) document.getElementById('quality-btn-label').textContent = _initResolutions[0];
|
if (_initResolutions.length > 0) {
|
||||||
|
const qlabel = qualityBtn.querySelector('#quality-btn-label');
|
||||||
|
if (qlabel) qlabel.textContent = _initResolutions[0];
|
||||||
|
}
|
||||||
|
|
||||||
const generateBtn = document.createElement('button');
|
const generateBtn = document.createElement('button');
|
||||||
generateBtn.className = 'bg-primary text-black px-6 md:px-8 py-3 md:py-3.5 rounded-xl md:rounded-[1.5rem] font-black text-sm md:text-base hover:shadow-glow hover:scale-105 active:scale-95 transition-all flex items-center justify-center gap-2.5 w-full sm:w-auto shadow-lg';
|
generateBtn.className = 'bg-primary text-black px-6 md:px-8 py-3 md:py-3.5 rounded-xl md:rounded-[1.5rem] font-black text-sm md:text-base hover:shadow-glow hover:scale-105 active:scale-95 transition-all flex items-center justify-center gap-2.5 w-full sm:w-auto shadow-lg';
|
||||||
|
generateBtn.setAttribute('data-tooltip', 'Generate AI image from prompt');
|
||||||
generateBtn.innerHTML = `Generate ✨`;
|
generateBtn.innerHTML = `Generate ✨`;
|
||||||
|
|
||||||
bottomRow.appendChild(controlsLeft);
|
bottomRow.appendChild(controlsLeft);
|
||||||
|
|
@ -171,6 +262,445 @@ export function ImageStudio() {
|
||||||
promptWrapper.appendChild(bar);
|
promptWrapper.appendChild(bar);
|
||||||
container.appendChild(promptWrapper);
|
container.appendChild(promptWrapper);
|
||||||
|
|
||||||
|
const inlineInstructions = createInlineInstructions('image');
|
||||||
|
inlineInstructions.classList.add('max-w-4xl', 'mt-8');
|
||||||
|
container.appendChild(inlineInstructions);
|
||||||
|
|
||||||
|
// Local generation progress bar (hidden until active)
|
||||||
|
const localProgressWrap = document.createElement('div');
|
||||||
|
localProgressWrap.className = 'w-full max-w-4xl mt-4 hidden flex-col gap-2';
|
||||||
|
localProgressWrap.id = 'local-progress-wrap';
|
||||||
|
localProgressWrap.innerHTML = `
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs font-bold text-white/60">Generating locally...</span>
|
||||||
|
<span id="local-progress-pct" class="text-xs font-bold text-primary">0%</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-1.5 rounded-full bg-white/10 overflow-hidden">
|
||||||
|
<div id="local-progress-fill" class="h-full bg-primary transition-all duration-200" style="width:0%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button id="local-cancel-btn" class="text-xs text-red-400 hover:text-red-300 transition-colors">Cancel</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
container.appendChild(localProgressWrap);
|
||||||
|
|
||||||
|
localProgressWrap.querySelector('#local-cancel-btn')?.addEventListener('click', () => {
|
||||||
|
localAI.cancelGeneration();
|
||||||
|
localProgressWrap.classList.remove('flex');
|
||||||
|
localProgressWrap.classList.add('hidden');
|
||||||
|
generateBtn.disabled = false;
|
||||||
|
generateBtn.innerHTML = `Generate ✨`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 3. QUICK TOOLS PANEL (Prompt Enhancer + Quick Starters)
|
||||||
|
// ==========================================
|
||||||
|
const toolsPanel = document.createElement('div');
|
||||||
|
toolsPanel.className = 'w-full max-w-4xl mt-6 animate-fade-in-up hidden';
|
||||||
|
toolsPanel.id = 'tools-panel';
|
||||||
|
|
||||||
|
// Build tools panel HTML
|
||||||
|
toolsPanel.innerHTML = `
|
||||||
|
<div class="bg-[#111]/90 backdrop-blur-xl border border-white/10 rounded-2xl p-5 flex flex-col gap-4">
|
||||||
|
<div class="flex items-center justify-between pb-3 border-b border-white/5">
|
||||||
|
<h3 class="text-sm font-bold text-white">Quick Tools</h3>
|
||||||
|
<button id="close-tools-btn" class="text-white/40 hover:text-white transition-colors">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col lg:flex-row gap-6">
|
||||||
|
<!-- Quick Starters Section -->
|
||||||
|
<div class="flex-1">
|
||||||
|
<h4 class="text-xs font-bold text-secondary uppercase tracking-wider mb-3">Quick Starters</h4>
|
||||||
|
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||||
|
${QUICK_PROMPTS.map(q => `
|
||||||
|
<button class="quick-starter-btn px-3 py-2 rounded-lg text-xs font-bold bg-white/5 text-secondary hover:bg-white/10 hover:text-primary transition-all text-left border border-white/5 hover:border-primary/30" data-prompt="${q.prompt}">
|
||||||
|
${q.label}
|
||||||
|
</button>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Prompt Enhancer Section -->
|
||||||
|
<div class="flex-1">
|
||||||
|
<h4 class="text-xs font-bold text-secondary uppercase tracking-wider mb-3">Prompt Enhancer</h4>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<input type="text" id="base-prompt-input"
|
||||||
|
placeholder="Enter base prompt..."
|
||||||
|
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-2.5 text-white text-sm placeholder:text-muted focus:outline-none focus:border-primary/50 transition-colors">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="text-[10px] font-bold text-muted uppercase tracking-wider mb-2 block">Enhancement Tags</label>
|
||||||
|
<div id="enhance-tags-area" class="flex flex-wrap gap-1.5">
|
||||||
|
${Object.entries(ENHANCE_TAGS).map(([category, tags]) =>
|
||||||
|
tags.map(tag => `<button class="enhance-tag-btn px-2 py-1 rounded-full text-[10px] font-bold bg-white/5 text-secondary hover:bg-white/10 transition-all" data-tag="${tag}">${tag}</button>`).join('')
|
||||||
|
).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-[10px] font-bold text-muted uppercase tracking-wider">Enhanced Prompt</label>
|
||||||
|
<div id="enhanced-prompt-display" class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-2.5 text-white text-xs min-h-[40px]"></div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button id="copy-enhanced-btn" class="px-3 py-1.5 rounded-lg text-xs font-bold bg-white/5 text-secondary hover:bg-white/10 transition-all">
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
<button id="use-enhanced-btn" class="px-3 py-1.5 rounded-lg text-xs font-bold bg-primary text-black hover:shadow-glow transition-all">
|
||||||
|
Use in Generator
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.appendChild(toolsPanel);
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 4. ADVANCED OPTIONS PANEL
|
||||||
|
// ==========================================
|
||||||
|
const STYLE_PRESETS = ['None', 'Photorealistic', 'Anime', 'Cinematic', 'Oil Painting', 'Watercolor', 'Digital Art', 'Concept Art', 'Cyberpunk'];
|
||||||
|
|
||||||
|
const advancedPanel = document.createElement('div');
|
||||||
|
advancedPanel.className = 'w-full max-w-4xl mt-6 animate-fade-in-up hidden';
|
||||||
|
advancedPanel.id = 'advanced-panel';
|
||||||
|
advancedPanel.innerHTML = `
|
||||||
|
<div class="bg-[#111]/90 backdrop-blur-xl border border-white/10 rounded-2xl p-5 flex flex-col gap-4">
|
||||||
|
<div class="flex items-center justify-between pb-3 border-b border-white/5">
|
||||||
|
<h3 class="text-sm font-bold text-white">Advanced Options</h3>
|
||||||
|
<button id="close-adv-btn" class="text-white/40 hover:text-white transition-colors">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Style Presets -->
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-xs font-bold text-secondary uppercase tracking-wider">Style Preset</label>
|
||||||
|
<div class="flex gap-2 flex-wrap">
|
||||||
|
${STYLE_PRESETS.map(s => `<button class="style-preset-btn px-3 py-1.5 rounded-lg text-xs font-bold bg-white/5 text-secondary hover:bg-white/10 transition-all" data-style="${s}">${s}</button>`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Negative Prompt -->
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-xs font-bold text-secondary uppercase tracking-wider">Negative Prompt</label>
|
||||||
|
<input type="text" id="negative-prompt-input"
|
||||||
|
placeholder="What to exclude from the image (e.g., blurry, distorted, watermark)"
|
||||||
|
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-2.5 text-white text-sm placeholder:text-muted focus:outline-none focus:border-primary/50 transition-colors">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Guidance Scale & Steps Row -->
|
||||||
|
<div class="flex gap-4 flex-wrap">
|
||||||
|
<div class="flex-1 min-w-[200px] flex flex-col gap-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<label class="text-xs font-bold text-secondary uppercase tracking-wider">Guidance Scale</label>
|
||||||
|
<span id="guidance-value" class="text-xs font-bold text-primary">7.5</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" id="guidance-slider" min="1" max="20" step="0.5" value="7.5"
|
||||||
|
class="w-full h-2 bg-white/10 rounded-lg appearance-none cursor-pointer accent-primary">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 min-w-[200px] flex flex-col gap-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<label class="text-xs font-bold text-secondary uppercase tracking-wider">Steps</label>
|
||||||
|
<span id="steps-value" class="text-xs font-bold text-primary">25</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" id="steps-slider" min="1" max="50" step="1" value="25"
|
||||||
|
class="w-full h-2 bg-white/10 rounded-lg appearance-none cursor-pointer accent-primary">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Seed -->
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<label class="text-xs font-bold text-secondary uppercase tracking-wider">Seed</label>
|
||||||
|
<button id="randomize-seed-btn" class="text-xs font-bold text-primary hover:text-primary/80 transition-colors">Randomize</button>
|
||||||
|
</div>
|
||||||
|
<input type="number" id="seed-input"
|
||||||
|
placeholder="-1 for random"
|
||||||
|
value="-1"
|
||||||
|
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-2.5 text-white text-sm placeholder:text-muted focus:outline-none focus:border-primary/50 transition-colors">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Batch Count -->
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<label class="text-xs font-bold text-secondary uppercase tracking-wider">Batch Count</label>
|
||||||
|
<span id="batch-value" class="text-xs font-bold text-primary">1</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" id="batch-slider" min="1" max="4" step="1" value="1"
|
||||||
|
class="w-full h-2 bg-white/10 rounded-lg appearance-none cursor-pointer accent-primary">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Width & Height -->
|
||||||
|
<div class="flex gap-4 flex-wrap">
|
||||||
|
<div class="flex-1 min-w-[120px] flex flex-col gap-2">
|
||||||
|
<label class="text-xs font-bold text-secondary uppercase tracking-wider">Width</label>
|
||||||
|
<input type="number" id="width-input"
|
||||||
|
placeholder="Auto"
|
||||||
|
value=""
|
||||||
|
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-2.5 text-white text-sm placeholder:text-muted focus:outline-none focus:border-primary/50 transition-colors">
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-[120px] flex flex-col gap-2">
|
||||||
|
<label class="text-xs font-bold text-secondary uppercase tracking-wider">Height</label>
|
||||||
|
<input type="number" id="height-input"
|
||||||
|
placeholder="Auto"
|
||||||
|
value=""
|
||||||
|
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-2.5 text-white text-sm placeholder:text-muted focus:outline-none focus:border-primary/50 transition-colors">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reference Strength (for I2I models) -->
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<label class="text-xs font-bold text-secondary uppercase tracking-wider">Reference Strength</label>
|
||||||
|
<span id="reference-strength-value" class="text-xs font-bold text-primary">50%</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" id="reference-strength-slider" min="0" max="100" step="5" value="50"
|
||||||
|
class="w-full h-2 bg-white/10 rounded-lg appearance-none cursor-pointer accent-primary">
|
||||||
|
<p class="text-xs text-muted">How much to preserve the reference image characteristics</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- LoRA Model Selection -->
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-xs font-bold text-secondary uppercase tracking-wider">LoRA Model (Optional)</label>
|
||||||
|
<input type="text" id="lora-input"
|
||||||
|
placeholder="e.g., civitai:1642876@1864626"
|
||||||
|
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-2.5 text-white text-sm placeholder:text-muted focus:outline-none focus:border-primary/50 transition-colors">
|
||||||
|
<div class="flex items-center gap-2 mt-1">
|
||||||
|
<label class="text-xs font-bold text-secondary">LoRA Weight:</label>
|
||||||
|
<input type="number" id="lora-weight-input"
|
||||||
|
value="1.0" min="0" max="4" step="0.1"
|
||||||
|
class="w-20 bg-white/5 border border-white/10 rounded-xl px-3 py-1.5 text-white text-sm focus:outline-none focus:border-primary/50 transition-colors">
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-muted">Enter a LoRA model ID from Civitai (format: civitai:id@version)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
container.appendChild(advancedPanel);
|
||||||
|
|
||||||
|
// Advanced panel toggle logic
|
||||||
|
const toggleAdvanced = () => {
|
||||||
|
showAdvanced = !showAdvanced;
|
||||||
|
advancedPanel.classList.toggle('hidden', !showAdvanced);
|
||||||
|
document.getElementById('advanced-btn-label').textContent = showAdvanced ? 'Less' : 'Advanced';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add tools panel and advanced panel to container first before accessing their elements
|
||||||
|
container.appendChild(toolsPanel);
|
||||||
|
container.appendChild(advancedPanel);
|
||||||
|
|
||||||
|
// Now set up event handlers after elements are in DOM
|
||||||
|
advancedBtn.onclick = toggleAdvanced;
|
||||||
|
const closeAdvBtn = advancedPanel.querySelector('#close-adv-btn');
|
||||||
|
if (closeAdvBtn) closeAdvBtn.onclick = toggleAdvanced;
|
||||||
|
|
||||||
|
// Quick Tools Panel toggle
|
||||||
|
const toggleTools = () => {
|
||||||
|
showToolsPanel = !showToolsPanel;
|
||||||
|
toolsPanel.classList.toggle('hidden', !showToolsPanel);
|
||||||
|
if (showToolsPanel) {
|
||||||
|
// Close advanced panel when opening tools
|
||||||
|
if (!showAdvanced) {
|
||||||
|
showAdvanced = true;
|
||||||
|
advancedPanel.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.getElementById('tools-btn-label').textContent = showToolsPanel ? 'Tools' : 'Tools';
|
||||||
|
};
|
||||||
|
|
||||||
|
toolsBtn.onclick = toggleTools;
|
||||||
|
const closeToolsBtn = toolsPanel.querySelector('#close-tools-btn');
|
||||||
|
if (closeToolsBtn) closeToolsBtn.onclick = toggleTools;
|
||||||
|
|
||||||
|
// Quick Starter buttons
|
||||||
|
const quickStarterBtns = toolsPanel.querySelectorAll('.quick-starter-btn');
|
||||||
|
quickStarterBtns.forEach(btn => {
|
||||||
|
btn.onclick = () => {
|
||||||
|
const prompt = btn.dataset.prompt;
|
||||||
|
textarea.value = prompt;
|
||||||
|
textarea.style.height = 'auto';
|
||||||
|
const maxHeight = window.innerWidth < 768 ? 150 : 250;
|
||||||
|
textarea.style.height = Math.min(textarea.scrollHeight, maxHeight) + 'px';
|
||||||
|
// Close tools panel after selection
|
||||||
|
showToolsPanel = false;
|
||||||
|
toolsPanel.classList.add('hidden');
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prompt Enhancer - selected tags state
|
||||||
|
const enhanceSelectedTags = new Set();
|
||||||
|
const basePromptInput = toolsPanel.querySelector('#base-prompt-input');
|
||||||
|
const enhancedPromptDisplay = toolsPanel.querySelector('#enhanced-prompt-display');
|
||||||
|
|
||||||
|
// Update enhanced prompt display
|
||||||
|
const updateEnhancedPrompt = () => {
|
||||||
|
const base = basePromptInput?.value?.trim() || '';
|
||||||
|
const tags = Array.from(enhanceSelectedTags).join(', ');
|
||||||
|
const enhanced = [base, tags].filter(p => p).join(', ');
|
||||||
|
if (enhancedPromptDisplay) {
|
||||||
|
enhancedPromptDisplay.textContent = enhanced || 'Your enhanced prompt will appear here...';
|
||||||
|
enhancedPromptDisplay.classList.toggle('text-muted', !enhanced);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Base prompt input handler
|
||||||
|
if (basePromptInput) {
|
||||||
|
basePromptInput.oninput = updateEnhancedPrompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhance tag buttons
|
||||||
|
const enhanceTagBtns = toolsPanel.querySelectorAll('.enhance-tag-btn');
|
||||||
|
enhanceTagBtns.forEach(btn => {
|
||||||
|
btn.onclick = () => {
|
||||||
|
const tag = btn.dataset.tag;
|
||||||
|
if (enhanceSelectedTags.has(tag)) {
|
||||||
|
enhanceSelectedTags.delete(tag);
|
||||||
|
btn.classList.remove('bg-primary', 'text-black');
|
||||||
|
btn.classList.add('bg-white/5', 'text-secondary');
|
||||||
|
} else {
|
||||||
|
enhanceSelectedTags.add(tag);
|
||||||
|
btn.classList.remove('bg-white/5', 'text-secondary');
|
||||||
|
btn.classList.add('bg-primary', 'text-black');
|
||||||
|
}
|
||||||
|
updateEnhancedPrompt();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Copy enhanced button
|
||||||
|
const copyEnhancedBtn = toolsPanel.querySelector('#copy-enhanced-btn');
|
||||||
|
if (copyEnhancedBtn) {
|
||||||
|
copyEnhancedBtn.onclick = () => {
|
||||||
|
const text = enhancedPromptDisplay?.textContent || '';
|
||||||
|
if (text && text !== 'Your enhanced prompt will appear here...') {
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
copyEnhancedBtn.textContent = 'Copied!';
|
||||||
|
setTimeout(() => { copyEnhancedBtn.textContent = 'Copy'; }, 1500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use enhanced button
|
||||||
|
const useEnhancedBtn = toolsPanel.querySelector('#use-enhanced-btn');
|
||||||
|
if (useEnhancedBtn) {
|
||||||
|
useEnhancedBtn.onclick = () => {
|
||||||
|
const text = enhancedPromptDisplay?.textContent || '';
|
||||||
|
if (text && text !== 'Your enhanced prompt will appear here...') {
|
||||||
|
textarea.value = text;
|
||||||
|
textarea.style.height = 'auto';
|
||||||
|
const maxHeight = window.innerWidth < 768 ? 150 : 250;
|
||||||
|
textarea.style.height = Math.min(textarea.scrollHeight, maxHeight) + 'px';
|
||||||
|
// Close tools panel after use
|
||||||
|
showToolsPanel = false;
|
||||||
|
toolsPanel.classList.add('hidden');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Negative prompt
|
||||||
|
const negPromptInput = advancedPanel.querySelector('#negative-prompt-input');
|
||||||
|
if (negPromptInput) negPromptInput.oninput = (e) => { negativePrompt = e.target.value; };
|
||||||
|
|
||||||
|
// Guidance scale slider
|
||||||
|
const guidanceSlider = advancedPanel.querySelector('#guidance-slider');
|
||||||
|
const guidanceValue = advancedPanel.querySelector('#guidance-value');
|
||||||
|
if (guidanceSlider && guidanceValue) {
|
||||||
|
guidanceSlider.oninput = (e) => {
|
||||||
|
guidanceScale = parseFloat(e.target.value);
|
||||||
|
guidanceValue.textContent = guidanceScale;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Steps slider
|
||||||
|
const stepsSlider = advancedPanel.querySelector('#steps-slider');
|
||||||
|
const stepsValue = advancedPanel.querySelector('#steps-value');
|
||||||
|
if (stepsSlider && stepsValue) {
|
||||||
|
stepsSlider.oninput = (e) => {
|
||||||
|
steps = parseInt(e.target.value);
|
||||||
|
stepsValue.textContent = steps;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed input
|
||||||
|
const seedInput = advancedPanel.querySelector('#seed-input');
|
||||||
|
if (seedInput) seedInput.oninput = (e) => { seed = parseInt(e.target.value) || -1; };
|
||||||
|
|
||||||
|
// Randomize seed button
|
||||||
|
const randSeedBtn = advancedPanel.querySelector('#randomize-seed-btn');
|
||||||
|
if (randSeedBtn) {
|
||||||
|
randSeedBtn.onclick = () => {
|
||||||
|
seed = Math.floor(Math.random() * 999999999);
|
||||||
|
if (seedInput) seedInput.value = seed;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch count slider
|
||||||
|
const batchSlider = advancedPanel.querySelector('#batch-slider');
|
||||||
|
const batchValueEl = advancedPanel.querySelector('#batch-value');
|
||||||
|
if (batchSlider && batchValueEl) {
|
||||||
|
batchSlider.oninput = (e) => {
|
||||||
|
batchCount = parseInt(e.target.value);
|
||||||
|
batchValueEl.textContent = batchCount;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Width input
|
||||||
|
const widthInput = advancedPanel.querySelector('#width-input');
|
||||||
|
if (widthInput) {
|
||||||
|
widthInput.oninput = (e) => {
|
||||||
|
customWidth = parseInt(e.target.value) || 0;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Height input
|
||||||
|
const heightInput = advancedPanel.querySelector('#height-input');
|
||||||
|
if (heightInput) {
|
||||||
|
heightInput.oninput = (e) => {
|
||||||
|
customHeight = parseInt(e.target.value) || 0;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reference strength slider
|
||||||
|
const refStrengthSlider = advancedPanel.querySelector('#reference-strength-slider');
|
||||||
|
const refStrengthValue = advancedPanel.querySelector('#reference-strength-value');
|
||||||
|
if (refStrengthSlider && refStrengthValue) {
|
||||||
|
refStrengthSlider.oninput = (e) => {
|
||||||
|
referenceStrength = parseInt(e.target.value);
|
||||||
|
refStrengthValue.textContent = referenceStrength + '%';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoRA input
|
||||||
|
const loraInput = advancedPanel.querySelector('#lora-input');
|
||||||
|
if (loraInput) {
|
||||||
|
loraInput.oninput = (e) => {
|
||||||
|
selectedLora = e.target.value.trim();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoRA weight input
|
||||||
|
const loraWeightInput = advancedPanel.querySelector('#lora-weight-input');
|
||||||
|
if (loraWeightInput) {
|
||||||
|
loraWeightInput.oninput = (e) => {
|
||||||
|
loraWeight = parseFloat(e.target.value) || 1.0;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Style preset handlers
|
||||||
|
advancedPanel.querySelectorAll('.style-preset-btn').forEach(btn => {
|
||||||
|
btn.onclick = () => {
|
||||||
|
selectedStyle = btn.dataset.style;
|
||||||
|
advancedPanel.querySelectorAll('.style-preset-btn').forEach(b => {
|
||||||
|
b.classList.remove('bg-primary/20', 'text-primary', 'border-primary/30');
|
||||||
|
b.classList.add('bg-white/5', 'text-secondary');
|
||||||
|
});
|
||||||
|
btn.classList.add('bg-primary/20', 'text-primary', 'border-primary/30');
|
||||||
|
btn.classList.remove('bg-white/5', 'text-secondary');
|
||||||
|
};
|
||||||
|
});
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// 3. DROPDOWNS (Professional implementation)
|
// 3. DROPDOWNS (Professional implementation)
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
@ -201,6 +731,48 @@ export function ImageStudio() {
|
||||||
|
|
||||||
const renderModels = (filter = '') => {
|
const renderModels = (filter = '') => {
|
||||||
list.innerHTML = '';
|
list.innerHTML = '';
|
||||||
|
|
||||||
|
if (useLocalModel) {
|
||||||
|
// ── Local model list (Wan2GP image-capable models only) ───
|
||||||
|
const filtered = LOCAL_IMAGE_MODELS.filter(m =>
|
||||||
|
m.name.toLowerCase().includes(filter.toLowerCase()) ||
|
||||||
|
m.id.toLowerCase().includes(filter.toLowerCase())
|
||||||
|
);
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
list.innerHTML = `<div class="text-xs text-muted text-center py-4">No local models match</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
filtered.forEach(m => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = `flex items-center justify-between p-3.5 hover:bg-white/5 rounded-2xl cursor-pointer transition-all border border-transparent hover:border-white/5 ${selectedLocalModel === m.id ? 'bg-white/5 border-white/5' : ''}`;
|
||||||
|
item.innerHTML = `
|
||||||
|
<div class="flex items-center gap-3.5">
|
||||||
|
<div class="w-10 h-10 ${m.featured ? 'bg-primary/10 text-primary' : 'bg-green-500/10 text-green-400'} border border-white/5 rounded-xl flex items-center justify-center font-black text-sm shadow-inner uppercase">${m.featured ? '⚡' : m.name.charAt(0)}</div>
|
||||||
|
<div class="flex flex-col gap-0.5">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span class="text-xs font-bold text-white tracking-tight">${m.name}</span>
|
||||||
|
${m.featured ? '<span class="text-[9px] font-black px-1 py-0.5 rounded bg-primary/20 text-primary">FEATURED</span>' : ''}
|
||||||
|
</div>
|
||||||
|
<span class="text-[10px] text-muted">${m.type.toUpperCase()} · ${m.family}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${selectedLocalModel === m.id ? '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#d9ff00" stroke-width="4"><polyline points="20 6 9 17 4 12"/></svg>' : ''}
|
||||||
|
`;
|
||||||
|
item.onclick = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
selectedLocalModel = m.id;
|
||||||
|
document.getElementById('model-btn-label').textContent = m.name;
|
||||||
|
selectedAr = m.aspectRatios[0];
|
||||||
|
document.getElementById('ar-btn-label').textContent = selectedAr;
|
||||||
|
qualityBtn.style.display = 'none';
|
||||||
|
closeDropdown();
|
||||||
|
};
|
||||||
|
list.appendChild(item);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Remote (API) model list ───────────────────────────────────
|
||||||
const filtered = getCurrentModels().filter(m => m.name.toLowerCase().includes(filter.toLowerCase()) || m.id.toLowerCase().includes(filter.toLowerCase()));
|
const filtered = getCurrentModels().filter(m => m.name.toLowerCase().includes(filter.toLowerCase()) || m.id.toLowerCase().includes(filter.toLowerCase()));
|
||||||
|
|
||||||
filtered.forEach(m => {
|
filtered.forEach(m => {
|
||||||
|
|
@ -595,6 +1167,74 @@ export function ImageStudio() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Local inference path ──────────────────────────────────────────────
|
||||||
|
if (useLocalModel) {
|
||||||
|
const lm = getLocalModelById(selectedLocalModel);
|
||||||
|
if (!lm) { alert('No local model selected.'); return; }
|
||||||
|
|
||||||
|
hero.classList.add('opacity-0', 'scale-95', '-translate-y-10', 'pointer-events-none');
|
||||||
|
generateBtn.disabled = true;
|
||||||
|
generateBtn.innerHTML = `<span class="animate-spin inline-block mr-2 text-black">◌</span> Generating...`;
|
||||||
|
|
||||||
|
const progressWrap = document.getElementById('local-progress-wrap');
|
||||||
|
const progressFill = document.getElementById('local-progress-fill');
|
||||||
|
const progressPct = document.getElementById('local-progress-pct');
|
||||||
|
progressWrap.classList.remove('hidden');
|
||||||
|
progressWrap.classList.add('flex');
|
||||||
|
|
||||||
|
const unsub = localAI.onProgress(({ progress, status }) => {
|
||||||
|
const pct = Math.round((progress ?? 0) * 100);
|
||||||
|
if (progressFill) progressFill.style.width = `${pct}%`;
|
||||||
|
if (progressPct) progressPct.textContent = status === 'starting' ? 'Starting...' : `${pct}%`;
|
||||||
|
generateBtn.innerHTML = `<span class="animate-spin inline-block mr-2 text-black">◌</span> ${status === 'starting' ? '...' : pct + '%'}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
let hadError = false;
|
||||||
|
try {
|
||||||
|
const res = await localAI.generate({
|
||||||
|
model: selectedLocalModel,
|
||||||
|
prompt,
|
||||||
|
negative_prompt: negativePrompt || undefined,
|
||||||
|
aspect_ratio: selectedAr,
|
||||||
|
steps: steps,
|
||||||
|
guidance_scale: guidanceScale,
|
||||||
|
seed,
|
||||||
|
});
|
||||||
|
unsub();
|
||||||
|
progressWrap.classList.replace('flex', 'hidden');
|
||||||
|
progressWrap.classList.add('hidden');
|
||||||
|
|
||||||
|
if (!res?.url) throw new Error('No output returned from local generation');
|
||||||
|
if (res.mediaType === 'video') {
|
||||||
|
throw new Error('This model produces video — use the Video studio instead.');
|
||||||
|
}
|
||||||
|
addToHistory({
|
||||||
|
id: Date.now().toString(),
|
||||||
|
url: res.url,
|
||||||
|
prompt,
|
||||||
|
model: `local:${selectedLocalModel}`,
|
||||||
|
aspect_ratio: selectedAr,
|
||||||
|
seed: res.seed,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
showImageInCanvas(res.url);
|
||||||
|
} catch (e) {
|
||||||
|
hadError = true;
|
||||||
|
unsub();
|
||||||
|
progressWrap.classList.add('hidden');
|
||||||
|
console.error('[Local] generation error:', e);
|
||||||
|
hero.classList.remove('opacity-0', 'scale-95', '-translate-y-10', 'pointer-events-none');
|
||||||
|
console.error('[Local] full error:', e.message);
|
||||||
|
generateBtn.innerHTML = `Error: ${e.message.slice(0, 120)}`;
|
||||||
|
setTimeout(() => { generateBtn.innerHTML = `Generate ✨`; }, 6000);
|
||||||
|
} finally {
|
||||||
|
generateBtn.disabled = false;
|
||||||
|
if (!hadError) generateBtn.innerHTML = `Generate ✨`;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Remote API path ───────────────────────────────────────────────────
|
||||||
const apiKey = localStorage.getItem('muapi_key');
|
const apiKey = localStorage.getItem('muapi_key');
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
AuthModal(() => generateBtn.click());
|
AuthModal(() => generateBtn.click());
|
||||||
|
|
|
||||||
397
src/components/LocalModelManager.js
Normal file
397
src/components/LocalModelManager.js
Normal file
|
|
@ -0,0 +1,397 @@
|
||||||
|
import { localAI, isLocalAIAvailable } from '../lib/localInferenceClient.js';
|
||||||
|
|
||||||
|
// ─── Icons ────────────────────────────────────────────────────────────────────
|
||||||
|
const DownloadIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg>`;
|
||||||
|
const TrashIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6M14 11v6"/></svg>`;
|
||||||
|
const CheckIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>`;
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
function fmtGB(gb) {
|
||||||
|
return gb >= 1 ? `${gb.toFixed(1)} GB` : `${(gb * 1024).toFixed(0)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tagEl(text) {
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.className = 'px-1.5 py-0.5 rounded-md text-[10px] font-bold bg-white/5 text-muted';
|
||||||
|
span.textContent = text;
|
||||||
|
return span;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Binary Status Bar ────────────────────────────────────────────────────────
|
||||||
|
function BinaryStatusBar(onStatusChange) {
|
||||||
|
const bar = document.createElement('div');
|
||||||
|
bar.className = 'flex items-center justify-between gap-3 p-3 rounded-xl bg-white/3 border border-white/5';
|
||||||
|
|
||||||
|
const label = document.createElement('div');
|
||||||
|
label.className = 'flex flex-col gap-0.5';
|
||||||
|
label.innerHTML = `
|
||||||
|
<span class="text-xs font-bold text-white">sd.cpp inference engine</span>
|
||||||
|
<span id="binary-status-text" class="text-[11px] text-muted">Checking...</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.id = 'binary-action-btn';
|
||||||
|
btn.className = 'px-3 py-1.5 rounded-lg text-xs font-bold transition-all hidden';
|
||||||
|
btn.textContent = 'Install';
|
||||||
|
|
||||||
|
bar.appendChild(label);
|
||||||
|
bar.appendChild(btn);
|
||||||
|
|
||||||
|
const progressBar = document.createElement('div');
|
||||||
|
progressBar.className = 'h-1 rounded-full bg-white/5 mt-2 hidden overflow-hidden';
|
||||||
|
progressBar.id = 'binary-progress-bar';
|
||||||
|
progressBar.innerHTML = `<div id="binary-progress-fill" class="h-full bg-primary transition-all" style="width:0%"></div>`;
|
||||||
|
bar.appendChild(progressBar);
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
const status = await localAI.getBinaryStatus();
|
||||||
|
const text = bar.querySelector('#binary-status-text');
|
||||||
|
if (status.exists) {
|
||||||
|
text.textContent = 'Installed and ready';
|
||||||
|
text.className = 'text-[11px] text-green-400';
|
||||||
|
btn.classList.add('hidden');
|
||||||
|
} else {
|
||||||
|
text.textContent = 'Not installed — required for local generation';
|
||||||
|
text.className = 'text-[11px] text-yellow-400';
|
||||||
|
btn.textContent = 'Install Engine';
|
||||||
|
btn.className = 'px-3 py-1.5 rounded-lg text-xs font-bold bg-primary text-black transition-all';
|
||||||
|
btn.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
if (onStatusChange) onStatusChange(status.exists);
|
||||||
|
};
|
||||||
|
|
||||||
|
btn.onclick = async () => {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Downloading...';
|
||||||
|
progressBar.classList.remove('hidden');
|
||||||
|
|
||||||
|
const unsub = localAI.onDownloadProgress(({ id, phase, progress }) => {
|
||||||
|
if (id !== '__binary__') return;
|
||||||
|
const fill = document.getElementById('binary-progress-fill');
|
||||||
|
const text = bar.querySelector('#binary-status-text');
|
||||||
|
if (fill) fill.style.width = `${Math.round(progress * 100)}%`;
|
||||||
|
if (text) text.textContent = phase === 'extracting' ? 'Extracting...' : `Downloading... ${Math.round(progress * 100)}%`;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await localAI.downloadBinary();
|
||||||
|
unsub();
|
||||||
|
progressBar.classList.add('hidden');
|
||||||
|
await refresh();
|
||||||
|
} catch (err) {
|
||||||
|
unsub();
|
||||||
|
const text = bar.querySelector('#binary-status-text');
|
||||||
|
if (text) text.textContent = `Error: ${err.message}`;
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Retry';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLocalAIAvailable()) refresh();
|
||||||
|
|
||||||
|
return bar;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Auxiliary file row (text encoder / VAE for Z-Image) ─────────────────────
|
||||||
|
function AuxRow(label, auxKey, initStatus, onStateChange) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'flex items-center justify-between gap-2 px-3 py-2 rounded-lg bg-white/3 border border-white/5';
|
||||||
|
|
||||||
|
const isReady = initStatus === 'downloaded';
|
||||||
|
|
||||||
|
row.innerHTML = `
|
||||||
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
|
${isReady
|
||||||
|
? `<span class="text-green-400 shrink-0">${CheckIcon}</span>`
|
||||||
|
: `<span class="text-yellow-400 shrink-0">!</span>`}
|
||||||
|
<span class="text-[11px] text-white truncate">${label}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
|
${isReady
|
||||||
|
? `<span class="text-[10px] text-green-400">Ready</span>`
|
||||||
|
: `<button class="aux-dl-btn flex items-center gap-1 px-2.5 py-1 rounded-lg text-[11px] font-bold bg-primary/20 text-primary border border-primary/30 hover:bg-primary/30 transition-all">${DownloadIcon} Get</button>`}
|
||||||
|
</div>
|
||||||
|
<div class="aux-progress hidden w-full col-span-2 mt-1">
|
||||||
|
<div class="h-1 rounded-full bg-white/10 overflow-hidden">
|
||||||
|
<div class="aux-fill h-full bg-primary transition-all" style="width:0%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="aux-text text-[10px] text-muted block mt-0.5">Downloading...</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const btn = row.querySelector('.aux-dl-btn');
|
||||||
|
if (btn) {
|
||||||
|
btn.onclick = async () => {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = `<span class="animate-spin">◌</span>`;
|
||||||
|
const progWrap = row.querySelector('.aux-progress');
|
||||||
|
const progFill = row.querySelector('.aux-fill');
|
||||||
|
const progText = row.querySelector('.aux-text');
|
||||||
|
progWrap.classList.remove('hidden');
|
||||||
|
|
||||||
|
const auxId = auxKey === 'llm' ? '__llm__' : '__vae__';
|
||||||
|
const unsub = localAI.onDownloadProgress(({ id, phase, progress }) => {
|
||||||
|
if (id !== auxId) return;
|
||||||
|
progFill.style.width = `${Math.round(progress * 100)}%`;
|
||||||
|
progText.textContent = phase === 'done' ? 'Complete!' : `Downloading... ${Math.round(progress * 100)}%`;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await localAI.downloadAuxiliary(auxKey);
|
||||||
|
unsub();
|
||||||
|
if (onStateChange) onStateChange();
|
||||||
|
} catch (err) {
|
||||||
|
unsub();
|
||||||
|
progText.textContent = `Error: ${err.message}`;
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = `${DownloadIcon} Retry`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Wan2GP Server Config ────────────────────────────────────────────────────
|
||||||
|
function Wan2gpConfigBar(onChange) {
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'flex flex-col gap-3 p-3 rounded-xl bg-white/3 border border-white/5';
|
||||||
|
wrap.innerHTML = `
|
||||||
|
<div class="flex flex-col gap-0.5">
|
||||||
|
<span class="text-xs font-bold text-white">Wan2GP server (optional)</span>
|
||||||
|
<span class="text-[11px] text-muted leading-relaxed">
|
||||||
|
Run <a href="https://github.com/deepbeepmeep/Wan2GP" target="_blank" class="text-primary hover:underline">Wan2GP</a>
|
||||||
|
on a CUDA box (<code class="text-primary/80">python wgp.py --listen --server-name 0.0.0.0</code>) to unlock video models from this UI.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input id="wan2gp-url" type="text" placeholder="http://127.0.0.1:7860"
|
||||||
|
class="flex-1 bg-white/5 border border-white/5 focus:border-primary/40 rounded-lg px-3 py-1.5 text-xs text-white placeholder-white/30 focus:outline-none"/>
|
||||||
|
<button id="wan2gp-test" class="px-3 py-1.5 rounded-lg text-xs font-bold bg-primary/20 text-primary border border-primary/30 hover:bg-primary/30 transition-all">Test</button>
|
||||||
|
<button id="wan2gp-save" class="px-3 py-1.5 rounded-lg text-xs font-bold bg-primary text-black hover:shadow-glow transition-all">Save</button>
|
||||||
|
</div>
|
||||||
|
<div id="wan2gp-status" class="text-[11px] text-muted">Not configured</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const input = wrap.querySelector('#wan2gp-url');
|
||||||
|
const testBtn = wrap.querySelector('#wan2gp-test');
|
||||||
|
const saveBtn = wrap.querySelector('#wan2gp-save');
|
||||||
|
const statusEl = wrap.querySelector('#wan2gp-status');
|
||||||
|
const setStatus = (text, kind = 'muted') => {
|
||||||
|
const colorMap = { muted: 'text-muted', ok: 'text-green-400', warn: 'text-yellow-400', err: 'text-red-400' };
|
||||||
|
statusEl.className = `text-[11px] ${colorMap[kind] || colorMap.muted}`;
|
||||||
|
statusEl.textContent = text;
|
||||||
|
};
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const cfg = await localAI.getWan2gpConfig();
|
||||||
|
if (cfg.url) {
|
||||||
|
input.value = cfg.url;
|
||||||
|
const r = await localAI.probeWan2gp(cfg.url);
|
||||||
|
setStatus(r.ok ? `Connected · Gradio ${r.version}` : `Saved URL not reachable: ${r.error}`, r.ok ? 'ok' : 'warn');
|
||||||
|
} else {
|
||||||
|
setStatus('Not configured (Wan2GP models will appear offline)', 'muted');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
testBtn.onclick = async () => {
|
||||||
|
const url = input.value.trim();
|
||||||
|
if (!url) { setStatus('Enter a URL first', 'warn'); return; }
|
||||||
|
setStatus('Probing...', 'muted');
|
||||||
|
testBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
const r = await localAI.probeWan2gp(url);
|
||||||
|
setStatus(r.ok ? `Reachable · Gradio ${r.version}` : `Unreachable: ${r.error}`, r.ok ? 'ok' : 'err');
|
||||||
|
} finally { testBtn.disabled = false; }
|
||||||
|
};
|
||||||
|
|
||||||
|
saveBtn.onclick = async () => {
|
||||||
|
const url = input.value.trim();
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
await localAI.setWan2gpUrl(url);
|
||||||
|
const r = url ? await localAI.probeWan2gp(url) : { ok: false, error: 'cleared' };
|
||||||
|
setStatus(r.ok ? `Saved · Connected to Gradio ${r.version}` : (url ? `Saved, not reachable: ${r.error}` : 'Cleared'), r.ok ? 'ok' : 'warn');
|
||||||
|
onChange?.();
|
||||||
|
} finally { saveBtn.disabled = false; }
|
||||||
|
};
|
||||||
|
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Model Card ───────────────────────────────────────────────────────────────
|
||||||
|
function Wan2gpModelCard(model) {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'flex items-start justify-between gap-3 p-4 rounded-xl border border-white/5 bg-white/3';
|
||||||
|
const ready = !!model.ready;
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="flex flex-col gap-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<span class="text-sm font-bold text-white truncate">${model.name}</span>
|
||||||
|
${ready ? `<span class="text-green-400">${CheckIcon}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<p class="text-[11px] text-muted leading-relaxed">${model.description}</p>
|
||||||
|
<div class="flex items-center gap-1.5 flex-wrap mt-1">
|
||||||
|
<span class="px-1.5 py-0.5 rounded-md text-[10px] font-bold ${model.type === 'video' ? 'bg-purple-500/15 text-purple-300' : 'bg-primary/10 text-primary'}">${model.type.toUpperCase()}</span>
|
||||||
|
<span class="px-1.5 py-0.5 rounded-md text-[10px] font-bold bg-white/5 text-muted">via Wan2GP</span>
|
||||||
|
${(model.tags || []).filter(t => !['featured', 'remote'].includes(t)).map(t => `<span class="px-1.5 py-0.5 rounded-md text-[10px] font-bold bg-white/5 text-muted">${t}</span>`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="shrink-0">
|
||||||
|
<span class="text-[10px] font-bold ${ready ? 'text-green-400' : 'text-yellow-400'}">${ready ? 'Available' : 'Server offline'}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModelCard(model, onStateChange) {
|
||||||
|
if (model.provider === 'wan2gp') return Wan2gpModelCard(model);
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'flex flex-col gap-3 p-4 rounded-xl border border-white/5 bg-white/3 hover:border-white/10 transition-all';
|
||||||
|
|
||||||
|
const isDownloaded = model.state === 'downloaded';
|
||||||
|
const auxStatus = model.auxiliaryStatus || {};
|
||||||
|
const auxReady = !model.requiresAuxiliary || (auxStatus.llm === 'downloaded' && auxStatus.vae === 'downloaded');
|
||||||
|
const fullyReady = isDownloaded && auxReady;
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="flex flex-col gap-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<span class="text-sm font-bold text-white truncate">${model.name}</span>
|
||||||
|
${model.featured ? `<span class="px-1.5 py-0.5 rounded-md text-[10px] font-black bg-primary/20 text-primary border border-primary/30">⚡ Featured</span>` : ''}
|
||||||
|
${fullyReady ? `<span class="text-green-400">${CheckIcon}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<p class="text-[11px] text-muted leading-relaxed">${model.description}</p>
|
||||||
|
<div class="flex items-center gap-1.5 flex-wrap mt-1">
|
||||||
|
<span class="px-1.5 py-0.5 rounded-md text-[10px] font-bold bg-primary/10 text-primary">${model.type.toUpperCase()}</span>
|
||||||
|
<span class="px-1.5 py-0.5 rounded-md text-[10px] font-bold bg-white/5 text-muted">${fmtGB(model.sizeGB)}</span>
|
||||||
|
${(model.tags || []).filter(t => t !== 'featured').map(t => `<span class="px-1.5 py-0.5 rounded-md text-[10px] font-bold bg-white/5 text-muted">${t}</span>`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
|
${isDownloaded
|
||||||
|
? `<button class="delete-btn p-2 rounded-lg text-red-400 hover:bg-red-500/10 transition-all">${TrashIcon}</button>`
|
||||||
|
: `<button class="download-btn flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold bg-primary text-black hover:shadow-glow transition-all">${DownloadIcon} Download</button>`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="progress-wrap hidden">
|
||||||
|
<div class="h-1 rounded-full bg-white/10 overflow-hidden">
|
||||||
|
<div class="progress-fill h-full bg-primary transition-all" style="width:0%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="progress-text text-[10px] text-muted mt-1 block">Preparing...</span>
|
||||||
|
</div>
|
||||||
|
${model.requiresAuxiliary ? `<div class="aux-section flex flex-col gap-1.5 pt-1 border-t border-white/5"></div>` : ''}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Auxiliary files section for Z-Image
|
||||||
|
if (model.requiresAuxiliary) {
|
||||||
|
const auxSection = card.querySelector('.aux-section');
|
||||||
|
auxSection.appendChild(document.createElement('span')).className = 'text-[10px] text-muted uppercase tracking-wider font-bold';
|
||||||
|
auxSection.querySelector('span').textContent = 'Required components';
|
||||||
|
auxSection.appendChild(AuxRow('Qwen3-4B Text Encoder (2.4 GB)', 'llm', auxStatus.llm, onStateChange));
|
||||||
|
auxSection.appendChild(AuxRow('FLUX VAE (335 MB)', 'vae', auxStatus.vae, onStateChange));
|
||||||
|
}
|
||||||
|
|
||||||
|
const progressWrap = card.querySelector('.progress-wrap');
|
||||||
|
const progressFill = card.querySelector('.progress-fill');
|
||||||
|
const progressText = card.querySelector('.progress-text');
|
||||||
|
|
||||||
|
const downloadBtn = card.querySelector('.download-btn');
|
||||||
|
if (downloadBtn) {
|
||||||
|
downloadBtn.onclick = async () => {
|
||||||
|
downloadBtn.disabled = true;
|
||||||
|
downloadBtn.innerHTML = `<span class="animate-spin">◌</span> Starting...`;
|
||||||
|
progressWrap.classList.remove('hidden');
|
||||||
|
|
||||||
|
const unsub = localAI.onDownloadProgress(({ id, phase, progress }) => {
|
||||||
|
if (id !== model.id) return;
|
||||||
|
progressFill.style.width = `${Math.round(progress * 100)}%`;
|
||||||
|
progressText.textContent = phase === 'done' ? 'Complete!' : `Downloading... ${Math.round(progress * 100)}%`;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await localAI.downloadModel(model.id);
|
||||||
|
unsub();
|
||||||
|
if (onStateChange) onStateChange();
|
||||||
|
} catch (err) {
|
||||||
|
unsub();
|
||||||
|
progressText.textContent = `Error: ${err.message}`;
|
||||||
|
downloadBtn.disabled = false;
|
||||||
|
downloadBtn.innerHTML = `${DownloadIcon} Retry`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteBtn = card.querySelector('.delete-btn');
|
||||||
|
if (deleteBtn) {
|
||||||
|
deleteBtn.onclick = async () => {
|
||||||
|
if (!confirm(`Delete "${model.name}"? You'll need to re-download it to use it again.`)) return;
|
||||||
|
await localAI.deleteModel(model.id);
|
||||||
|
if (onStateChange) onStateChange();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main component ───────────────────────────────────────────────────────────
|
||||||
|
export function LocalModelManager() {
|
||||||
|
const root = document.createElement('div');
|
||||||
|
root.className = 'flex flex-col gap-5';
|
||||||
|
|
||||||
|
if (!isLocalAIAvailable()) {
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="flex flex-col items-center gap-3 py-8 text-center">
|
||||||
|
<p class="text-sm font-bold text-white">Local Models</p>
|
||||||
|
<p class="text-xs text-muted max-w-xs">Local model inference is only available in the desktop app (Electron build). Use <span class="text-primary font-bold">npm run electron:build</span> to build.</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Section: engine status
|
||||||
|
const engineSection = document.createElement('div');
|
||||||
|
engineSection.className = 'flex flex-col gap-2';
|
||||||
|
engineSection.innerHTML = `<h3 class="text-xs font-bold text-secondary uppercase tracking-wider">Inference Engine</h3>`;
|
||||||
|
|
||||||
|
let binaryReady = false;
|
||||||
|
const binaryBar = BinaryStatusBar((ready) => { binaryReady = ready; });
|
||||||
|
engineSection.appendChild(binaryBar);
|
||||||
|
|
||||||
|
const wan2gpBar = Wan2gpConfigBar(() => renderModels());
|
||||||
|
engineSection.appendChild(wan2gpBar);
|
||||||
|
root.appendChild(engineSection);
|
||||||
|
|
||||||
|
// ── Section: models
|
||||||
|
const modelsSection = document.createElement('div');
|
||||||
|
modelsSection.className = 'flex flex-col gap-3';
|
||||||
|
modelsSection.innerHTML = `
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-xs font-bold text-secondary uppercase tracking-wider">Local Models</h3>
|
||||||
|
<span class="text-[10px] text-muted">Stored in your app data folder</span>
|
||||||
|
</div>
|
||||||
|
<div id="local-model-list" class="flex flex-col gap-3"></div>
|
||||||
|
`;
|
||||||
|
root.appendChild(modelsSection);
|
||||||
|
|
||||||
|
const listEl = modelsSection.querySelector('#local-model-list');
|
||||||
|
|
||||||
|
const renderModels = async () => {
|
||||||
|
listEl.innerHTML = `<div class="text-xs text-muted text-center py-4">Loading...</div>`;
|
||||||
|
try {
|
||||||
|
const models = await localAI.listModels();
|
||||||
|
listEl.innerHTML = '';
|
||||||
|
models.forEach(m => {
|
||||||
|
listEl.appendChild(ModelCard(m, renderModels));
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
listEl.innerHTML = `<div class="text-xs text-red-400 text-center py-4">Error loading models: ${err.message}</div>`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
renderModels();
|
||||||
|
|
||||||
|
return root;
|
||||||
|
}
|
||||||
153
src/components/McpCliStudio.js
Normal file
153
src/components/McpCliStudio.js
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
export function McpCliStudio() {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.className = 'w-full h-full overflow-y-auto bg-app-bg text-white';
|
||||||
|
|
||||||
|
const inner = document.createElement('div');
|
||||||
|
inner.className = 'max-w-5xl mx-auto px-6 py-12 flex flex-col gap-12';
|
||||||
|
container.appendChild(inner);
|
||||||
|
|
||||||
|
// Hero
|
||||||
|
const hero = document.createElement('section');
|
||||||
|
hero.className = 'flex flex-col items-center text-center gap-4';
|
||||||
|
hero.innerHTML = `
|
||||||
|
<div class="px-3 py-1 rounded-full border border-white/10 bg-white/5 text-[11px] font-bold uppercase tracking-widest text-secondary">
|
||||||
|
For developers & AI agents
|
||||||
|
</div>
|
||||||
|
<h1 class="text-4xl md:text-5xl font-bold tracking-tight">MCP & CLI</h1>
|
||||||
|
<p class="text-secondary text-base md:text-lg max-w-2xl">
|
||||||
|
Use Open Higgsfield AI from your terminal, your IDE, or any MCP-compatible
|
||||||
|
assistant. Generate cinematic images, videos, and audio across 100+ models —
|
||||||
|
without leaving your workflow.
|
||||||
|
</p>
|
||||||
|
`;
|
||||||
|
inner.appendChild(hero);
|
||||||
|
|
||||||
|
// Quick start
|
||||||
|
const quick = document.createElement('section');
|
||||||
|
quick.className = 'glass-panel rounded-2xl p-6 md:p-8 flex flex-col gap-4';
|
||||||
|
quick.innerHTML = `
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-[11px] font-bold uppercase tracking-widest text-secondary">Quick start</span>
|
||||||
|
<div class="flex-1 h-px bg-white/5"></div>
|
||||||
|
</div>
|
||||||
|
<div class="grid md:grid-cols-3 gap-4">
|
||||||
|
${quickStep('1', 'Install the CLI', 'npm install -g muapi-cli')}
|
||||||
|
${quickStep('2', 'Sign in', 'muapi auth login')}
|
||||||
|
${quickStep('3', 'Generate from chat', 'npx skills add SamurAIGPT/Generative-Media-Skills')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
inner.appendChild(quick);
|
||||||
|
|
||||||
|
// Feature cards
|
||||||
|
const cards = document.createElement('section');
|
||||||
|
cards.className = 'grid md:grid-cols-3 gap-4';
|
||||||
|
|
||||||
|
cards.appendChild(featureCard({
|
||||||
|
tag: 'CLI',
|
||||||
|
title: 'muapi-cli',
|
||||||
|
body: 'Generate images, videos, and audio from the terminal across 14+ AI models. Dual interface — colored human output plus JSON for agents (--output-json, --jq filtering). Async workflows, file uploads, credit tracking.',
|
||||||
|
code: 'npm install -g muapi-cli\nmuapi image generate "a cyberpunk city" \\\n --model flux-dev',
|
||||||
|
link: 'https://github.com/SamurAIGPT/muapi-cli',
|
||||||
|
linkLabel: 'View muapi-cli on GitHub',
|
||||||
|
icon: `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
cards.appendChild(featureCard({
|
||||||
|
tag: 'MCP',
|
||||||
|
title: 'muapi-mcp-server',
|
||||||
|
body: 'Connect Claude, Cursor, Windsurf, and any MCP-compatible assistant to 100+ generative models. Hosted endpoint — no install. 19 structured tools with input/output schemas, async polling, and account management.',
|
||||||
|
code: 'claude mcp add --transport http muapi \\\n https://api.muapi.ai/mcp \\\n --header "Authorization: Bearer YOUR_KEY"',
|
||||||
|
link: 'https://github.com/SamurAIGPT/muapi-mcp-server',
|
||||||
|
linkLabel: 'View muapi-mcp-server on GitHub',
|
||||||
|
icon: `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 1v6M12 17v6M4.22 4.22l4.24 4.24M15.54 15.54l4.24 4.24M1 12h6M17 12h6M4.22 19.78l4.24-4.24M15.54 8.46l4.24-4.24"/></svg>`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
cards.appendChild(featureCard({
|
||||||
|
tag: 'Skills',
|
||||||
|
title: 'Generative Media Skills',
|
||||||
|
body: 'Multimodal toolkit for Claude Code, Cursor, and Gemini CLI. Cinema Director, Nano-Banana, UI Designer, Logo Creator, Seedance 2, AI Clipping, and YouTube Shorts presets. Agent-native with JSON outputs and semantic exit codes.',
|
||||||
|
code: 'npx skills add SamurAIGPT/Generative-Media-Skills --all',
|
||||||
|
link: 'https://github.com/SamurAIGPT/Generative-Media-Skills',
|
||||||
|
linkLabel: 'View Generative-Media-Skills on GitHub',
|
||||||
|
icon: `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l2.39 4.84L20 8l-4 3.9.94 5.5L12 14.77 7.06 17.4 8 11.9 4 8l5.61-1.16L12 2z"/></svg>`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
inner.appendChild(cards);
|
||||||
|
|
||||||
|
// Usage examples
|
||||||
|
const examples = document.createElement('section');
|
||||||
|
examples.className = 'flex flex-col gap-4';
|
||||||
|
examples.innerHTML = `
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-[11px] font-bold uppercase tracking-widest text-secondary">Examples</span>
|
||||||
|
<div class="flex-1 h-px bg-white/5"></div>
|
||||||
|
</div>
|
||||||
|
<div class="grid md:grid-cols-2 gap-4">
|
||||||
|
${exampleBlock('Image generation', 'muapi image generate "a serene mountain lake at sunrise" \\\n --model flux-dev --download ./outputs')}
|
||||||
|
${exampleBlock('Text-to-video', 'muapi video generate "a dog running on a beach" \\\n --model kling-master')}
|
||||||
|
${exampleBlock('Audio creation', 'muapi audio create "upbeat lo-fi hip hop for studying"')}
|
||||||
|
${exampleBlock('Run a skill', 'bash library/visual/nano-banana/scripts/\\\n generate-nano-art.sh --file image.jpg --view')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
inner.appendChild(examples);
|
||||||
|
|
||||||
|
// Footer note
|
||||||
|
const footer = document.createElement('p');
|
||||||
|
footer.className = 'text-center text-xs text-secondary opacity-60 pb-4';
|
||||||
|
footer.textContent = 'Open-source · MIT licensed · Works with Claude, Cursor, Windsurf, and Gemini CLI';
|
||||||
|
inner.appendChild(footer);
|
||||||
|
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
function quickStep(num, title, code) {
|
||||||
|
return `
|
||||||
|
<div class="rounded-xl border border-white/5 bg-white/[0.02] p-4 flex flex-col gap-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="w-6 h-6 rounded-full bg-white text-black text-xs font-bold flex items-center justify-center">${num}</span>
|
||||||
|
<span class="text-sm font-bold">${title}</span>
|
||||||
|
</div>
|
||||||
|
<code class="text-[12px] font-mono text-primary bg-black/40 rounded-md px-2 py-1.5 break-all">${escapeHtml(code)}</code>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function featureCard({ tag, title, body, code, link, linkLabel, icon }) {
|
||||||
|
const card = document.createElement('a');
|
||||||
|
card.href = link;
|
||||||
|
card.target = '_blank';
|
||||||
|
card.rel = 'noopener noreferrer';
|
||||||
|
card.setAttribute('aria-label', linkLabel);
|
||||||
|
card.className = 'glass-panel rounded-2xl p-6 flex flex-col gap-3 hover:bg-white/[0.04] transition-colors group';
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="w-10 h-10 rounded-xl bg-white/5 flex items-center justify-center text-white">${icon}</div>
|
||||||
|
<span class="text-[10px] font-bold uppercase tracking-widest text-secondary">${tag}</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-bold">${title}</h3>
|
||||||
|
<p class="text-[13px] text-secondary leading-relaxed">${body}</p>
|
||||||
|
<pre class="text-[11px] font-mono text-primary bg-black/40 rounded-md px-3 py-2 overflow-x-auto whitespace-pre">${escapeHtml(code)}</pre>
|
||||||
|
<div class="flex items-center gap-1 text-[12px] font-bold text-secondary group-hover:text-white transition-colors mt-auto">
|
||||||
|
<span>View on GitHub</span>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="7" y1="17" x2="17" y2="7"/><polyline points="7 7 17 7 17 17"/></svg>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exampleBlock(title, code) {
|
||||||
|
return `
|
||||||
|
<div class="rounded-xl border border-white/5 bg-white/[0.02] p-4 flex flex-col gap-2">
|
||||||
|
<span class="text-[12px] font-bold text-white/80">${title}</span>
|
||||||
|
<pre class="text-[11px] font-mono text-primary bg-black/40 rounded-md px-3 py-2 overflow-x-auto whitespace-pre">${escapeHtml(code)}</pre>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
@ -1,92 +1,117 @@
|
||||||
|
import { LocalModelManager } from './LocalModelManager.js';
|
||||||
|
import { isLocalAIAvailable } from '../lib/localInferenceClient.js';
|
||||||
|
|
||||||
export function SettingsModal(onClose) {
|
export function SettingsModal(onClose) {
|
||||||
const overlay = document.createElement('div');
|
const overlay = document.createElement('div');
|
||||||
overlay.className = 'fixed inset-0 bg-black/80 flex items-center justify-center z-50';
|
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.8);display:flex;align-items:center;justify-content:center;z-index:100;';
|
||||||
overlay.style.position = 'fixed';
|
|
||||||
overlay.style.top = '0';
|
|
||||||
overlay.style.left = '0';
|
|
||||||
overlay.style.right = '0';
|
|
||||||
overlay.style.bottom = '0';
|
|
||||||
overlay.style.backgroundColor = 'rgba(0,0,0,0.8)';
|
|
||||||
overlay.style.display = 'flex';
|
|
||||||
overlay.style.alignItems = 'center';
|
|
||||||
overlay.style.justifyContent = 'center';
|
|
||||||
overlay.style.zIndex = '100';
|
|
||||||
|
|
||||||
const modal = document.createElement('div');
|
const modal = document.createElement('div');
|
||||||
modal.className = 'bg-card p-6 rounded-xl border border-border-color w-96 glass';
|
modal.style.cssText = 'background:var(--bg-card,#111);border-radius:1rem;border:1px solid rgba(255,255,255,0.08);width:min(90vw,36rem);max-height:85vh;display:flex;flex-direction:column;overflow:hidden;';
|
||||||
modal.style.background = 'var(--bg-card)';
|
|
||||||
modal.style.padding = '1.5rem';
|
|
||||||
modal.style.borderRadius = 'var(--border-radius-xl)';
|
|
||||||
modal.style.border = '1px solid var(--border-color)';
|
|
||||||
modal.style.width = '24rem';
|
|
||||||
|
|
||||||
const title = document.createElement('h2');
|
// ── Header ────────────────────────────────────────────────────────────────
|
||||||
title.textContent = 'Settings';
|
const header = document.createElement('div');
|
||||||
title.className = 'text-xl font-bold mb-4';
|
header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:1.25rem 1.5rem;border-bottom:1px solid rgba(255,255,255,0.06);flex-shrink:0;';
|
||||||
title.style.marginBottom = '1rem';
|
header.innerHTML = `
|
||||||
|
<h2 style="font-size:1rem;font-weight:800;color:#fff;margin:0;">Settings</h2>
|
||||||
|
<button id="settings-close-btn" style="color:rgba(255,255,255,0.4);background:none;border:none;cursor:pointer;padding:4px;">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M18 6L6 18M6 6l12 12"/></svg>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
modal.appendChild(header);
|
||||||
|
|
||||||
const label = document.createElement('label');
|
// ── Tabs ──────────────────────────────────────────────────────────────────
|
||||||
label.textContent = 'Muapi API Key';
|
const TABS = [
|
||||||
label.className = 'block text-sm text-secondary mb-2';
|
{ id: 'api', label: 'API Key' },
|
||||||
|
...(isLocalAIAvailable() ? [{ id: 'local', label: 'Local Models' }] : []),
|
||||||
|
];
|
||||||
|
|
||||||
const input = document.createElement('input');
|
let activeTab = 'api';
|
||||||
input.type = 'password';
|
|
||||||
input.className = 'w-full mb-4 p-2 rounded bg-input border border-border-color';
|
|
||||||
input.value = localStorage.getItem('muapi_key') || '';
|
|
||||||
input.placeholder = 'Enter your Muapi API key...';
|
|
||||||
input.style.width = '100%';
|
|
||||||
input.style.marginBottom = '1rem';
|
|
||||||
|
|
||||||
const btnContainer = document.createElement('div');
|
const tabBar = document.createElement('div');
|
||||||
btnContainer.className = 'flex justify-end gap-2';
|
tabBar.style.cssText = 'display:flex;gap:0.25rem;padding:0.75rem 1.5rem 0;border-bottom:1px solid rgba(255,255,255,0.06);flex-shrink:0;';
|
||||||
btnContainer.style.display = 'flex';
|
|
||||||
btnContainer.style.justifyContent = 'flex-end';
|
|
||||||
btnContainer.style.gap = '0.5rem';
|
|
||||||
|
|
||||||
const cancelBtn = document.createElement('button');
|
const tabBtns = {};
|
||||||
cancelBtn.textContent = 'Cancel';
|
TABS.forEach(({ id, label }) => {
|
||||||
cancelBtn.className = 'px-4 py-2 rounded hover:bg-white/5';
|
const btn = document.createElement('button');
|
||||||
cancelBtn.onclick = () => {
|
btn.textContent = label;
|
||||||
document.body.removeChild(overlay);
|
btn.style.cssText = 'padding:0.4rem 0.75rem;border-radius:0.5rem 0.5rem 0 0;font-size:0.75rem;font-weight:700;border:none;cursor:pointer;transition:all 0.15s;';
|
||||||
|
btn.onclick = () => switchTab(id);
|
||||||
|
tabBtns[id] = btn;
|
||||||
|
tabBar.appendChild(btn);
|
||||||
|
});
|
||||||
|
modal.appendChild(tabBar);
|
||||||
|
|
||||||
|
// ── Body ──────────────────────────────────────────────────────────────────
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.style.cssText = 'flex:1;overflow-y:auto;padding:1.5rem;';
|
||||||
|
modal.appendChild(body);
|
||||||
|
|
||||||
|
// ── Tab: API Key ──────────────────────────────────────────────────────────
|
||||||
|
const apiPanel = document.createElement('div');
|
||||||
|
apiPanel.innerHTML = `
|
||||||
|
<div style="display:flex;flex-direction:column;gap:0.75rem;">
|
||||||
|
<div>
|
||||||
|
<label style="display:block;font-size:0.75rem;color:rgba(255,255,255,0.5);margin-bottom:0.4rem;font-weight:600;">Muapi API Key</label>
|
||||||
|
<input id="settings-api-key" type="password"
|
||||||
|
style="width:100%;box-sizing:border-box;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.1);border-radius:0.75rem;padding:0.6rem 0.9rem;color:#fff;font-size:0.875rem;outline:none;"
|
||||||
|
placeholder="Enter your Muapi API key..."
|
||||||
|
value="${localStorage.getItem('muapi_key') || ''}">
|
||||||
|
</div>
|
||||||
|
<p style="font-size:0.7rem;color:rgba(255,255,255,0.3);margin:0;">
|
||||||
|
Your API key is stored locally and never sent anywhere except api.muapi.ai.
|
||||||
|
</p>
|
||||||
|
<div style="display:flex;justify-content:flex-end;gap:0.5rem;margin-top:0.5rem;">
|
||||||
|
<button id="settings-cancel-btn" style="padding:0.5rem 1rem;border-radius:0.5rem;background:none;border:1px solid rgba(255,255,255,0.1);color:rgba(255,255,255,0.6);font-size:0.75rem;font-weight:700;cursor:pointer;">Cancel</button>
|
||||||
|
<button id="settings-save-btn" style="padding:0.5rem 1rem;border-radius:0.5rem;background:var(--color-primary,#d9ff00);color:#000;font-size:0.75rem;font-weight:700;cursor:pointer;border:none;">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ── Tab: Local Models ─────────────────────────────────────────────────────
|
||||||
|
const localPanel = LocalModelManager();
|
||||||
|
|
||||||
|
// ── Tab switching ─────────────────────────────────────────────────────────
|
||||||
|
const switchTab = (id) => {
|
||||||
|
activeTab = id;
|
||||||
|
body.innerHTML = '';
|
||||||
|
|
||||||
|
TABS.forEach(({ id: tid }) => {
|
||||||
|
const btn = tabBtns[tid];
|
||||||
|
if (tid === id) {
|
||||||
|
btn.style.background = 'rgba(255,255,255,0.08)';
|
||||||
|
btn.style.color = '#fff';
|
||||||
|
} else {
|
||||||
|
btn.style.background = 'transparent';
|
||||||
|
btn.style.color = 'rgba(255,255,255,0.4)';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (id === 'api') body.appendChild(apiPanel);
|
||||||
|
if (id === 'local') body.appendChild(localPanel);
|
||||||
|
};
|
||||||
|
|
||||||
|
switchTab('api');
|
||||||
|
|
||||||
|
// ── API key save/cancel handlers ──────────────────────────────────────────
|
||||||
|
const close = () => {
|
||||||
|
if (document.body.contains(overlay)) document.body.removeChild(overlay);
|
||||||
if (onClose) onClose();
|
if (onClose) onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveBtn = document.createElement('button');
|
apiPanel.querySelector('#settings-cancel-btn').onclick = close;
|
||||||
saveBtn.textContent = 'Save';
|
apiPanel.querySelector('#settings-save-btn').onclick = () => {
|
||||||
saveBtn.className = 'px-4 py-2 rounded bg-primary text-black font-medium';
|
const key = apiPanel.querySelector('#settings-api-key').value.trim();
|
||||||
saveBtn.style.backgroundColor = 'var(--color-primary)';
|
|
||||||
saveBtn.style.color = 'black';
|
|
||||||
saveBtn.style.fontWeight = '500';
|
|
||||||
|
|
||||||
saveBtn.onclick = () => {
|
|
||||||
const key = input.value.trim();
|
|
||||||
if (key) {
|
if (key) {
|
||||||
localStorage.setItem('muapi_key', key);
|
localStorage.setItem('muapi_key', key);
|
||||||
alert('API Key saved!');
|
close();
|
||||||
document.body.removeChild(overlay);
|
|
||||||
if (onClose) onClose();
|
|
||||||
} else {
|
} else {
|
||||||
alert('Please enter a valid key');
|
alert('Please enter a valid API key.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
modal.appendChild(title);
|
header.querySelector('#settings-close-btn').onclick = close;
|
||||||
modal.appendChild(label);
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
||||||
modal.appendChild(input);
|
|
||||||
|
|
||||||
btnContainer.appendChild(cancelBtn);
|
|
||||||
btnContainer.appendChild(saveBtn);
|
|
||||||
modal.appendChild(btnContainer);
|
|
||||||
|
|
||||||
overlay.appendChild(modal);
|
overlay.appendChild(modal);
|
||||||
|
|
||||||
// Close on outside click
|
|
||||||
overlay.addEventListener('click', (e) => {
|
|
||||||
if (e.target === overlay) {
|
|
||||||
document.body.removeChild(overlay);
|
|
||||||
if (onClose) onClose();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return overlay;
|
return overlay;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,12 @@ import { getUploadHistory, saveUpload, removeUpload, generateThumbnail } from '.
|
||||||
* @param {number} [options.maxImages=1] - Maximum number of images selectable
|
* @param {number} [options.maxImages=1] - Maximum number of images selectable
|
||||||
* @returns {{ trigger: HTMLElement, panel: HTMLElement, reset: function, setMaxImages: function }}
|
* @returns {{ trigger: HTMLElement, panel: HTMLElement, reset: function, setMaxImages: function }}
|
||||||
*/
|
*/
|
||||||
export function createUploadPicker({ anchorContainer, onSelect, onClear, maxImages: initialMaxImages = 1 }) {
|
export function createUploadPicker({ anchorContainer, onSelect, onClear, maxImages: initialMaxImages = 1, uploadFn, requireApiKey }) {
|
||||||
|
// uploadFn(file) → Promise<string url>. Defaults to Muapi-hosted upload.
|
||||||
|
// requireApiKey() → boolean. Lets the caller suppress the AuthModal when
|
||||||
|
// the active provider doesn't need a Muapi key (e.g. local Wan2GP).
|
||||||
|
const doUpload = uploadFn || ((file) => muapi.uploadFile(file));
|
||||||
|
const needsKey = typeof requireApiKey === 'function' ? requireApiKey : () => true;
|
||||||
let panelOpen = false;
|
let panelOpen = false;
|
||||||
let maxImages = initialMaxImages;
|
let maxImages = initialMaxImages;
|
||||||
let selectedEntries = []; // [{ url, thumbnail }, ...]
|
let selectedEntries = []; // [{ url, thumbnail }, ...]
|
||||||
|
|
@ -318,10 +323,12 @@ export function createUploadPicker({ anchorContainer, onSelect, onClear, maxImag
|
||||||
const files = Array.from(e.target.files);
|
const files = Array.from(e.target.files);
|
||||||
if (!files.length) return;
|
if (!files.length) return;
|
||||||
|
|
||||||
const apiKey = localStorage.getItem('muapi_key');
|
if (needsKey()) {
|
||||||
if (!apiKey) {
|
const apiKey = localStorage.getItem('muapi_key');
|
||||||
AuthModal(() => fileInput.click());
|
if (!apiKey) {
|
||||||
return;
|
AuthModal(() => fileInput.click());
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
showSpinner();
|
showSpinner();
|
||||||
|
|
@ -330,10 +337,11 @@ export function createUploadPicker({ anchorContainer, onSelect, onClear, maxImag
|
||||||
if (maxImages === 1) {
|
if (maxImages === 1) {
|
||||||
// Single mode: upload first file only, replace selection
|
// Single mode: upload first file only, replace selection
|
||||||
const file = files[0];
|
const file = files[0];
|
||||||
const [uploadedUrl, thumbnail] = await Promise.all([
|
const [uploadResult, thumbnail] = await Promise.all([
|
||||||
muapi.uploadFile(file),
|
doUpload(file),
|
||||||
generateThumbnail(file)
|
generateThumbnail(file)
|
||||||
]);
|
]);
|
||||||
|
const uploadedUrl = typeof uploadResult === 'string' ? uploadResult : uploadResult?.url;
|
||||||
const entry = { id: Date.now().toString(), name: file.name, uploadedUrl, thumbnail, timestamp: new Date().toISOString() };
|
const entry = { id: Date.now().toString(), name: file.name, uploadedUrl, thumbnail, timestamp: new Date().toISOString() };
|
||||||
saveUpload(entry);
|
saveUpload(entry);
|
||||||
selectedEntries = [{ url: uploadedUrl, thumbnail }];
|
selectedEntries = [{ url: uploadedUrl, thumbnail }];
|
||||||
|
|
@ -346,10 +354,11 @@ export function createUploadPicker({ anchorContainer, onSelect, onClear, maxImag
|
||||||
|
|
||||||
// Upload all in parallel
|
// Upload all in parallel
|
||||||
const results = await Promise.all(toUpload.map(async (file) => {
|
const results = await Promise.all(toUpload.map(async (file) => {
|
||||||
const [uploadedUrl, thumbnail] = await Promise.all([
|
const [uploadResult, thumbnail] = await Promise.all([
|
||||||
muapi.uploadFile(file),
|
doUpload(file),
|
||||||
generateThumbnail(file)
|
generateThumbnail(file)
|
||||||
]);
|
]);
|
||||||
|
const uploadedUrl = typeof uploadResult === 'string' ? uploadResult : uploadResult?.url;
|
||||||
return { id: Date.now().toString() + Math.random(), name: file.name, uploadedUrl, thumbnail, timestamp: new Date().toISOString() };
|
return { id: Date.now().toString() + Math.random(), name: file.name, uploadedUrl, thumbnail, timestamp: new Date().toISOString() };
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,36 @@ import { t2vModels, getAspectRatiosForVideoModel, getDurationsForModel, getResol
|
||||||
import { AuthModal } from './AuthModal.js';
|
import { AuthModal } from './AuthModal.js';
|
||||||
import { createUploadPicker } from './UploadPicker.js';
|
import { createUploadPicker } from './UploadPicker.js';
|
||||||
import { savePendingJob, removePendingJob, getPendingJobs } from '../lib/pendingJobs.js';
|
import { savePendingJob, removePendingJob, getPendingJobs } from '../lib/pendingJobs.js';
|
||||||
|
import { localAI, isLocalAIAvailable } from '../lib/localInferenceClient.js';
|
||||||
|
import { isWan2gpModelId, getLocalModelById, localT2VModels, localI2VModels } from '../lib/localModels.js';
|
||||||
|
|
||||||
|
// Promotes a wan2gp catalog entry (lib/localModels.js shape) into the
|
||||||
|
// `inputs`-shaped descriptor the Video Studio dropdowns/controls expect.
|
||||||
|
const adaptLocalToVideoEntry = (m) => ({
|
||||||
|
id: m.id,
|
||||||
|
name: m.name,
|
||||||
|
provider: 'wan2gp',
|
||||||
|
inputs: {
|
||||||
|
prompt: { type: 'string', name: 'prompt', title: 'Prompt' },
|
||||||
|
aspect_ratio: { type: 'string', name: 'aspect_ratio', enum: m.aspectRatios || ['16:9', '1:1', '9:16'], default: (m.aspectRatios || ['16:9'])[0] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export function VideoStudio() {
|
export function VideoStudio() {
|
||||||
const container = document.createElement('div');
|
const container = document.createElement('div');
|
||||||
container.className = 'w-full h-full flex flex-col items-center justify-center bg-app-bg relative p-4 md:p-6 overflow-y-auto custom-scrollbar overflow-x-hidden';
|
container.className = 'w-full h-full flex flex-col items-center justify-center bg-app-bg relative p-4 md:p-6 overflow-y-auto custom-scrollbar overflow-x-hidden';
|
||||||
|
|
||||||
|
// Merge Wan2GP video models in only when running inside Electron AND the
|
||||||
|
// user has a Wan2GP server configured. We can't probe synchronously, so
|
||||||
|
// we always include them when isLocalAIAvailable() — getCurrentModel()
|
||||||
|
// reads from these arrays, so they need to be present from init.
|
||||||
|
const localT2V = isLocalAIAvailable() ? localT2VModels.map(adaptLocalToVideoEntry) : [];
|
||||||
|
const localI2V = isLocalAIAvailable() ? localI2VModels.map(adaptLocalToVideoEntry) : [];
|
||||||
|
const allT2V = [...t2vModels, ...localT2V];
|
||||||
|
const allI2V = [...i2vModels, ...localI2V];
|
||||||
|
|
||||||
// --- State ---
|
// --- State ---
|
||||||
const defaultModel = t2vModels[0];
|
const defaultModel = allT2V[0];
|
||||||
let selectedModel = defaultModel.id;
|
let selectedModel = defaultModel.id;
|
||||||
let selectedModelName = defaultModel.name;
|
let selectedModelName = defaultModel.name;
|
||||||
let selectedAr = defaultModel.inputs?.aspect_ratio?.default || '16:9';
|
let selectedAr = defaultModel.inputs?.aspect_ratio?.default || '16:9';
|
||||||
|
|
@ -17,24 +40,43 @@ export function VideoStudio() {
|
||||||
let selectedResolution = defaultModel.inputs?.resolution?.default || '';
|
let selectedResolution = defaultModel.inputs?.resolution?.default || '';
|
||||||
let selectedQuality = defaultModel.inputs?.quality?.default || '';
|
let selectedQuality = defaultModel.inputs?.quality?.default || '';
|
||||||
let selectedMode = '';
|
let selectedMode = '';
|
||||||
|
let selectedEffectName = '';
|
||||||
let lastGenerationId = null;
|
let lastGenerationId = null;
|
||||||
let lastGenerationModel = null;
|
let lastGenerationModel = null;
|
||||||
let dropdownOpen = null;
|
let dropdownOpen = null;
|
||||||
let uploadedImageUrl = null;
|
let uploadedImageUrl = null;
|
||||||
|
let uploadedEndImageUrl = null; // optional end-frame for FLF i2v models
|
||||||
let imageMode = false; // false = t2v models, true = i2v models
|
let imageMode = false; // false = t2v models, true = i2v models
|
||||||
let v2vMode = false; // true = video-to-video tools mode
|
let v2vMode = false; // true = video-to-video tools mode
|
||||||
let uploadedVideoUrl = null;
|
let uploadedVideoUrl = null;
|
||||||
|
|
||||||
const getCurrentModels = () => v2vMode ? v2vModels : (imageMode ? i2vModels : t2vModels);
|
const getCurrentModels = () => v2vMode ? v2vModels : (imageMode ? allI2V : allT2V);
|
||||||
const getCurrentAspectRatios = (id) => imageMode ? getAspectRatiosForI2VModel(id) : getAspectRatiosForVideoModel(id);
|
// Local Wan2GP entries don't live in the Muapi-derived helpers, so we
|
||||||
const getCurrentDurations = (id) => imageMode ? getDurationsForI2VModel(id) : getDurationsForModel(id);
|
// resolve aspect ratios off the catalog when the selected id is local.
|
||||||
const getCurrentResolutions = (id) => imageMode ? getResolutionsForI2VModel(id) : getResolutionsForVideoModel(id);
|
const getCurrentAspectRatios = (id) => {
|
||||||
|
const local = getLocalModelById(id);
|
||||||
|
if (local) return local.aspectRatios || ['16:9', '1:1', '9:16'];
|
||||||
|
return imageMode ? getAspectRatiosForI2VModel(id) : getAspectRatiosForVideoModel(id);
|
||||||
|
};
|
||||||
|
const getCurrentDurations = (id) => {
|
||||||
|
if (getLocalModelById(id)) return [];
|
||||||
|
return imageMode ? getDurationsForI2VModel(id) : getDurationsForModel(id);
|
||||||
|
};
|
||||||
|
const getCurrentResolutions = (id) => {
|
||||||
|
if (getLocalModelById(id)) return [];
|
||||||
|
return imageMode ? getResolutionsForI2VModel(id) : getResolutionsForVideoModel(id);
|
||||||
|
};
|
||||||
const getCurrentModes = (id) => getModesForModel(id);
|
const getCurrentModes = (id) => getModesForModel(id);
|
||||||
const getCurrentModel = () => getCurrentModels().find(m => m.id === selectedModel);
|
const getCurrentModel = () => getCurrentModels().find(m => m.id === selectedModel);
|
||||||
|
const isMotionControlV2V = () => v2vMode && !!getCurrentModel()?.imageField;
|
||||||
const getQualitiesForModel = (id) => {
|
const getQualitiesForModel = (id) => {
|
||||||
const model = getCurrentModels().find(m => m.id === id);
|
const model = getCurrentModels().find(m => m.id === id);
|
||||||
return model?.inputs?.quality?.enum || [];
|
return model?.inputs?.quality?.enum || [];
|
||||||
};
|
};
|
||||||
|
const getEffectNamesForModel = (id) => {
|
||||||
|
const model = getCurrentModels().find(m => m.id === id);
|
||||||
|
return model?.inputs?.name?.enum || [];
|
||||||
|
};
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// 1. HERO SECTION
|
// 1. HERO SECTION
|
||||||
|
|
@ -81,6 +123,14 @@ export function VideoStudio() {
|
||||||
anchorContainer: container,
|
anchorContainer: container,
|
||||||
onSelect: ({ url }) => {
|
onSelect: ({ url }) => {
|
||||||
uploadedImageUrl = url;
|
uploadedImageUrl = url;
|
||||||
|
// Motion-control v2v: image is a second input alongside the video, not a mode switch
|
||||||
|
if (isMotionControlV2V()) {
|
||||||
|
textarea.disabled = false;
|
||||||
|
textarea.placeholder = uploadedVideoUrl
|
||||||
|
? (getCurrentModel()?.promptRequired ? 'Describe the motion' : 'Describe the motion (optional)')
|
||||||
|
: 'Now upload a reference video using the 🎥 button';
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Clear video mode if active
|
// Clear video mode if active
|
||||||
if (v2vMode) {
|
if (v2vMode) {
|
||||||
uploadedVideoUrl = null;
|
uploadedVideoUrl = null;
|
||||||
|
|
@ -89,8 +139,13 @@ export function VideoStudio() {
|
||||||
}
|
}
|
||||||
if (!imageMode) {
|
if (!imageMode) {
|
||||||
imageMode = true;
|
imageMode = true;
|
||||||
selectedModel = i2vModels[0].id;
|
const currentT2V = allT2V.find(m => m.id === selectedModel);
|
||||||
selectedModelName = i2vModels[0].name;
|
const sibling = currentT2V?.family
|
||||||
|
? allI2V.find(m => m.family === currentT2V.family)
|
||||||
|
: null;
|
||||||
|
const target = sibling || allI2V[0];
|
||||||
|
selectedModel = target.id;
|
||||||
|
selectedModelName = target.name;
|
||||||
document.getElementById('v-model-btn-label').textContent = selectedModelName;
|
document.getElementById('v-model-btn-label').textContent = selectedModelName;
|
||||||
updateControlsForModel(selectedModel);
|
updateControlsForModel(selectedModel);
|
||||||
}
|
}
|
||||||
|
|
@ -99,18 +154,67 @@ export function VideoStudio() {
|
||||||
},
|
},
|
||||||
onClear: () => {
|
onClear: () => {
|
||||||
uploadedImageUrl = null;
|
uploadedImageUrl = null;
|
||||||
|
// Motion-control v2v: keep the model selection; just lose the image
|
||||||
|
if (isMotionControlV2V()) return;
|
||||||
imageMode = false;
|
imageMode = false;
|
||||||
selectedModel = t2vModels[0].id;
|
// Clearing the start frame invalidates any selected end frame.
|
||||||
selectedModelName = t2vModels[0].name;
|
uploadedEndImageUrl = null;
|
||||||
|
endPicker?.reset();
|
||||||
|
selectedModel = allT2V[0].id;
|
||||||
|
selectedModelName = allT2V[0].name;
|
||||||
document.getElementById('v-model-btn-label').textContent = selectedModelName;
|
document.getElementById('v-model-btn-label').textContent = selectedModelName;
|
||||||
updateControlsForModel(selectedModel);
|
updateControlsForModel(selectedModel);
|
||||||
textarea.placeholder = 'Describe the video you want to create';
|
textarea.placeholder = 'Describe the video you want to create';
|
||||||
textarea.disabled = false;
|
textarea.disabled = false;
|
||||||
}
|
},
|
||||||
|
// Route the upload through the configured Wan2GP server when the active
|
||||||
|
// model is local; otherwise fall back to the Muapi-hosted upload.
|
||||||
|
uploadFn: (file) => isWan2gpModelId(selectedModel) ? localAI.uploadFileToWan2gp(file) : muapi.uploadFile(file),
|
||||||
|
requireApiKey: () => !isWan2gpModelId(selectedModel),
|
||||||
});
|
});
|
||||||
topRow.appendChild(picker.trigger);
|
topRow.appendChild(picker.trigger);
|
||||||
container.appendChild(picker.panel);
|
container.appendChild(picker.panel);
|
||||||
|
|
||||||
|
// --- End-Frame Upload Picker (FLF i2v models — kling/veo/seedance/etc.) ---
|
||||||
|
// Shown only when imageMode is on AND the selected i2v model declares a
|
||||||
|
// `lastImageField` in its catalog entry. Reuses the same UploadPicker UI;
|
||||||
|
// a corner badge differentiates it from the start-frame picker.
|
||||||
|
const endPicker = createUploadPicker({
|
||||||
|
anchorContainer: container,
|
||||||
|
onSelect: ({ url }) => { uploadedEndImageUrl = url; },
|
||||||
|
onClear: () => { uploadedEndImageUrl = null; },
|
||||||
|
uploadFn: (file) => muapi.uploadFile(file),
|
||||||
|
requireApiKey: () => true,
|
||||||
|
});
|
||||||
|
endPicker.trigger.title = 'End frame (optional)';
|
||||||
|
// Visual marker: small "L" badge in the corner so users can tell the two
|
||||||
|
// pickers apart at a glance. The wrapper keeps it from interfering with
|
||||||
|
// UploadPicker's own thumbnail/spinner state swapping.
|
||||||
|
const endBadge = document.createElement('div');
|
||||||
|
endBadge.className = 'absolute top-0.5 left-0.5 px-1 h-4 bg-white/20 rounded-md flex items-center justify-center pointer-events-none';
|
||||||
|
endBadge.innerHTML = '<span class="text-[8px] font-black text-white leading-none">END</span>';
|
||||||
|
endPicker.trigger.appendChild(endBadge);
|
||||||
|
endPicker.trigger.classList.add('hidden'); // start hidden until updateEndFrameVisibility flips it on
|
||||||
|
topRow.appendChild(endPicker.trigger);
|
||||||
|
container.appendChild(endPicker.panel);
|
||||||
|
|
||||||
|
const updateEndFrameVisibility = () => {
|
||||||
|
const model = getCurrentModel();
|
||||||
|
const supports = imageMode && !!model?.lastImageField;
|
||||||
|
if (supports) {
|
||||||
|
endPicker.trigger.classList.remove('hidden');
|
||||||
|
endPicker.trigger.classList.add('flex');
|
||||||
|
} else {
|
||||||
|
endPicker.trigger.classList.add('hidden');
|
||||||
|
endPicker.trigger.classList.remove('flex');
|
||||||
|
// Drop any stale end-frame selection when leaving FLF-capable state
|
||||||
|
if (uploadedEndImageUrl) {
|
||||||
|
uploadedEndImageUrl = null;
|
||||||
|
endPicker.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// --- Video Upload Picker (Video-to-Video) ---
|
// --- Video Upload Picker (Video-to-Video) ---
|
||||||
const videoFileInput = document.createElement('input');
|
const videoFileInput = document.createElement('input');
|
||||||
videoFileInput.type = 'file';
|
videoFileInput.type = 'file';
|
||||||
|
|
@ -165,10 +269,15 @@ export function VideoStudio() {
|
||||||
|
|
||||||
const clearVideoUpload = () => {
|
const clearVideoUpload = () => {
|
||||||
uploadedVideoUrl = null;
|
uploadedVideoUrl = null;
|
||||||
v2vMode = false;
|
|
||||||
showVideoIcon();
|
showVideoIcon();
|
||||||
selectedModel = t2vModels[0].id;
|
// Motion-control v2v: keep the model and image; user can re-upload a video
|
||||||
selectedModelName = t2vModels[0].name;
|
if (isMotionControlV2V()) {
|
||||||
|
textarea.placeholder = 'Upload a reference video using the 🎥 button';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
v2vMode = false;
|
||||||
|
selectedModel = allT2V[0].id;
|
||||||
|
selectedModelName = allT2V[0].name;
|
||||||
document.getElementById('v-model-btn-label').textContent = selectedModelName;
|
document.getElementById('v-model-btn-label').textContent = selectedModelName;
|
||||||
updateControlsForModel(selectedModel);
|
updateControlsForModel(selectedModel);
|
||||||
textarea.placeholder = 'Describe the video you want to create';
|
textarea.placeholder = 'Describe the video you want to create';
|
||||||
|
|
@ -200,19 +309,27 @@ export function VideoStudio() {
|
||||||
uploadedVideoUrl = url;
|
uploadedVideoUrl = url;
|
||||||
showVideoReady(file.name);
|
showVideoReady(file.name);
|
||||||
|
|
||||||
// Switch to v2v mode
|
// If a motion-control v2v model is already selected, keep it and the image upload
|
||||||
if (imageMode) {
|
if (isMotionControlV2V()) {
|
||||||
picker.reset();
|
textarea.disabled = false;
|
||||||
uploadedImageUrl = null;
|
textarea.placeholder = uploadedImageUrl
|
||||||
imageMode = false;
|
? (getCurrentModel()?.promptRequired ? 'Describe the motion' : 'Describe the motion (optional)')
|
||||||
|
: 'Now upload a reference image using the 🖼 button';
|
||||||
|
} else {
|
||||||
|
// Default v2v flow (e.g. watermark remover) — auto-pick the first v2v model
|
||||||
|
if (imageMode) {
|
||||||
|
picker.reset();
|
||||||
|
uploadedImageUrl = null;
|
||||||
|
imageMode = false;
|
||||||
|
}
|
||||||
|
v2vMode = true;
|
||||||
|
selectedModel = v2vModels[0].id;
|
||||||
|
selectedModelName = v2vModels[0].name;
|
||||||
|
document.getElementById('v-model-btn-label').textContent = selectedModelName;
|
||||||
|
updateControlsForModel(selectedModel);
|
||||||
|
textarea.placeholder = 'Video ready — click Generate to remove watermark';
|
||||||
|
textarea.disabled = true;
|
||||||
}
|
}
|
||||||
v2vMode = true;
|
|
||||||
selectedModel = v2vModels[0].id;
|
|
||||||
selectedModelName = v2vModels[0].name;
|
|
||||||
document.getElementById('v-model-btn-label').textContent = selectedModelName;
|
|
||||||
updateControlsForModel(selectedModel);
|
|
||||||
textarea.placeholder = 'Video ready — click Generate to remove watermark';
|
|
||||||
textarea.disabled = true;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[VideoStudio] Video upload failed:', err);
|
console.error('[VideoStudio] Video upload failed:', err);
|
||||||
showVideoIcon();
|
showVideoIcon();
|
||||||
|
|
@ -252,10 +369,11 @@ export function VideoStudio() {
|
||||||
const controlsLeft = document.createElement('div');
|
const controlsLeft = document.createElement('div');
|
||||||
controlsLeft.className = 'flex items-center gap-1.5 md:gap-2.5 relative overflow-x-auto no-scrollbar pb-1 md:pb-0';
|
controlsLeft.className = 'flex items-center gap-1.5 md:gap-2.5 relative overflow-x-auto no-scrollbar pb-1 md:pb-0';
|
||||||
|
|
||||||
const createControlBtn = (icon, label, id) => {
|
const createControlBtn = (icon, label, id, tooltip) => {
|
||||||
const btn = document.createElement('button');
|
const btn = document.createElement('button');
|
||||||
btn.id = id;
|
btn.id = id;
|
||||||
btn.className = 'flex items-center gap-1.5 md:gap-2.5 px-3 md:px-4 py-2 md:py-2.5 bg-white/5 hover:bg-white/10 rounded-xl md:rounded-2xl transition-all border border-white/5 group whitespace-nowrap';
|
btn.className = 'flex items-center gap-1.5 md:gap-2.5 px-3 md:px-4 py-2 md:py-2.5 bg-white/5 hover:bg-white/10 rounded-xl md:rounded-2xl transition-all border border-white/5 group whitespace-nowrap';
|
||||||
|
if (tooltip) btn.setAttribute('data-tooltip', tooltip);
|
||||||
btn.innerHTML = `
|
btn.innerHTML = `
|
||||||
${icon}
|
${icon}
|
||||||
<span id="${id}-label" class="text-xs font-bold text-white group-hover:text-primary transition-colors">${label}</span>
|
<span id="${id}-label" class="text-xs font-bold text-white group-hover:text-primary transition-colors">${label}</span>
|
||||||
|
|
@ -268,34 +386,44 @@ export function VideoStudio() {
|
||||||
<div class="w-5 h-5 bg-primary rounded-md flex items-center justify-center shadow-lg shadow-primary/20">
|
<div class="w-5 h-5 bg-primary rounded-md flex items-center justify-center shadow-lg shadow-primary/20">
|
||||||
<span class="text-[10px] font-black text-black">V</span>
|
<span class="text-[10px] font-black text-black">V</span>
|
||||||
</div>
|
</div>
|
||||||
`, selectedModelName, 'v-model-btn');
|
`, selectedModelName, 'v-model-btn', 'Select AI video model');
|
||||||
|
|
||||||
const arBtn = createControlBtn(`
|
const arBtn = createControlBtn(`
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="opacity-60 text-secondary"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="opacity-60 text-secondary"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/></svg>
|
||||||
`, selectedAr, 'v-ar-btn');
|
`, selectedAr, 'v-ar-btn', 'Change aspect ratio');
|
||||||
|
|
||||||
const durationBtn = createControlBtn(`
|
const durationBtn = createControlBtn(`
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="opacity-60 text-secondary"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="opacity-60 text-secondary"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||||
`, `${selectedDuration}s`, 'v-duration-btn');
|
`, `${selectedDuration}s`, 'v-duration-btn', 'Set video duration');
|
||||||
|
|
||||||
const resolutionBtn = createControlBtn(`
|
const resolutionBtn = createControlBtn(`
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="opacity-60 text-secondary"><path d="M6 2L3 6v15a2 2 0 002 2h14a2 2 0 002-2V6l-3-4H6z"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="opacity-60 text-secondary"><path d="M6 2L3 6v15a2 2 0 002 2h14a2 2 0 002-2V6l-3-4H6z"/></svg>
|
||||||
`, selectedResolution || '720p', 'v-resolution-btn');
|
`, selectedResolution || '720p', 'v-resolution-btn', 'Set output resolution');
|
||||||
|
|
||||||
const qualityBtn = createControlBtn(`
|
const qualityBtn = createControlBtn(`
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="opacity-60 text-secondary"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="opacity-60 text-secondary"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
|
||||||
`, selectedQuality || 'basic', 'v-quality-btn');
|
`, selectedQuality || 'basic', 'v-quality-btn', 'Set output quality');
|
||||||
|
|
||||||
const modeBtn = createControlBtn(`
|
const modeBtn = createControlBtn(`
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="opacity-60 text-secondary"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="opacity-60 text-secondary"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||||
`, selectedMode || 'normal', 'v-mode-btn');
|
`, selectedMode || 'normal', 'v-mode-btn');
|
||||||
|
|
||||||
|
const effectNameBtn = createControlBtn(`
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="opacity-60 text-secondary"><path d="M12 2l2.4 7.4H22l-6.2 4.5 2.4 7.4L12 17l-6.2 4.3 2.4-7.4L2 9.4h7.6L12 2z"/></svg>
|
||||||
|
`, 'Effect', 'v-effect-btn', 'Select effect type');
|
||||||
|
|
||||||
controlsLeft.appendChild(modelBtn);
|
controlsLeft.appendChild(modelBtn);
|
||||||
controlsLeft.appendChild(arBtn);
|
controlsLeft.appendChild(arBtn);
|
||||||
controlsLeft.appendChild(durationBtn);
|
controlsLeft.appendChild(durationBtn);
|
||||||
controlsLeft.appendChild(resolutionBtn);
|
controlsLeft.appendChild(resolutionBtn);
|
||||||
controlsLeft.appendChild(qualityBtn);
|
controlsLeft.appendChild(qualityBtn);
|
||||||
controlsLeft.appendChild(modeBtn);
|
controlsLeft.appendChild(effectNameBtn);
|
||||||
|
|
||||||
|
// Advanced options toggle button
|
||||||
|
const advancedBtn = createControlBtn(`
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="opacity-60 text-secondary"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06a1.65 1.65 0 001.82-.33 1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06a1.65 1.65 0 00-1.82.33A1.65 1.65 0 0019.4 9a1.65 1.65 0 00-1.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
|
||||||
|
`, 'Advanced', 'v-advanced-btn', 'Show advanced options');
|
||||||
|
controlsLeft.appendChild(advancedBtn);
|
||||||
|
|
||||||
// Initial visibility (t2v mode)
|
// Initial visibility (t2v mode)
|
||||||
const initDurations = getDurationsForModel(defaultModel.id);
|
const initDurations = getDurationsForModel(defaultModel.id);
|
||||||
|
|
@ -304,9 +432,11 @@ export function VideoStudio() {
|
||||||
resolutionBtn.style.display = initResolutions.length > 0 ? 'flex' : 'none';
|
resolutionBtn.style.display = initResolutions.length > 0 ? 'flex' : 'none';
|
||||||
qualityBtn.style.display = 'none';
|
qualityBtn.style.display = 'none';
|
||||||
modeBtn.style.display = getModesForModel(defaultModel.id).length > 0 ? 'flex' : 'none';
|
modeBtn.style.display = getModesForModel(defaultModel.id).length > 0 ? 'flex' : 'none';
|
||||||
|
effectNameBtn.style.display = 'none';
|
||||||
|
|
||||||
const generateBtn = document.createElement('button');
|
const generateBtn = document.createElement('button');
|
||||||
generateBtn.className = 'bg-primary text-black px-6 md:px-8 py-3 md:py-3.5 rounded-xl md:rounded-[1.5rem] font-black text-sm md:text-base hover:shadow-glow hover:scale-105 active:scale-95 transition-all flex items-center justify-center gap-2.5 w-full sm:w-auto shadow-lg';
|
generateBtn.className = 'bg-primary text-black px-6 md:px-8 py-3 md:py-3.5 rounded-xl md:rounded-[1.5rem] font-black text-sm md:text-base hover:shadow-glow hover:scale-105 active:scale-95 transition-all flex items-center justify-center gap-2.5 w-full sm:w-auto shadow-lg';
|
||||||
|
generateBtn.setAttribute('data-tooltip', 'Generate AI video from prompt');
|
||||||
generateBtn.innerHTML = `Generate ✨`;
|
generateBtn.innerHTML = `Generate ✨`;
|
||||||
|
|
||||||
bottomRow.appendChild(controlsLeft);
|
bottomRow.appendChild(controlsLeft);
|
||||||
|
|
@ -324,6 +454,9 @@ export function VideoStudio() {
|
||||||
const updateControlsForModel = (modelId) => {
|
const updateControlsForModel = (modelId) => {
|
||||||
const model = getCurrentModels().find(m => m.id === modelId);
|
const model = getCurrentModels().find(m => m.id === modelId);
|
||||||
|
|
||||||
|
// End-frame picker visibility depends on imageMode + model.lastImageField.
|
||||||
|
updateEndFrameVisibility();
|
||||||
|
|
||||||
// In v2v mode, hide all parameter controls — no prompt/AR/duration/etc needed
|
// In v2v mode, hide all parameter controls — no prompt/AR/duration/etc needed
|
||||||
if (v2vMode) {
|
if (v2vMode) {
|
||||||
arBtn.style.display = 'none';
|
arBtn.style.display = 'none';
|
||||||
|
|
@ -331,6 +464,7 @@ export function VideoStudio() {
|
||||||
resolutionBtn.style.display = 'none';
|
resolutionBtn.style.display = 'none';
|
||||||
qualityBtn.style.display = 'none';
|
qualityBtn.style.display = 'none';
|
||||||
modeBtn.style.display = 'none';
|
modeBtn.style.display = 'none';
|
||||||
|
effectNameBtn.style.display = 'none';
|
||||||
extendBanner.classList.add('hidden');
|
extendBanner.classList.add('hidden');
|
||||||
extendBanner.classList.remove('flex');
|
extendBanner.classList.remove('flex');
|
||||||
return;
|
return;
|
||||||
|
|
@ -388,6 +522,17 @@ export function VideoStudio() {
|
||||||
modeBtn.style.display = 'none';
|
modeBtn.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Effect name (ai-video-effects / motion-controls)
|
||||||
|
const effectNames = getEffectNamesForModel(modelId);
|
||||||
|
if (effectNames.length > 0) {
|
||||||
|
selectedEffectName = model?.inputs?.name?.default || effectNames[0];
|
||||||
|
document.getElementById('v-effect-btn-label').textContent = selectedEffectName;
|
||||||
|
effectNameBtn.style.display = 'flex';
|
||||||
|
} else {
|
||||||
|
selectedEffectName = '';
|
||||||
|
effectNameBtn.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
// Extend banner (extend model only)
|
// Extend banner (extend model only)
|
||||||
if (model?.requiresRequestId) {
|
if (model?.requiresRequestId) {
|
||||||
extendBanner.classList.remove('hidden');
|
extendBanner.classList.remove('hidden');
|
||||||
|
|
@ -429,7 +574,7 @@ export function VideoStudio() {
|
||||||
<div class="w-10 h-10 ${iconColor} border border-white/5 rounded-xl flex items-center justify-center font-black text-sm shadow-inner uppercase">${m.name.charAt(0)}</div>
|
<div class="w-10 h-10 ${iconColor} border border-white/5 rounded-xl flex items-center justify-center font-black text-sm shadow-inner uppercase">${m.name.charAt(0)}</div>
|
||||||
<div class="flex flex-col gap-0.5">
|
<div class="flex flex-col gap-0.5">
|
||||||
<span class="text-xs font-bold text-white tracking-tight">${m.name}</span>
|
<span class="text-xs font-bold text-white tracking-tight">${m.name}</span>
|
||||||
${isV2V ? '<span class="text-[9px] text-orange-400/70">Upload a video to use</span>' : ''}
|
${isV2V ? `<span class="text-[9px] text-orange-400/70">${m.imageField ? 'Upload a video and image' : 'Upload a video to use'}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${selectedModel === m.id ? '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#d9ff00" stroke-width="4"><polyline points="20 6 9 17 4 12"/></svg>' : ''}
|
${selectedModel === m.id ? '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#d9ff00" stroke-width="4"><polyline points="20 6 9 17 4 12"/></svg>' : ''}
|
||||||
|
|
@ -440,14 +585,25 @@ export function VideoStudio() {
|
||||||
// Switch to v2v mode
|
// Switch to v2v mode
|
||||||
v2vMode = true;
|
v2vMode = true;
|
||||||
imageMode = false;
|
imageMode = false;
|
||||||
picker.reset();
|
const isMC = !!m.imageField;
|
||||||
uploadedImageUrl = null;
|
if (!isMC) {
|
||||||
|
// Single-input v2v (watermark remover etc.) — drop any image
|
||||||
|
picker.reset();
|
||||||
|
uploadedImageUrl = null;
|
||||||
|
}
|
||||||
selectedModel = m.id;
|
selectedModel = m.id;
|
||||||
selectedModelName = m.name;
|
selectedModelName = m.name;
|
||||||
document.getElementById('v-model-btn-label').textContent = selectedModelName;
|
document.getElementById('v-model-btn-label').textContent = selectedModelName;
|
||||||
updateControlsForModel(selectedModel);
|
updateControlsForModel(selectedModel);
|
||||||
textarea.placeholder = 'Upload a video using the 🎥 button, then click Generate';
|
if (isMC) {
|
||||||
textarea.disabled = true;
|
textarea.placeholder = m.promptRequired
|
||||||
|
? 'Upload a reference video and image, then describe the motion'
|
||||||
|
: 'Upload a reference video and image, then describe the motion (optional)';
|
||||||
|
textarea.disabled = false;
|
||||||
|
} else {
|
||||||
|
textarea.placeholder = 'Upload a video using the 🎥 button, then click Generate';
|
||||||
|
textarea.disabled = true;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Leaving v2v mode if was in it
|
// Leaving v2v mode if was in it
|
||||||
if (v2vMode) {
|
if (v2vMode) {
|
||||||
|
|
@ -472,7 +628,7 @@ export function VideoStudio() {
|
||||||
const lf = filter.toLowerCase();
|
const lf = filter.toLowerCase();
|
||||||
|
|
||||||
// Regular generation models (always t2v or i2v, never v2v)
|
// Regular generation models (always t2v or i2v, never v2v)
|
||||||
const generationModels = imageMode ? i2vModels : t2vModels;
|
const generationModels = imageMode ? allI2V : allT2V;
|
||||||
const filteredMain = generationModels
|
const filteredMain = generationModels
|
||||||
.filter(m => m.name.toLowerCase().includes(lf) || m.id.toLowerCase().includes(lf));
|
.filter(m => m.name.toLowerCase().includes(lf) || m.id.toLowerCase().includes(lf));
|
||||||
filteredMain.forEach(m => list.appendChild(makeModelItem(m, false)));
|
filteredMain.forEach(m => list.appendChild(makeModelItem(m, false)));
|
||||||
|
|
@ -610,6 +766,29 @@ export function VideoStudio() {
|
||||||
list.appendChild(item);
|
list.appendChild(item);
|
||||||
});
|
});
|
||||||
dropdown.appendChild(list);
|
dropdown.appendChild(list);
|
||||||
|
|
||||||
|
} else if (type === 'effect') {
|
||||||
|
dropdown.classList.add('max-w-[240px]');
|
||||||
|
dropdown.classList.remove('max-w-[200px]');
|
||||||
|
dropdown.innerHTML = `<div class="text-[10px] font-bold text-secondary uppercase tracking-widest px-3 py-2 border-b border-white/5 mb-2">Effect Type</div>`;
|
||||||
|
const list = document.createElement('div');
|
||||||
|
list.className = 'flex flex-col gap-1 max-h-[50vh] overflow-y-auto custom-scrollbar';
|
||||||
|
getEffectNamesForModel(selectedModel).forEach(e => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'flex items-center justify-between p-3 hover:bg-white/5 rounded-2xl cursor-pointer transition-all group';
|
||||||
|
item.innerHTML = `
|
||||||
|
<span class="text-xs font-bold text-white opacity-80 group-hover:opacity-100">${e}</span>
|
||||||
|
${selectedEffectName === e ? '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#d9ff00" stroke-width="4"><polyline points="20 6 9 17 4 12"/></svg>' : ''}
|
||||||
|
`;
|
||||||
|
item.onclick = (ev) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
selectedEffectName = e;
|
||||||
|
document.getElementById('v-effect-btn-label').textContent = e;
|
||||||
|
closeDropdown();
|
||||||
|
};
|
||||||
|
list.appendChild(item);
|
||||||
|
});
|
||||||
|
dropdown.appendChild(list);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Position dropdown
|
// Position dropdown
|
||||||
|
|
@ -643,6 +822,7 @@ export function VideoStudio() {
|
||||||
resolutionBtn.onclick = toggleDropdown('resolution', resolutionBtn);
|
resolutionBtn.onclick = toggleDropdown('resolution', resolutionBtn);
|
||||||
qualityBtn.onclick = toggleDropdown('quality', qualityBtn);
|
qualityBtn.onclick = toggleDropdown('quality', qualityBtn);
|
||||||
modeBtn.onclick = toggleDropdown('mode', modeBtn);
|
modeBtn.onclick = toggleDropdown('mode', modeBtn);
|
||||||
|
effectNameBtn.onclick = toggleDropdown('effect', effectNameBtn);
|
||||||
|
|
||||||
window.addEventListener('click', closeDropdown);
|
window.addEventListener('click', closeDropdown);
|
||||||
container.appendChild(dropdown);
|
container.appendChild(dropdown);
|
||||||
|
|
@ -872,8 +1052,8 @@ export function VideoStudio() {
|
||||||
uploadedVideoUrl = null;
|
uploadedVideoUrl = null;
|
||||||
v2vMode = false;
|
v2vMode = false;
|
||||||
showVideoIcon();
|
showVideoIcon();
|
||||||
selectedModel = t2vModels[0].id;
|
selectedModel = allT2V[0].id;
|
||||||
selectedModelName = t2vModels[0].name;
|
selectedModelName = allT2V[0].name;
|
||||||
document.getElementById('v-model-btn-label').textContent = selectedModelName;
|
document.getElementById('v-model-btn-label').textContent = selectedModelName;
|
||||||
updateControlsForModel(selectedModel);
|
updateControlsForModel(selectedModel);
|
||||||
textarea.placeholder = 'Describe the video you want to create';
|
textarea.placeholder = 'Describe the video you want to create';
|
||||||
|
|
@ -909,6 +1089,14 @@ export function VideoStudio() {
|
||||||
alert('Please upload a video first.');
|
alert('Please upload a video first.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (model?.imageField && !uploadedImageUrl) {
|
||||||
|
alert('Please upload a reference image for motion control.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (model?.promptRequired && !prompt) {
|
||||||
|
alert('Please describe the motion you want.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
} else if (isExtendMode) {
|
} else if (isExtendMode) {
|
||||||
if (!lastGenerationId) {
|
if (!lastGenerationId) {
|
||||||
alert('No Seedance 2.0 generation found to extend. Generate a video first.');
|
alert('No Seedance 2.0 generation found to extend. Generate a video first.');
|
||||||
|
|
@ -926,16 +1114,30 @@ export function VideoStudio() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiKey = localStorage.getItem('muapi_key');
|
const isLocal = isWan2gpModelId(selectedModel);
|
||||||
if (!apiKey) {
|
|
||||||
AuthModal(() => generateBtn.click());
|
// Local Wan2GP generations don't go through Muapi — skip the auth gate.
|
||||||
return;
|
if (!isLocal) {
|
||||||
|
const apiKey = localStorage.getItem('muapi_key');
|
||||||
|
if (!apiKey) {
|
||||||
|
AuthModal(() => generateBtn.click());
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
hero.classList.add('opacity-0', 'scale-95', '-translate-y-10', 'pointer-events-none');
|
hero.classList.add('opacity-0', 'scale-95', '-translate-y-10', 'pointer-events-none');
|
||||||
generateBtn.disabled = true;
|
generateBtn.disabled = true;
|
||||||
generateBtn.innerHTML = `<span class="animate-spin inline-block mr-2 text-black">◌</span> Generating...`;
|
generateBtn.innerHTML = `<span class="animate-spin inline-block mr-2 text-black">◌</span> Generating...`;
|
||||||
|
|
||||||
|
// For local generations, surface step progress in the button label.
|
||||||
|
let unsubscribeProgress = null;
|
||||||
|
if (isLocal) {
|
||||||
|
unsubscribeProgress = localAI.onProgress(({ status, progress }) => {
|
||||||
|
const pct = typeof progress === 'number' ? Math.round(progress * 100) : null;
|
||||||
|
generateBtn.innerHTML = `<span class="animate-spin inline-block mr-2 text-black">◌</span> ${status || 'Generating'}${pct != null ? ` ${pct}%` : '…'}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let hadError = false;
|
let hadError = false;
|
||||||
let capturedRequestId = null;
|
let capturedRequestId = null;
|
||||||
const historyMeta = { prompt, model: selectedModel, aspect_ratio: selectedAr, duration: selectedDuration };
|
const historyMeta = { prompt, model: selectedModel, aspect_ratio: selectedAr, duration: selectedDuration };
|
||||||
|
|
@ -946,15 +1148,44 @@ export function VideoStudio() {
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// ─── Local Wan2GP path ───────────────────────────────────────────
|
||||||
|
// Uploaded image URLs were minted by uploadFileToWan2gp(), so
|
||||||
|
// wan2gpProvider can rehydrate the Gradio file descriptor.
|
||||||
|
if (isLocal) {
|
||||||
|
const localParams = {
|
||||||
|
model: selectedModel,
|
||||||
|
prompt: prompt || '',
|
||||||
|
aspect_ratio: selectedAr,
|
||||||
|
};
|
||||||
|
if (imageMode && uploadedImageUrl) localParams.image = uploadedImageUrl;
|
||||||
|
const res = await localAI.generate(localParams);
|
||||||
|
console.log('[VideoStudio] Local response:', res);
|
||||||
|
if (res && res.url) {
|
||||||
|
const genId = Date.now().toString();
|
||||||
|
lastGenerationId = null;
|
||||||
|
lastGenerationModel = null;
|
||||||
|
addToHistory({ id: genId, url: res.url, prompt, model: selectedModel, aspect_ratio: selectedAr, timestamp: new Date().toISOString() });
|
||||||
|
showVideoInCanvas(res.url, selectedModel);
|
||||||
|
} else {
|
||||||
|
throw new Error('No video URL returned by Wan2GP');
|
||||||
|
}
|
||||||
|
generateBtn.disabled = false;
|
||||||
|
generateBtn.innerHTML = `Generate ✨`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (v2vMode) {
|
if (v2vMode) {
|
||||||
const res = await muapi.processV2V({ model: selectedModel, video_url: uploadedVideoUrl, onRequestId });
|
const v2vParams = { model: selectedModel, video_url: uploadedVideoUrl, onRequestId };
|
||||||
|
if (model?.imageField && uploadedImageUrl) v2vParams.image_url = uploadedImageUrl;
|
||||||
|
if (model?.hasPrompt && prompt) v2vParams.prompt = prompt;
|
||||||
|
const res = await muapi.processV2V(v2vParams);
|
||||||
console.log('[VideoStudio] V2V response:', res);
|
console.log('[VideoStudio] V2V response:', res);
|
||||||
if (res && res.url) {
|
if (res && res.url) {
|
||||||
if (capturedRequestId) removePendingJob(capturedRequestId);
|
if (capturedRequestId) removePendingJob(capturedRequestId);
|
||||||
const genId = res.id || capturedRequestId || Date.now().toString();
|
const genId = res.id || capturedRequestId || Date.now().toString();
|
||||||
lastGenerationId = null;
|
lastGenerationId = null;
|
||||||
lastGenerationModel = null;
|
lastGenerationModel = null;
|
||||||
addToHistory({ id: genId, url: res.url, prompt: '', model: selectedModel, timestamp: new Date().toISOString() });
|
addToHistory({ id: genId, url: res.url, prompt: model?.hasPrompt ? prompt : '', model: selectedModel, timestamp: new Date().toISOString() });
|
||||||
showVideoInCanvas(res.url, selectedModel);
|
showVideoInCanvas(res.url, selectedModel);
|
||||||
} else {
|
} else {
|
||||||
throw new Error('No video URL returned by API');
|
throw new Error('No video URL returned by API');
|
||||||
|
|
@ -970,14 +1201,18 @@ export function VideoStudio() {
|
||||||
image_url: uploadedImageUrl,
|
image_url: uploadedImageUrl,
|
||||||
onRequestId,
|
onRequestId,
|
||||||
};
|
};
|
||||||
if (prompt) i2vParams.prompt = prompt;
|
i2vParams.prompt = prompt || '';
|
||||||
i2vParams.aspect_ratio = selectedAr;
|
i2vParams.aspect_ratio = selectedAr;
|
||||||
|
if (uploadedEndImageUrl && getCurrentModel()?.lastImageField) {
|
||||||
|
i2vParams.last_image = uploadedEndImageUrl;
|
||||||
|
}
|
||||||
const durations = getCurrentDurations(selectedModel);
|
const durations = getCurrentDurations(selectedModel);
|
||||||
if (durations.length > 0) i2vParams.duration = selectedDuration;
|
if (durations.length > 0) i2vParams.duration = selectedDuration;
|
||||||
const resolutions = getCurrentResolutions(selectedModel);
|
const resolutions = getCurrentResolutions(selectedModel);
|
||||||
if (resolutions.length > 0) i2vParams.resolution = selectedResolution;
|
if (resolutions.length > 0) i2vParams.resolution = selectedResolution;
|
||||||
if (selectedQuality) i2vParams.quality = selectedQuality;
|
if (selectedQuality) i2vParams.quality = selectedQuality;
|
||||||
if (selectedMode) i2vParams.mode = selectedMode;
|
if (selectedMode) i2vParams.mode = selectedMode;
|
||||||
|
if (selectedEffectName) i2vParams.name = selectedEffectName;
|
||||||
|
|
||||||
const res = await muapi.generateI2V(i2vParams);
|
const res = await muapi.generateI2V(i2vParams);
|
||||||
console.log('[VideoStudio] I2V response:', res);
|
console.log('[VideoStudio] I2V response:', res);
|
||||||
|
|
@ -1064,6 +1299,7 @@ export function VideoStudio() {
|
||||||
}, 4000);
|
}, 4000);
|
||||||
} finally {
|
} finally {
|
||||||
generateBtn.disabled = false;
|
generateBtn.disabled = false;
|
||||||
|
if (typeof unsubscribeProgress === 'function') unsubscribeProgress();
|
||||||
// Only reset the label on success; the catch timeout handles the error case
|
// Only reset the label on success; the catch timeout handles the error case
|
||||||
if (!hadError) generateBtn.innerHTML = `Generate ✨`;
|
if (!hadError) generateBtn.innerHTML = `Generate ✨`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
24
src/components/WorkflowStudio.js
Normal file
24
src/components/WorkflowStudio.js
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
export function WorkflowStudio() {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.className = 'w-full h-full flex flex-col items-center justify-center bg-app-bg text-white gap-4';
|
||||||
|
|
||||||
|
const icon = document.createElement('div');
|
||||||
|
icon.innerHTML = `<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" opacity="0.4">
|
||||||
|
<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/>
|
||||||
|
<rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/>
|
||||||
|
<path d="M6.5 10v4M17.5 10v4M10 6.5h4M10 17.5h4"/>
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
const title = document.createElement('p');
|
||||||
|
title.textContent = 'Workflow Studio';
|
||||||
|
title.className = 'text-lg font-bold opacity-60';
|
||||||
|
|
||||||
|
const sub = document.createElement('p');
|
||||||
|
sub.textContent = 'Available in the web app at open-generative-ai.com';
|
||||||
|
sub.className = 'text-sm opacity-40';
|
||||||
|
|
||||||
|
container.appendChild(icon);
|
||||||
|
container.appendChild(title);
|
||||||
|
container.appendChild(sub);
|
||||||
|
return container;
|
||||||
|
}
|
||||||
107
src/lib/localInferenceClient.js
Normal file
107
src/lib/localInferenceClient.js
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
// Frontend client for local inference — wraps window.localAI (Electron IPC).
|
||||||
|
// Two providers live behind the same surface:
|
||||||
|
// - sd.cpp: bundled engine, downloads weights to disk, runs locally
|
||||||
|
// - wan2gp: user-run Gradio server, generation is remote HTTP
|
||||||
|
// Provider is read off the model entry's `provider` field.
|
||||||
|
|
||||||
|
import { getLocalModelById } from './localModels.js';
|
||||||
|
|
||||||
|
export const isLocalAIAvailable = () => typeof window !== 'undefined' && !!window.localAI?.isElectron;
|
||||||
|
|
||||||
|
class LocalInferenceClient {
|
||||||
|
// ── sd.cpp APIs ───────────────────────────────────────────────────────
|
||||||
|
async getBinaryStatus() {
|
||||||
|
if (!isLocalAIAvailable()) return { exists: false };
|
||||||
|
return window.localAI.getBinaryStatus();
|
||||||
|
}
|
||||||
|
async downloadBinary() {
|
||||||
|
if (!isLocalAIAvailable()) throw new Error('Local AI only available in the desktop app.');
|
||||||
|
return window.localAI.downloadBinary();
|
||||||
|
}
|
||||||
|
async downloadModel(modelId) {
|
||||||
|
if (!isLocalAIAvailable()) throw new Error('Local AI only available in the desktop app.');
|
||||||
|
return window.localAI.downloadModel(modelId);
|
||||||
|
}
|
||||||
|
async downloadAuxiliary(auxKey) {
|
||||||
|
if (!isLocalAIAvailable()) throw new Error('Local AI only available in the desktop app.');
|
||||||
|
return window.localAI.downloadAuxiliary(auxKey);
|
||||||
|
}
|
||||||
|
async deleteModel(modelId) {
|
||||||
|
if (!isLocalAIAvailable()) throw new Error('Local AI only available in the desktop app.');
|
||||||
|
return window.localAI.deleteModel(modelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Wan2GP APIs ───────────────────────────────────────────────────────
|
||||||
|
async getWan2gpConfig() {
|
||||||
|
if (!isLocalAIAvailable()) return { url: '' };
|
||||||
|
return window.localAI.wan2gp.getConfig();
|
||||||
|
}
|
||||||
|
async setWan2gpUrl(url) {
|
||||||
|
if (!isLocalAIAvailable()) throw new Error('Local AI only available in the desktop app.');
|
||||||
|
return window.localAI.wan2gp.setUrl(url);
|
||||||
|
}
|
||||||
|
async probeWan2gp(url) {
|
||||||
|
if (!isLocalAIAvailable()) return { ok: false, error: 'Not in desktop app' };
|
||||||
|
return window.localAI.wan2gp.probe(url);
|
||||||
|
}
|
||||||
|
// Pushes a File/Blob to the configured Wan2GP server's /upload endpoint
|
||||||
|
// and returns { url, path }. URL is a previewable HTTP link; the provider
|
||||||
|
// also remembers the path so a subsequent generate(params.image=url) call
|
||||||
|
// can rehydrate it as a Gradio file descriptor.
|
||||||
|
async uploadFileToWan2gp(file) {
|
||||||
|
if (!isLocalAIAvailable()) throw new Error('Local AI only available in the desktop app.');
|
||||||
|
const buf = await file.arrayBuffer();
|
||||||
|
return window.localAI.wan2gp.uploadFile({
|
||||||
|
name: file.name,
|
||||||
|
type: file.type,
|
||||||
|
bytes: new Uint8Array(buf),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Unified model list (both providers merged) ────────────────────────
|
||||||
|
async listModels() {
|
||||||
|
if (!isLocalAIAvailable()) return [];
|
||||||
|
const [sdcpp, wan2gp] = await Promise.all([
|
||||||
|
window.localAI.listModels(),
|
||||||
|
window.localAI.wan2gp.listModels().catch(() => []),
|
||||||
|
]);
|
||||||
|
return [
|
||||||
|
...sdcpp.map(m => ({ ...m, provider: m.provider || 'sdcpp' })),
|
||||||
|
...wan2gp,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Provider-aware generate ───────────────────────────────────────────
|
||||||
|
async generate(params) {
|
||||||
|
if (!isLocalAIAvailable()) throw new Error('Local AI only available in the desktop app.');
|
||||||
|
const model = getLocalModelById(params.model);
|
||||||
|
if (model?.provider === 'wan2gp') {
|
||||||
|
return window.localAI.wan2gp.generate(params);
|
||||||
|
}
|
||||||
|
return window.localAI.generate(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelGeneration() {
|
||||||
|
if (!isLocalAIAvailable()) return;
|
||||||
|
// Ask both — only the running one reacts.
|
||||||
|
window.localAI.cancelGeneration();
|
||||||
|
window.localAI.wan2gp.cancelGeneration();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to generation progress events.
|
||||||
|
* sd.cpp emits { step, totalSteps, progress, status }.
|
||||||
|
* Wan2GP emits { progress, status }.
|
||||||
|
*/
|
||||||
|
onProgress(callback) {
|
||||||
|
if (!isLocalAIAvailable()) return () => {};
|
||||||
|
return window.localAI.onProgress(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
onDownloadProgress(callback) {
|
||||||
|
if (!isLocalAIAvailable()) return () => {};
|
||||||
|
return window.localAI.onDownloadProgress(callback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const localAI = new LocalInferenceClient();
|
||||||
176
src/lib/localModels.js
Normal file
176
src/lib/localModels.js
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
// Frontend-side local model catalog.
|
||||||
|
// Two providers:
|
||||||
|
// - sdcpp: bundled engine, weights live on disk
|
||||||
|
// - wan2gp: user-run remote Gradio server
|
||||||
|
// Mirrors electron/lib/modelCatalog.js (sd.cpp) and electron/lib/wan2gpProvider.js (wan2gp).
|
||||||
|
export const LOCAL_MODEL_CATALOG = [
|
||||||
|
// ── sd.cpp: Z-Image (Tongyi-MAI) ────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'z-image-turbo',
|
||||||
|
name: 'Z-Image Turbo',
|
||||||
|
description: 'WaveSpeed\'s featured local model — 6B params, ultra-fast 8-step generation. No API key needed.',
|
||||||
|
type: 'z-image',
|
||||||
|
provider: 'sdcpp',
|
||||||
|
filename: 'z_image_turbo-Q4_K.gguf',
|
||||||
|
sizeGB: 3.4,
|
||||||
|
aspectRatios: ['1:1', '4:3', '3:4', '16:9', '9:16'],
|
||||||
|
defaultSteps: 8,
|
||||||
|
defaultGuidance: 1.0,
|
||||||
|
tags: ['turbo', 'fast', 'local', 'featured'],
|
||||||
|
featured: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'z-image-base',
|
||||||
|
name: 'Z-Image Base',
|
||||||
|
description: 'Full-quality 6B parameter model from Tongyi-MAI — higher detail, 50-step generation.',
|
||||||
|
type: 'z-image',
|
||||||
|
provider: 'sdcpp',
|
||||||
|
filename: 'Z-Image-Q4_K_M.gguf',
|
||||||
|
sizeGB: 3.5,
|
||||||
|
aspectRatios: ['1:1', '4:3', '3:4', '16:9', '9:16'],
|
||||||
|
defaultSteps: 50,
|
||||||
|
defaultGuidance: 7.5,
|
||||||
|
tags: ['high-quality', 'local', 'detailed'],
|
||||||
|
featured: true,
|
||||||
|
},
|
||||||
|
// ── sd.cpp: SD 1.5 (small, M2-friendly) ─────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'dreamshaper-8',
|
||||||
|
name: 'Dreamshaper 8',
|
||||||
|
description: 'Versatile SD 1.5 model — great for portraits, landscapes, and artistic styles.',
|
||||||
|
type: 'sd1',
|
||||||
|
provider: 'sdcpp',
|
||||||
|
filename: 'DreamShaper_8_pruned.safetensors',
|
||||||
|
sizeGB: 2.1,
|
||||||
|
aspectRatios: ['1:1', '4:3', '3:4', '16:9', '9:16'],
|
||||||
|
defaultSteps: 20,
|
||||||
|
defaultGuidance: 7.5,
|
||||||
|
tags: ['photorealistic', 'artistic', 'versatile'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'realistic-vision-v51',
|
||||||
|
name: 'Realistic Vision v5.1',
|
||||||
|
description: 'Highly photorealistic people and scenes, based on SD 1.5.',
|
||||||
|
type: 'sd1',
|
||||||
|
provider: 'sdcpp',
|
||||||
|
filename: 'realisticVisionV51_v51VAE.safetensors',
|
||||||
|
sizeGB: 2.1,
|
||||||
|
aspectRatios: ['1:1', '4:3', '3:4', '16:9', '9:16'],
|
||||||
|
defaultSteps: 25,
|
||||||
|
defaultGuidance: 7,
|
||||||
|
tags: ['photorealistic', 'portraits', 'people'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'anything-v5',
|
||||||
|
name: 'Anything v5',
|
||||||
|
description: 'High quality anime and illustration style image generation.',
|
||||||
|
type: 'sd1',
|
||||||
|
provider: 'sdcpp',
|
||||||
|
filename: 'Anything-v5.0-PRT.safetensors',
|
||||||
|
sizeGB: 2.1,
|
||||||
|
aspectRatios: ['1:1', '4:3', '3:4', '16:9', '9:16'],
|
||||||
|
defaultSteps: 20,
|
||||||
|
defaultGuidance: 7,
|
||||||
|
tags: ['anime', 'illustration', 'artistic'],
|
||||||
|
},
|
||||||
|
// ── sd.cpp: SDXL ────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'stable-diffusion-xl-base',
|
||||||
|
name: 'SDXL Base 1.0',
|
||||||
|
description: 'Official Stable Diffusion XL base model — higher resolution, excellent quality.',
|
||||||
|
type: 'sdxl',
|
||||||
|
provider: 'sdcpp',
|
||||||
|
filename: 'sd_xl_base_1.0.safetensors',
|
||||||
|
sizeGB: 6.9,
|
||||||
|
aspectRatios: ['1:1', '4:3', '3:4', '16:9', '9:16'],
|
||||||
|
defaultSteps: 30,
|
||||||
|
defaultGuidance: 7.5,
|
||||||
|
tags: ['sdxl', 'high-quality', 'versatile'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Wan2GP: image models ────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'wan2gp:flux-dev',
|
||||||
|
name: 'Flux.1 Dev (Wan2GP)',
|
||||||
|
description: 'Image — FLUX.1 dev served by Wan2GP. Requires running Wan2GP server.',
|
||||||
|
type: 'image',
|
||||||
|
family: 'flux',
|
||||||
|
provider: 'wan2gp',
|
||||||
|
aspectRatios: ['1:1', '4:3', '3:4', '16:9', '9:16'],
|
||||||
|
defaultSteps: 28,
|
||||||
|
defaultGuidance: 3.5,
|
||||||
|
tags: ['image', 'flux', 'remote'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'wan2gp:qwen-image',
|
||||||
|
name: 'Qwen Image (Wan2GP)',
|
||||||
|
description: 'Image — Qwen-Image text-to-image served by Wan2GP.',
|
||||||
|
type: 'image',
|
||||||
|
family: 'qwen',
|
||||||
|
provider: 'wan2gp',
|
||||||
|
aspectRatios: ['1:1', '4:3', '3:4', '16:9', '9:16'],
|
||||||
|
defaultSteps: 30,
|
||||||
|
defaultGuidance: 4.0,
|
||||||
|
tags: ['image', 'qwen', 'remote'],
|
||||||
|
},
|
||||||
|
// ── Wan2GP: video models ────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'wan2gp:wan22-t2v',
|
||||||
|
name: 'Wan 2.2 (Text-to-Video)',
|
||||||
|
description: 'Video — Wan 2.2 text-to-video. Slow on consumer GPUs.',
|
||||||
|
type: 'video',
|
||||||
|
family: 'wan',
|
||||||
|
provider: 'wan2gp',
|
||||||
|
aspectRatios: ['16:9', '1:1', '9:16'],
|
||||||
|
defaultSteps: 25,
|
||||||
|
defaultGuidance: 5.0,
|
||||||
|
tags: ['video', 'wan', 'text-to-video'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'wan2gp:wan22-i2v',
|
||||||
|
name: 'Wan 2.2 (Image-to-Video)',
|
||||||
|
description: 'Video — Wan 2.2 image-to-video. Provide a start frame.',
|
||||||
|
type: 'video',
|
||||||
|
family: 'wan',
|
||||||
|
provider: 'wan2gp',
|
||||||
|
needsImage: true,
|
||||||
|
aspectRatios: ['16:9', '1:1', '9:16'],
|
||||||
|
defaultSteps: 25,
|
||||||
|
defaultGuidance: 5.0,
|
||||||
|
tags: ['video', 'wan', 'image-to-video'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'wan2gp:hunyuan-video',
|
||||||
|
name: 'Hunyuan Video (Wan2GP)',
|
||||||
|
description: 'Video — Hunyuan text-to-video via Wan2GP.',
|
||||||
|
type: 'video',
|
||||||
|
family: 'hunyuan',
|
||||||
|
provider: 'wan2gp',
|
||||||
|
aspectRatios: ['16:9', '1:1', '9:16'],
|
||||||
|
defaultSteps: 30,
|
||||||
|
defaultGuidance: 6.0,
|
||||||
|
tags: ['video', 'hunyuan'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'wan2gp:ltx-video',
|
||||||
|
name: 'LTX Video (Wan2GP)',
|
||||||
|
description: 'Video — LTX text-to-video. Fastest video option in Wan2GP.',
|
||||||
|
type: 'video',
|
||||||
|
family: 'ltx',
|
||||||
|
provider: 'wan2gp',
|
||||||
|
aspectRatios: ['16:9', '1:1', '9:16'],
|
||||||
|
defaultSteps: 20,
|
||||||
|
defaultGuidance: 3.0,
|
||||||
|
tags: ['video', 'ltx', 'fast'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getLocalModelById(id) {
|
||||||
|
return LOCAL_MODEL_CATALOG.find(m => m.id === id) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isWan2gpModelId = (id) => getLocalModelById(id)?.provider === 'wan2gp';
|
||||||
|
export const isLocalModelId = (id) => !!getLocalModelById(id);
|
||||||
|
|
||||||
|
export const localT2VModels = LOCAL_MODEL_CATALOG.filter(m => m.provider === 'wan2gp' && m.type === 'video' && !m.needsImage);
|
||||||
|
export const localI2VModels = LOCAL_MODEL_CATALOG.filter(m => m.provider === 'wan2gp' && m.type === 'video' && m.needsImage);
|
||||||
8176
src/lib/models.js
8176
src/lib/models.js
File diff suppressed because it is too large
Load diff
|
|
@ -245,7 +245,7 @@ export class MuapiClient {
|
||||||
const finalPayload = {};
|
const finalPayload = {};
|
||||||
|
|
||||||
// Only include prompt if the model supports it and one was provided
|
// Only include prompt if the model supports it and one was provided
|
||||||
if (params.prompt) finalPayload.prompt = params.prompt;
|
finalPayload.prompt = params.prompt || '';
|
||||||
|
|
||||||
// Place the uploaded image(s) in the correct field for this model
|
// Place the uploaded image(s) in the correct field for this model
|
||||||
const imageField = modelInfo?.imageField || 'image_url';
|
const imageField = modelInfo?.imageField || 'image_url';
|
||||||
|
|
@ -326,11 +326,19 @@ export class MuapiClient {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Optional end-frame image — only for models declaring lastImageField.
|
||||||
|
// Server-side param name varies (last_image vs end_image_url).
|
||||||
|
const lastImageField = modelInfo?.lastImageField;
|
||||||
|
if (lastImageField && params.last_image) {
|
||||||
|
finalPayload[lastImageField] = params.last_image;
|
||||||
|
}
|
||||||
|
|
||||||
if (params.aspect_ratio) finalPayload.aspect_ratio = params.aspect_ratio;
|
if (params.aspect_ratio) finalPayload.aspect_ratio = params.aspect_ratio;
|
||||||
if (params.duration) finalPayload.duration = params.duration;
|
if (params.duration) finalPayload.duration = params.duration;
|
||||||
if (params.resolution) finalPayload.resolution = params.resolution;
|
if (params.resolution) finalPayload.resolution = params.resolution;
|
||||||
if (params.quality) finalPayload.quality = params.quality;
|
if (params.quality) finalPayload.quality = params.quality;
|
||||||
if (params.mode) finalPayload.mode = params.mode;
|
if (params.mode) finalPayload.mode = params.mode;
|
||||||
|
if (params.name) finalPayload.name = params.name;
|
||||||
|
|
||||||
console.log('[Muapi] I2V Request:', url);
|
console.log('[Muapi] I2V Request:', url);
|
||||||
console.log('[Muapi] I2V Payload:', finalPayload);
|
console.log('[Muapi] I2V Payload:', finalPayload);
|
||||||
|
|
@ -399,10 +407,14 @@ export class MuapiClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Processes a video through a Video-to-Video model (e.g. watermark remover).
|
* Processes a video through a Video-to-Video model.
|
||||||
|
* Single-input tools (e.g. watermark remover) only need `video_url`.
|
||||||
|
* Motion-control models additionally need `image_url` and (often) `prompt`.
|
||||||
* @param {Object} params
|
* @param {Object} params
|
||||||
* @param {string} params.model - v2vModel id
|
* @param {string} params.model - v2vModel id
|
||||||
* @param {string} params.video_url - The uploaded video URL
|
* @param {string} params.video_url - The uploaded video URL
|
||||||
|
* @param {string} [params.image_url] - Reference image URL (motion-control models)
|
||||||
|
* @param {string} [params.prompt] - Motion description (motion-control models)
|
||||||
*/
|
*/
|
||||||
async processV2V(params) {
|
async processV2V(params) {
|
||||||
const key = this.getKey();
|
const key = this.getKey();
|
||||||
|
|
@ -413,6 +425,13 @@ export class MuapiClient {
|
||||||
const videoField = modelInfo?.videoField || 'video_url';
|
const videoField = modelInfo?.videoField || 'video_url';
|
||||||
const finalPayload = { [videoField]: params.video_url };
|
const finalPayload = { [videoField]: params.video_url };
|
||||||
|
|
||||||
|
if (modelInfo?.imageField && params.image_url) {
|
||||||
|
finalPayload[modelInfo.imageField] = params.image_url;
|
||||||
|
}
|
||||||
|
if (modelInfo?.hasPrompt && params.prompt) {
|
||||||
|
finalPayload.prompt = params.prompt;
|
||||||
|
}
|
||||||
|
|
||||||
console.log('[Muapi] V2V Request:', url);
|
console.log('[Muapi] V2V Request:', url);
|
||||||
console.log('[Muapi] V2V Payload:', finalPayload);
|
console.log('[Muapi] V2V Payload:', finalPayload);
|
||||||
|
|
||||||
|
|
@ -470,7 +489,7 @@ export class MuapiClient {
|
||||||
if (params.audio_url) finalPayload.audio_url = params.audio_url;
|
if (params.audio_url) finalPayload.audio_url = params.audio_url;
|
||||||
if (params.image_url) finalPayload.image_url = params.image_url;
|
if (params.image_url) finalPayload.image_url = params.image_url;
|
||||||
if (params.video_url) finalPayload.video_url = params.video_url;
|
if (params.video_url) finalPayload.video_url = params.video_url;
|
||||||
if (params.prompt) finalPayload.prompt = params.prompt;
|
if (modelInfo?.hasPrompt) finalPayload.prompt = params.prompt || '';
|
||||||
if (params.resolution) finalPayload.resolution = params.resolution;
|
if (params.resolution) finalPayload.resolution = params.resolution;
|
||||||
if (params.seed !== undefined && params.seed !== -1) finalPayload.seed = params.seed;
|
if (params.seed !== undefined && params.seed !== -1) finalPayload.seed = params.seed;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,21 @@
|
||||||
|
export const ENHANCE_TAGS = {
|
||||||
|
quality: ['professional photography', 'ultra-detailed', '8K resolution', 'high dynamic range', 'award-winning'],
|
||||||
|
lighting: ['cinematic lighting', 'golden hour', 'dramatic studio lighting', 'soft diffused light', 'neon glow', 'volumetric rays'],
|
||||||
|
mood: ['moody atmosphere', 'serene and peaceful', 'epic and dramatic', 'warm and cozy', 'dark and mysterious'],
|
||||||
|
style: ['photorealistic', 'oil painting style', 'watercolor', 'digital art', 'concept art', 'anime style', 'cyberpunk aesthetic'],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const QUICK_PROMPTS = [
|
||||||
|
{ label: 'Portrait', prompt: 'Professional portrait photograph, shallow depth of field, soft studio lighting, 85mm lens' },
|
||||||
|
{ label: 'Landscape', prompt: 'Breathtaking landscape photograph, golden hour, wide angle, dramatic clouds, 4K' },
|
||||||
|
{ label: 'Product', prompt: 'Commercial product photography, clean white background, studio lighting, professional' },
|
||||||
|
{ label: 'Fantasy', prompt: 'Epic fantasy scene, magical atmosphere, volumetric lighting, highly detailed, concept art' },
|
||||||
|
{ label: 'Sci-Fi', prompt: 'Futuristic sci-fi environment, neon lights, cyberpunk city, rain reflections, cinematic' },
|
||||||
|
{ label: 'Food', prompt: 'Professional food photography, appetizing, warm lighting, shallow depth of field, editorial' },
|
||||||
|
{ label: 'Architecture', prompt: 'Architectural photography, dramatic angles, clean lines, modern design, professional' },
|
||||||
|
{ label: 'Fashion', prompt: 'High fashion editorial, avant-garde styling, studio lighting, Vogue aesthetic, professional' },
|
||||||
|
];
|
||||||
|
|
||||||
export const CAMERA_MAP = {
|
export const CAMERA_MAP = {
|
||||||
"Modular 8K Digital": "modular 8K digital cinema camera",
|
"Modular 8K Digital": "modular 8K digital cinema camera",
|
||||||
"Full-Frame Cine Digital": "full-frame digital cinema camera",
|
"Full-Frame Cine Digital": "full-frame digital cinema camera",
|
||||||
|
|
|
||||||
12
src/main.js
12
src/main.js
|
|
@ -24,6 +24,18 @@ function navigate(page) {
|
||||||
import('./components/LipSyncStudio.js').then(({ LipSyncStudio }) => {
|
import('./components/LipSyncStudio.js').then(({ LipSyncStudio }) => {
|
||||||
contentArea.appendChild(LipSyncStudio());
|
contentArea.appendChild(LipSyncStudio());
|
||||||
});
|
});
|
||||||
|
} else if (page === 'workflows') {
|
||||||
|
import('./components/WorkflowStudio.js').then(({ WorkflowStudio }) => {
|
||||||
|
contentArea.appendChild(WorkflowStudio());
|
||||||
|
});
|
||||||
|
} else if (page === 'agents') {
|
||||||
|
import('./components/AgentStudio.js').then(({ AgentStudio }) => {
|
||||||
|
contentArea.appendChild(AgentStudio());
|
||||||
|
});
|
||||||
|
} else if (page === 'mcp-cli') {
|
||||||
|
import('./components/McpCliStudio.js').then(({ McpCliStudio }) => {
|
||||||
|
contentArea.appendChild(McpCliStudio());
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,6 @@
|
||||||
@import "tailwindcss";
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
@theme {
|
@tailwind utilities;
|
||||||
--color-primary: #d9ff00;
|
|
||||||
--color-primary-hover: #c4e600;
|
|
||||||
--color-app-bg: #050505;
|
|
||||||
--color-panel-bg: #0a0a0a;
|
|
||||||
--color-card-bg: #141414;
|
|
||||||
--color-secondary: #a1a1aa;
|
|
||||||
--color-muted: #52525b;
|
|
||||||
|
|
||||||
--font-sans: "Inter", "system-ui", "-apple-system", "sans-serif";
|
|
||||||
|
|
||||||
--radius-xl: 1rem;
|
|
||||||
--radius-2xl: 1.5rem;
|
|
||||||
--radius-3xl: 2rem;
|
|
||||||
|
|
||||||
--shadow-glow: 0 0 20px rgba(217, 255, 0, 0.4);
|
|
||||||
--shadow-glow-accent: 0 0 20px rgba(168, 85, 247, 0.4);
|
|
||||||
--shadow-3xl: 0 35px 60px -15px rgba(0, 0, 0, 0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
body {
|
body {
|
||||||
|
|
@ -72,4 +54,181 @@
|
||||||
|
|
||||||
.animate-fade-in-up {
|
.animate-fade-in-up {
|
||||||
animation: fade-in-up 0.6s cubic-bezier(0.23, 1, 0.32, 1) forwards;
|
animation: fade-in-up 0.6s cubic-bezier(0.23, 1, 0.32, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Thumbnail cards */
|
||||||
|
.thumb-hero {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb-hero img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: top center;
|
||||||
|
transition: transform 0.4s cubic-bezier(0.23, 1, 0.32, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb-hero::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 60%;
|
||||||
|
background: linear-gradient(to top, rgba(0, 0, 0, 0.75), transparent);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb-hero:hover img,
|
||||||
|
.group:hover .thumb-hero img {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb-skeleton {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes skeleton-pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb-fallback .thumb-hero {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-banner img {
|
||||||
|
transition: transform 0.6s cubic-bezier(0.23, 1, 0.32, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-banner:hover img {
|
||||||
|
transform: scale(1.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.animate-fade-in-up {
|
||||||
|
animation: none;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb-hero img {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb-skeleton {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================
|
||||||
|
TOOLTIP SYSTEM
|
||||||
|
======================== */
|
||||||
|
|
||||||
|
/* Base tooltip container */
|
||||||
|
[data-tooltip] {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tooltip arrow */
|
||||||
|
[data-tooltip]::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 100%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%) translateY(8px);
|
||||||
|
border: 6px solid transparent;
|
||||||
|
border-top-color: #1a1a1a;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tooltip body */
|
||||||
|
[data-tooltip]::after {
|
||||||
|
content: attr(data-tooltip);
|
||||||
|
position: absolute;
|
||||||
|
bottom: 100%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%) translateY(12px);
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
z-index: 9999;
|
||||||
|
box-shadow: 0 10px 30px -10px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show tooltip on hover */
|
||||||
|
[data-tooltip]:hover::before,
|
||||||
|
[data-tooltip]:hover::after {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(-50%) translateY(-6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tooltip positioning variants */
|
||||||
|
[data-tooltip-bottom]::before {
|
||||||
|
bottom: auto;
|
||||||
|
top: 100%;
|
||||||
|
border-top-color: transparent;
|
||||||
|
border-bottom-color: #1a1a1a;
|
||||||
|
transform: translateX(-50%) translateY(-8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-tooltip-bottom]::after {
|
||||||
|
bottom: auto;
|
||||||
|
top: 100%;
|
||||||
|
transform: translateX(-50%) translateY(-12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-tooltip-bottom]:hover::before,
|
||||||
|
[data-tooltip-bottom]:hover::after {
|
||||||
|
transform: translateX(-50%) translateY(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tooltip for left-aligned elements */
|
||||||
|
[data-tooltip-left]::before {
|
||||||
|
left: 0;
|
||||||
|
transform: translateX(-100%) translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-tooltip-left]::after {
|
||||||
|
left: 0;
|
||||||
|
transform: translateX(calc(-100% - 10px)) translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-tooltip-left]:hover::before,
|
||||||
|
[data-tooltip-left]:hover::after {
|
||||||
|
transform: translateX(calc(-100% - 6px)) translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tooltip for right-aligned elements */
|
||||||
|
[data-tooltip-right]::before {
|
||||||
|
left: auto;
|
||||||
|
right: 0;
|
||||||
|
transform: translateX(100%) translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-tooltip-right]::after {
|
||||||
|
left: auto;
|
||||||
|
right: 0;
|
||||||
|
transform: translateX(calc(100% + 10px)) translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-tooltip-right]:hover::before,
|
||||||
|
[data-tooltip-right]:hover::after {
|
||||||
|
transform: translateX(calc(100% + 6px)) translateY(-50%);
|
||||||
}
|
}
|
||||||
|
|
@ -1,11 +1,7 @@
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
base: './',
|
base: './',
|
||||||
plugins: [
|
|
||||||
tailwindcss(),
|
|
||||||
],
|
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
Loading…
Add table
Add a link
Reference in a new issue