chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:02:44 +08:00
commit 33645b2d9d
144 changed files with 63699 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Electron build output
release/
.webpack/
# Next.js
.next/
out/
# Misc
*.pem
.env
.env.*
!.env.example
+9
View File
@@ -0,0 +1,9 @@
[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
[submodule "packages/Open-AI-Design-Agent"]
path = packages/Open-AI-Design-Agent
url = https://github.com/Anil-matcha/Open-AI-Design-Agent
+28
View File
@@ -0,0 +1,28 @@
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/Open-AI-Design-Agent/packages/design-agent/package*.json ./packages/Open-AI-Design-Agent/packages/design-agent/
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"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Open Generative AI Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+573
View File
@@ -0,0 +1,573 @@
# Open Generative AI — Unrestricted Open-Source Alternative to AI Video Platforms
[![Powered by MuAPI](https://img.shields.io/badge/Powered%20by-MuAPI-6366f1?style=flat-square&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZmlsbD0id2hpdGUiIGQ9Ik0xMiAyQzYuNDggMiAyIDYuNDggMiAxMnM0LjQ4IDEwIDEwIDEwIDEwLTQuNDggMTAtMTBTMTcuNTIgMiAxMiAyem0tMSAxNHYtNGgtMnYtMmg0djZoLTJ6bTAtOFY2aDJ2MmgtMnoiLz48L3N2Zz4=)](https://muapi.ai?utm_source=github&utm_medium=badge&utm_campaign=open-generative-ai)
> **The free, open-source alternative to AI Video Platforms.** Generate AI images and videos using 200+ state-of-the-art models — no content filters, no closed ecosystem, no subscription fees.
**Community:** Join [Discord](https://discord.gg/tANKJkHck) for discussions and support
<p align="center">
<a href="https://github.com/Anil-matcha/awesome-generative-ai-apps">
<img src="https://img.shields.io/badge/Part%20of-Awesome%20Generative%20AI%20Apps-FFD700?style=for-the-badge&logo=github&logoColor=black" alt="Awesome Generative AI Apps">
</a>
</p>
> 🎨 **[Explore 50+ more open-source AI apps →](https://github.com/Anil-matcha/awesome-generative-ai-apps)**
## Related Projects
> 🤖 **Automate media generations 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.
> 🎬 **Seedance 2.5 prompts & API guide:** [awesome-seedance-2.5-api-prompts](https://github.com/Anil-matcha/awesome-seedance-2.5-api-prompts) — Curated prompt templates, camera control vocabulary, MuAPI reference, and cinematic examples for Seedance 2.5 video generation.
> 🎥 **Seedance 2.5 Python SDK:** [Seedance-2.5-API](https://github.com/SamurAIGPT/Seedance-2.5-API) — Python wrapper for ByteDance's Seedance 2.5 API — text-to-video, image-to-video, realistic human faces, character consistency.
> 🍌 **Claude Fable 5 use cases + 20% off on MuAPI:** [awesome-claude-fable-5](https://github.com/Anil-matcha/awesome-claude-fable-5) — 60 curated real-world use cases, prompts, and benchmarks for Claude Fable 5, with **20% off Fable 5 access** via [MuAPI](https://muapi.ai/pricing?utm_source=github&utm_medium=readme&utm_campaign=open-generative-ai).
- [Vadoo](https://vadoo.tv) — Unrestricted AI image & video generation → auto-publish as YouTube Shorts and TikToks & earn
- [AI-Youtube-Shorts-Generator](https://github.com/SamurAIGPT/AI-Youtube-Shorts-Generator) — Auto-generate viral YouTube Shorts from long-form videos using AI
- [muapi-cli](https://github.com/SamurAIGPT/muapi-cli) — Official CLI for MuAPI — run these models from your terminal
- [Vibe-Workflow](https://github.com/SamurAIGPT/Vibe-Workflow) — Node-based AI workflow builder for generative image & video pipelines
- [Text-To-Video-AI](https://github.com/SamurAIGPT/Text-To-Video-AI) — Lightweight text-to-video script — no UI required
- [muapi-comfyui](https://github.com/SamurAIGPT/muapi-comfyui) — ComfyUI nodes for 100+ MuAPI models
- [n8n-nodes-muapi](https://github.com/SamurAIGPT/n8n-nodes-muapi) — n8n community nodes for MuAPI — automate media generation
- [Open-AI-Design-Agent](https://github.com/Anil-matcha/Open-AI-Design-Agent) — Open-source autonomous AI design agent
- [Free-AI-Social-Media-Scheduler](https://github.com/Anil-matcha/Free-AI-Social-Media-Scheduler) — Free open-source AI social media scheduler — self-hostable alternative to Buffer and Hootsuite
- [awesome-seedance-2.5-api-prompts](https://github.com/Anil-matcha/awesome-seedance-2.5-api-prompts) — Curated Seedance 2.5 API guide, prompts, camera controls, and video generation examples
- [AI-Voice-Agent](https://github.com/Anil-matcha/AI-Voice-Agent) — Self-hosted AI voice agent for real-time voice conversations, sales calls, and customer support
## 🌐 Try it Online — No Install Required
**Hosted version:** [https://muapi.ai/open-generative-ai?utm_source=github&utm_medium=readme&utm_campaign=open-generative-ai](https://muapi.ai/open-generative-ai?utm_source=github&utm_medium=readme&utm_campaign=open-generative-ai)
Use all studios (Image, Video, Audio, AI Clipping, Vibe Motion, Lip Sync, Cinema, Marketing, Workflows, Agents, Design Agent, Apps, MCP & CLI) 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
One-click installers — no Node.js or terminal required.
| Platform | Download |
|---|---|
| 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 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) | [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-Generative-AI/releases](https://github.com/Anil-matcha/Open-Generative-AI/releases)
### macOS Installation Guide
Because the app is not notarized by Apple, macOS Gatekeeper will block it on first launch. Follow these steps:
**Step 1** — Mount the DMG and drag the app to `/Applications`
**Step 2** — Open Terminal and run:
```bash
xattr -cr "/Applications/Open Generative AI.app"
```
**Step 3** — Right-click the app in `/Applications` → click **Open** → click **Open** again on the dialog
> You only need to do this once. After that, the app opens normally.
**Alternative (no Terminal):**
1. Try to open the app — macOS will block it
2. Go to **System Settings → Privacy & Security**
3. Scroll down to find _"Open Generative AI was blocked"_
4. Click **Open Anyway****Open**
### Windows Installation — SmartScreen warning fix
Windows SmartScreen may show a warning because the installer is not code-signed:
1. Click **More info** on the SmartScreen dialog
2. Click **Run anyway**
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 Generative AI is a free, open-source AI image, video, cinema, and lip sync studio that brings creative workflows to everyone. No content filters, no prompt rejections, no guardrails — just full creative freedom. Powered by [Muapi.ai](https://muapi.ai?utm_source=github&utm_medium=readme&utm_campaign=open-generative-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 Generative AI instead of other AI Video Platforms?**
- **No filters** — no content filters, no nanny guardrails, no prompt rejections
- **Free & open-source** — no subscription, no vendor lock-in
- **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
- **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
- **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/).
## ⚡ 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.
By default, `sd.cpp` stores the engine, model weights, and temporary downloads under Electron's app data directory. Common paths are:
- macOS: `~/Library/Application Support/open-generative-ai/local-ai`
- Windows: `%APPDATA%\open-generative-ai\local-ai`
- Linux: `~/.config/open-generative-ai/local-ai`
To keep multi-GB model weights on another drive, set `OPEN_GENERATIVE_AI_LOCAL_AI_DIR`
before launching the desktop app. The app will create `bin/`, `models/`, and `tmp/`
inside that directory, and **Settings -> Local Models** shows the resolved model folder.
Local engine output and download errors are written to the app process console, so launch
from Terminal or PowerShell when you need troubleshooting logs.
### 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 ~12 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="${OPEN_GENERATIVE_AI_LOCAL_AI_DIR:-$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
- **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.
- **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.
- **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.
- **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)
- **Image & Video Download** — One-click download of generated outputs in full resolution
- **API Key Management** — Secure API key storage in browser localStorage (never sent to any server except Muapi)
- **Responsive Design** — Works seamlessly on desktop and mobile with dark glassmorphism UI
### 🖼️ Image Studio — Dual Mode
The Image Studio automatically switches between two model sets:
| Mode | Trigger | Models | Prompt |
| :--- | :--- | :--- | :--- |
| **Text-to-Image** | Default (no image) | 50+ t2i models (Flux, Nano Banana 2, Seedream 5.0, Ideogram, GPT-4o, Midjourney…) | Required |
| **Image-to-Image** | Reference image uploaded | 55+ i2i models (Kontext, Nano Banana 2 Edit, Seedream 5.0 Edit, Seededit, Upscaler…) | Optional |
#### Newly Added Models
| Model | Type | Key Features |
| :--- | :--- | :--- |
| **Nano Banana 2** | Text-to-Image | Google Gemini 3.1 Flash Image · Resolution 1K/2K/4K · Google Search enhancement · aspect ratio `auto` |
| **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 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
Models that accept multiple reference images expose a multi-select picker when active:
| Model | Max Images |
| :--- | :--- |
| Nano Banana 2 Edit | 14 |
| Nano Banana Edit | 10 |
| Flux Kontext Dev I2I | 10 |
| Kling O1 Edit Image | 10 |
| GPT-4o Edit / GPT Image 1.5 Edit | 10 |
| Bytedance Seedream Edit v4 / v4.5 | 10 |
| Vidu Q2 Reference to Image | 7 |
| Flux 2 Flex/Pro Edit | 8 |
| Nano Banana Pro Edit | 8 |
| Flux Kontext Pro/Max I2I | 2 |
| Wan 2.5/2.6 Image Edit | 23 |
| Qwen Image Edit Plus / 2511 | 3 |
| GPT-4o Image to Image | 5 |
| Flux 2 Klein 4b/9b Edit | 4 |
When a multi-image model is selected the upload trigger switches to multi-select mode:
- **Checkboxes with order numbers** — images are sent to the model in the order you select them
- **Batch upload** — pick multiple files at once from your file dialog
- **Count badge** on the trigger shows how many images are active; a `+` badge appears when more slots are available
- **"Use Selected" button** confirms and closes the picker
### 🎬 Video Studio — Dual Mode
The Video Studio follows the same pattern:
| Mode | Trigger | Models | Prompt |
| :--- | :--- | :--- | :--- |
| **Text-to-Video** | Default (no image) | 40+ t2v models (Kling, Sora, Veo, Wan, Seedance 2.0, Hailuo, Runway…) | Required |
| **Image-to-Video** | Start frame uploaded | 60+ i2v models (Kling I2V, Veo3 I2V, Runway I2V, Wan I2V, Seedance 2.0 I2V, Midjourney I2V…) | Optional |
#### Newly Added Models
| Model | Type | Key Features |
| :--- | :--- | :--- |
| **Seedance 2.0** | Text-to-Video | ByteDance · Aspect ratios 16:9 / 9:16 / 4:3 / 3:4 · Duration 5 / 10 / 15s · Quality basic/high |
| **Seedance 2.0 I2V** | Image-to-Video | ByteDance · Animate images into video · Up to 9 reference images · Aspect ratios 16:9 / 9:16 / 4:3 / 3:4 · 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 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
The **Lip Sync Studio** generates audio-driven talking videos using 9 models across two input modes:
| Mode | Trigger | Description |
| :--- | :--- | :--- |
| **Portrait Image** | Default | Upload a portrait image + audio file → animated talking video |
| **Video** | Switch to Video mode | Upload an existing video + audio file → lipsync video |
#### Image-based Models (Portrait Image + Audio → Video)
| Model | Endpoint | Resolutions | Prompt |
| :--- | :--- | :--- | :--- |
| **Infinite Talk** | `infinitetalk-image-to-video` | 480p, 720p | Optional |
| **Wan 2.2 Speech to Video** | `wan2.2-speech-to-video` | 480p, 720p | Optional |
| **LTX 2.3 Lipsync** | `ltx-2.3-lipsync` | 480p, 720p, 1080p | Optional |
| **LTX 2 19B Lipsync** | `ltx-2-19b-lipsync` | 480p, 720p, 1080p | Optional |
#### Video-based Models (Video + Audio → Lipsync Video)
| Model | Endpoint | Resolutions | Prompt |
| :--- | :--- | :--- | :--- |
| **Sync Lipsync** | `sync-lipsync` | — | — |
| **LatentSync** | `latentsync-video` | — | — |
| **Creatify Lipsync** | `creatify-lipsync` | — | — |
| **Veed Lipsync** | `veed-lipsync` | — | — |
| **Infinite Talk V2V** | `infinitetalk-video-to-video` | 480p, 720p | Optional |
**How it works:**
1. Select **Portrait Image** or **Video** mode using the toggle
2. Upload your portrait image (or video) using the image/video upload button
3. Upload your audio file using the audio upload button
4. Optionally enter a prompt to guide the motion style
5. Select a model and resolution (where supported), then click **Generate**
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
The **Cinema Studio** offers precise control over the virtual camera, translating your choices into optimized prompt modifiers:
| Category | Available Options |
| :--- | :--- |
| **Cameras** | Modular 8K Digital, Full-Frame Cine Digital, Grand Format 70mm Film, Studio Digital S35, Classic 16mm Film, Premium Large Format Digital |
| **Lenses** | Creative Tilt, Compact Anamorphic, Extreme Macro, 70s Cinema Prime, Classic Anamorphic, Premium Modern Prime, Warm Cinema Prime, Swirl Bokeh Portrait, Vintage Prime, Halation Diffusion, Clinical Sharp Prime |
| **Focal Lengths** | 8mm (Ultra-Wide), 14mm, 24mm, 35mm (Human Eye), 50mm (Portrait), 85mm (Tight Portrait) |
| **Apertures** | f/1.4 (Shallow DoF), f/4 (Balanced), f/11 (Deep Focus) |
### 📁 Upload History & Picker
Every image you upload is saved locally (URL + thumbnail) so you never upload the same file twice:
- Click the upload button to open the **reference image picker**
- Previously uploaded images appear in a 3-column grid with thumbnails
- **Single-image models** — click a thumbnail to instantly select and close
- **Multi-image models** — toggle multiple thumbnails (shown with order numbers), then click **Use Selected**
- Upload new images with the **Upload files** button (supports multi-file selection in multi-image mode)
- Remove individual images from history with the ✕ button
- History persists across browser sessions (stored in `localStorage`)
## 🚀 Quick Start
### Prerequisites
- [Node.js](https://nodejs.org/) (v18+)
- A [Muapi.ai access key](https://muapi.ai/access-keys?utm_source=github&utm_medium=readme&utm_campaign=open-generative-ai). Copy the generated key value into the app; do not enter the key name or label.
### 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
# Clone the repository (with submodules — required for the workflow + agent packages)
git clone --recurse-submodules https://github.com/Anil-matcha/Open-Generative-AI.git
cd Open-Generative-AI
# If you already cloned without --recurse-submodules, run this once:
# git submodule update --init --recursive
# Install dependencies + build workspace packages (studio, workflow, agents).
# 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
```
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/agents` are empty.
### Production Build
```bash
npm run build
npm run start
```
### Desktop App Build
Build native desktop apps with Electron:
```bash
# macOS (DMG — Intel + Apple Silicon)
npm run electron:build
# Windows (NSIS installer — x64 + ARM64)
npm run electron:build:win
# Linux (AppImage + DEB — x64)
npm run electron:build:linux
# Both platforms in one pass
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-Generative-AI/releases).
## 🏗️ Architecture
The app is a **Next.js monorepo** with a shared `packages/studio` component library.
```
Open-Generative-AI/
├── app/ # Next.js App Router
│ ├── layout.js # Root layout (Tailwind, fonts)
│ ├── page.js # Redirects → /studio
│ └── studio/
│ └── page.js # Studio page — renders StandaloneShell
├── components/
│ ├── StandaloneShell.js # Tab nav + BYOK (API key from localStorage)
│ └── ApiKeyModal.js # API key entry modal
├── packages/
│ └── studio/ # Shared React component library
│ └── src/
│ ├── index.js # Exports: ImageStudio, VideoStudio, AudioStudio, ClippingStudio, VibeMotionStudio, LipSyncStudio, CinemaStudio, MarketingStudio, WorkflowStudio, AgentStudio, DesignAgentStudio, AppsStudio, McpCliStudio
│ ├── models.js # 200+ model definitions (single source of truth)
│ ├── muapi.js # API client (named exports, apiKey as first param)
│ └── components/
│ ├── ImageStudio.jsx # Dual-mode t2i/i2i studio
│ ├── VideoStudio.jsx # Dual-mode t2v/i2v studio
│ ├── LipSyncStudio.jsx # Portrait/video + audio → talking video
│ ├── CinemaStudio.jsx # Pro studio with camera controls
│ └── WorkflowStudio.jsx # Multi-step pipeline builder & playground
├── next.config.mjs # transpilePackages: ['studio']
├── tailwind.config.js
└── package.json # workspaces: ["packages/studio"]
```
The `packages/studio` library is also consumed by the hosted version on [muapi.ai](https://muapi.ai?utm_source=github&utm_medium=readme&utm_campaign=open-generative-ai) — model updates made in `packages/studio/src/models.js` apply to both the self-hosted app and the hosted version automatically.
## 🔌 API Integration
The app communicates with [Muapi.ai](https://muapi.ai?utm_source=github&utm_medium=readme&utm_campaign=open-generative-ai) using a two-step pattern:
1. **Submit** — `POST /api/v1/{model-endpoint}` with prompt and parameters
2. **Poll** — `GET /api/v1/predictions/{request_id}/result` until status is `completed`
Authentication uses the `x-api-key` header. During development, a Vite proxy handles CORS by routing `/api` requests to `https://api.muapi.ai`.
File uploads use `POST /api/v1/upload_file` (multipart/form-data) and return a hosted URL that is passed to image-conditioned models. For multi-image models the full `images_list` array is forwarded to the API in one request.
Lip sync jobs use the same two-step pattern: a dedicated `processLipSync()` method accepts `image_url` or `video_url` alongside `audio_url`, dispatches to the model's endpoint, and polls until the output video URL is available.
## 🎨 Supported Model Categories
| Category | Count | Examples |
|---|---|---|
| **Text-to-Image** | 50+ | Flux Dev, Nano Banana 2, Seedream 5.0, Ideogram v3, Midjourney v7, GPT-4o, SDXL |
| **Image-to-Image** | 55+ | Nano Banana 2 Edit (×14), Flux Kontext Pro, GPT-4o Edit, Seededit v3, Upscaler, Background Remover |
| **Text-to-Video** | 40+ | Kling v3, Sora 2, Veo 3, Wan 2.6, Seedance 2.0, Seedance 2.0 Extend, Seedance Pro, Hailuo 2.3, Runway Gen-3 |
| **Image-to-Video** | 60+ | Kling v2.1 I2V, Veo3 I2V, Runway I2V, Seedance 2.0 I2V, Midjourney v7 I2V, Hunyuan I2V, Wan2.2 I2V |
| **Lip Sync** | 9 | Infinite Talk I2V, Wan 2.2 Speech to Video, LTX 2.3 Lipsync, LTX 2 19B Lipsync, Sync, LatentSync, Creatify, Veed, Infinite Talk V2V |
## 🛠️ Tech Stack
- **Next.js 14** — App Router, server components, fast dev server
- **React 18** — Studio UI components
- **Tailwind CSS v3** — Utility-first styling
- **npm workspaces** — Monorepo with shared `packages/studio` library
- **Muapi.ai** — AI model API gateway
## 🤔 How is this different from other AI Video Platforms?
**Open Generative AI** is a community-driven, open-source alternative that provides similar creative capabilities without the closed ecosystem:
| | Other providers | Open Generative AI |
| :--- | :--- | :--- |
| **Cost** | Subscription-based | Free (open-source) |
| **Content filters** | Yes — prompts blocked or altered | None |
| **Restrictions** | Platform guardrails enforced | Full creative freedom |
| **Models** | Proprietary | 200+ open & commercial models |
| **Multi-image input** | Limited | Up to 14 images per request |
| **Lip sync** | No | 9 models, image & video modes |
| **Hosted version** | Subscription | Free at [muapi.ai/open-generative-ai](https://muapi.ai/open-generative-ai?utm_source=github&utm_medium=readme&utm_campaign=open-generative-ai) |
| **Self-hosting** | No | Yes |
| **Customizable** | No | Fully hackable |
| **Data privacy** | Cloud-based | Your data stays local |
| **Source code** | Closed | MIT licensed |
## 📄 License
MIT
## 🙏 Credits
Built with [Muapi.ai](https://muapi.ai?utm_source=github&utm_medium=readme&utm_campaign=open-generative-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/).
---
*Looking for a free, open-source AI Video Platform? Open Generative AI is an open-source AI image and video generation studio — with no content filters that you can self-host, customize, and extend.*
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`Anil-matcha/Open-Generative-AI`
- 原始仓库:https://github.com/Anil-matcha/Open-Generative-AI
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+38
View File
@@ -0,0 +1,38 @@
import { execSync } from 'child_process';
import path from 'path';
import fs from 'fs';
export default async function afterPack({ appOutDir, packager }) {
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`);
console.log(` • ad-hoc signing path=${appPath}`);
execSync(`codesign --deep --force --sign - "${appPath}"`, { stdio: 'inherit' });
console.log(` • ad-hoc signing complete`);
}
+83
View 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>
);
}
@@ -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
View 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
View 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
View 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
View 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
View 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
View 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>
);
}
+107
View File
@@ -0,0 +1,107 @@
import { NextResponse } from 'next/server';
const MUAPI_BASE = 'https://api.muapi.ai';
function getApiKey(request) {
// Only accept x-api-key header. Cookie-based auth is removed for security:
// cookies without HttpOnly flag can be stolen by XSS (CWE-522).
const headerKey = request.headers.get('x-api-key');
return headerKey || null;
}
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);
// NOTE: credential logging removed for security (CWE-200)
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);
// NOTE: credential logging removed for security (CWE-200)
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
View 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;
// Cookie-based auth removed for security (CWE-522)
return null;
}
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);
// NOTE: credential logging removed for security (CWE-200)
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 });
}
}
+142
View File
@@ -0,0 +1,142 @@
import { NextResponse } from 'next/server';
const MUAPI_BASE = 'https://api.muapi.ai';
function getApiKey(request) {
// Only accept x-api-key header. Cookie-based auth is removed for security:
// cookies without HttpOnly flag can be stolen by XSS (CWE-522).
const headerKey = request.headers.get('x-api-key');
return headerKey || null;
}
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 });
}
}
+53
View File
@@ -0,0 +1,53 @@
import { NextResponse } from 'next/server';
import { validateUploadProxyTarget } from '../../../src/lib/uploadProxyTarget';
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 });
}
const validatedTarget = validateUploadProxyTarget(targetUrl);
if (!validatedTarget.ok) {
return NextResponse.json(
{ error: 'Invalid upload target', reason: validatedTarget.reason },
{ 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(validatedTarget.url, {
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 });
}
}
@@ -0,0 +1,122 @@
import { NextResponse } from 'next/server';
const MUAPI_BASE = 'https://api.muapi.ai';
function getApiKey(request) {
const authHeader = request.headers.get('Authorization');
if (authHeader && authHeader.startsWith('Bearer ')) {
return authHeader.substring(7);
}
const headerKey = request.headers.get('x-api-key');
if (headerKey) return headerKey;
// Cookie-based auth removed for security: no HttpOnly flag exposes key to XSS (CWE-522)
return null;
}
function cleanHeaders(request) {
const headers = new Headers(request.headers);
headers.delete('host');
headers.delete('connection');
headers.delete('cookie');
headers.delete('Authorization');
headers.delete('x-api-key');
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}/api/v1/creative-agent/${path}${search}`;
const headers = cleanHeaders(request);
const apiKey = getApiKey(request);
// NOTE: credential logging removed for security (CWE-200)
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) {
console.error(`[creative-agent proxy GET ERROR] ${targetUrl}:`, error.message);
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/creative-agent/${path}${search}`;
const headers = cleanHeaders(request);
const apiKey = getApiKey(request);
// NOTE: credential logging removed for security (CWE-200)
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) {
console.error(`[creative-agent proxy POST ERROR] ${targetUrl}:`, error.message);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export async function PATCH(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/creative-agent/${path}${search}`;
const headers = cleanHeaders(request);
const apiKey = getApiKey(request);
// NOTE: credential logging removed for security (CWE-200)
if (apiKey) headers.set('x-api-key', apiKey);
try {
const body = await request.arrayBuffer();
const response = await fetch(targetUrl, { method: 'PATCH', headers, body });
const data = await response.json();
return NextResponse.json(data, { status: response.status });
} catch (error) {
console.error(`[creative-agent proxy PATCH ERROR] ${targetUrl}:`, error.message);
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}/api/v1/creative-agent/${path}${search}`;
const headers = cleanHeaders(request);
const apiKey = getApiKey(request);
// NOTE: credential logging removed for security (CWE-200)
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) {
console.error(`[creative-agent proxy DELETE ERROR] ${targetUrl}:`, error.message);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
+40
View File
@@ -0,0 +1,40 @@
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;
// Cookie-based auth removed for security (CWE-522)
return null;
}
function cleanHeaders(request) {
const headers = new Headers(request.headers);
headers.delete('host');
headers.delete('connection');
headers.delete('cookie');
return headers;
}
export async function GET(request) {
const { search } = new URL(request.url);
const targetUrl = `${MUAPI_BASE}/app/get_file_upload_url${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();
return NextResponse.json(data, { status: response.status });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
+46
View File
@@ -0,0 +1,46 @@
import { NextResponse } from 'next/server';
import { validateUploadProxyTarget } from '../../../../src/lib/uploadProxyTarget';
export async function POST(request) {
try {
const formData = await request.formData();
// Extract the original S3 target URL
const targetUrl = formData.get('x-proxy-target-url');
if (!targetUrl) {
return NextResponse.json({ error: 'Missing proxy target URL' }, { status: 400 });
}
const validatedTarget = validateUploadProxyTarget(targetUrl);
if (!validatedTarget.ok) {
return NextResponse.json(
{ error: 'Invalid upload target', reason: validatedTarget.reason },
{ status: 400 }
);
}
const s3FormData = new FormData();
for (const [key, value] of formData.entries()) {
if (key !== 'x-proxy-target-url') {
s3FormData.append(key, value);
}
}
const s3Response = await fetch(validatedTarget.url, {
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 });
}
}
+134
View File
@@ -0,0 +1,134 @@
import { NextResponse } from 'next/server';
const MUAPI_BASE = 'https://api.muapi.ai';
function getApiKey(request) {
// Only accept x-api-key header. Cookie-based auth is removed for security:
// cookies without HttpOnly flag can be stolen by any XSS (CWE-522).
const headerKey = request.headers.get('x-api-key');
return headerKey || null;
}
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);
// NOTE: apiKey is intentionally NOT logged here to prevent credential leakage (CWE-200)
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);
// NOTE: credential logging removed for security (CWE-200)
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 });
}
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function Home() {
redirect('/studio');
}
+60
View File
@@ -0,0 +1,60 @@
@tailwind base;
@tailwind components;
@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; }
body {
background: #050505;
color: white;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
-webkit-font-smoothing: antialiased;
}
:root {
--color-primary: #22d3ee;
--bg-app: #030303;
--bg-panel: #0a0a0a;
--bg-card: #111111;
--border-color: rgba(255,255,255,0.05);
--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-track { background: transparent; }
.custom-scrollbar::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 2px; }
.scrollbar-none::-webkit-scrollbar {
height: 2px;
width: 2px;
}
.scrollbar-none::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-none::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.12);
border-radius: 999px;
}
.scrollbar-none::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.25);
}
.scrollbar-none {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.12) transparent;
}
@keyframes fade-in-up {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fade-in-up { animation: fade-in-up 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards; }
+20
View File
@@ -0,0 +1,20 @@
import './globals.css';
import { Inter } from "next/font/google";
const inter = Inter({
variable: "--font-inter",
subsets: ["latin"],
});
export const metadata = {
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.',
};
export default function RootLayout({ children }) {
return (
<html lang="en">
<body className={inter.variable}>{children}</body>
</html>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function Home() {
redirect('/studio');
}
+9
View File
@@ -0,0 +1,9 @@
import StandaloneShell from '@/components/StandaloneShell';
export const metadata = {
title: 'Studio — Open Generative AI',
};
export default function StudioPage() {
return <StandaloneShell />;
}
+9
View 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
View 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
View 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
View 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>
}
+8
View File
@@ -0,0 +1,8 @@
Stage packaged local inference binaries here before running Electron builds.
Examples:
- `npm run stage:local-ai -- linux arm64 /path/to/stable-diffusion.cpp/build-cuda-arm64`
- `npm run stage:local-ai -- linux x64 /path/to/stable-diffusion.cpp/build/bin`
This copies the expected runtime files into `build/local-ai/<platform>-<arch>/bin`,
which Electron Builder then bundles into `resources/local-ai/...` via `extraResources`.
+85
View File
@@ -0,0 +1,85 @@
'use client';
import { useState } from 'react';
export default function ApiKeyModal({ onSave, onClose, overlay = false, title, subtitle }) {
const [key, setKey] = useState('');
const [error, setError] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
const trimmed = key.trim();
if (!trimmed) { setError('Please enter your API key'); return; }
onSave(trimmed);
};
const wrapperClass = overlay
? 'fixed inset-0 z-[200] bg-black/70 backdrop-blur-sm flex items-center justify-center px-4 font-inter animate-fade-in-up'
: 'min-h-screen bg-[#030303] flex items-center justify-center px-4 font-inter';
return (
<div className={wrapperClass}>
<div className="w-full max-w-sm bg-[#0a0a0a]/90 backdrop-blur-xl border border-white/10 rounded-xl p-10 shadow-2xl relative">
{overlay && onClose && (
<button
type="button"
onClick={onClose}
aria-label="Close"
className="absolute top-3 right-3 w-8 h-8 rounded-md text-white/40 hover:text-white hover:bg-white/10 transition-colors flex items-center justify-center"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
)}
<div className="flex flex-col items-center text-center mb-10">
<div className="w-14 h-14 bg-[#22d3ee]/5 rounded-2xl flex items-center justify-center border border-[#22d3ee]/10 mb-6 group hover:border-[#22d3ee]/30 transition-colors">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#22d3ee" 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" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
<h1 className="text-xl font-bold text-white tracking-tight mb-2">
{title || 'Open Generative AI'}
</h1>
<p className="text-white/40 text-[13px] leading-relaxed px-4">
{subtitle || (
<>Enter your <a href="https://muapi.ai/access-keys" target="_blank" rel="noreferrer" className="text-[#22d3ee] hover:text-[#e5ff33] transition-colors">Muapi.ai</a> API key to start creating</>
)}
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<label className="block text-xs font-bold text-white/30 ml-1">
API Access Key
</label>
<input
type="password"
value={key}
onChange={(e) => { setKey(e.target.value); setError(''); }}
placeholder="Paste your key here..."
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-[#22d3ee]/30 focus:bg-white/[0.07] transition-all"
suppressHydrationWarning
/>
{error && <p className="mt-2 text-red-500/80 text-[11px] font-medium ml-1">{error}</p>}
</div>
<button
type="submit"
className="w-full bg-[#22d3ee] 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-[#22d3ee]/5"
suppressHydrationWarning
>
Get Started
</button>
<p className="text-center text-[12px] text-white/20 pt-2">
Need a key?{' '}
<a href="https://muapi.ai/access-keys" target="_blank" rel="noreferrer" className="text-white/40 hover:text-[#22d3ee] transition-colors font-medium">
Get one free
</a>
</p>
</form>
</div>
</div>
);
}
+443
View File
@@ -0,0 +1,443 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useParams, useRouter } from 'next/navigation';
import dynamic from 'next/dynamic';
import { ImageStudio, VideoStudio, ClippingStudio, VibeMotionStudio, LipSyncStudio, RecastStudio, CinemaStudio, AudioStudio, MarketingStudio, WorkflowStudio, AgentStudio, AppsStudio, AiInfluencerStudio, getUserBalance } from 'studio';
const DesignAgentStudio = dynamic(() => import('studio').then(mod => mod.DesignAgentStudio), {
ssr: false,
loading: () => <div className="h-full w-full bg-black flex items-center justify-center text-white/20">Loading Design Studio...</div>
});
import axios from 'axios';
import ApiKeyModal from './ApiKeyModal';
const TABS = [
{ id: 'image', label: 'Image Studio' },
{ id: 'video', label: 'Video Studio' },
{ id: 'audio', label: 'Audio Studio' },
{ id: 'clipping', label: 'AI Clipping' },
{ id: 'vibe-motion', label: 'Vibe Motion' },
{ id: 'lipsync', label: 'Lip Sync' },
{ id: 'body-swap', label: 'Body Swap' },
{ id: 'cinema', label: 'Cinema Studio' },
{ id: 'marketing', label: 'Marketing Studio' },
{ id: 'workflows', label: 'Workflows' },
{ id: 'agents', label: 'Agents' },
{ id: 'design-agent', label: 'Design Agent' },
{ id: 'apps', label: 'Explore Apps' },
{ id: 'ai-influencer', label: 'AI Influencer Studio' },
];
const STORAGE_KEY = 'muapi_key';
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('design-agent')) return 'design-agent';
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 [activeTab, setActiveTab] = useState(getInitialTab());
const [balance, setBalance] = useState(null);
const [showSettings, setShowSettings] = useState(false);
const [isHeaderVisible, setIsHeaderVisible] = useState(true);
const [hasMounted, setHasMounted] = useState(false);
const [showVadooBanner, setShowVadooBanner] = useState(() => {
if (typeof window !== 'undefined') return localStorage.getItem('vadoo_banner_dismissed') !== '1';
return true;
});
// Drag and Drop State
const [isDragging, setIsDragging] = useState(false);
const [droppedFiles, setDroppedFiles] = useState(null);
// Popstate event listener to sync tab state with URL on back/forward navigation
useEffect(() => {
const handlePopState = () => {
const path = window.location.pathname;
const segments = path.split('/').filter(Boolean);
const tabId = segments[1] || 'image';
if (TABS.find(t => t.id === tabId)) {
setActiveTab(tabId);
}
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, []);
const handleTabChange = (tabId) => {
window.history.pushState(null, '', `/studio/${tabId}`);
setActiveTab(tabId);
};
// Auto-hide header when inside a specific workflow view or design agent
useEffect(() => {
const isEditingWorkflow = (activeTab === 'workflows' || !!idFromParams) && urlWorkflowId;
const isDesignAgent = activeTab === 'design-agent';
if (isEditingWorkflow || isDesignAgent) {
setIsHeaderVisible(false);
} else {
setIsHeaderVisible(true);
}
}, [activeTab, urlWorkflowId, idFromParams]);
// Global builder CSS cleanup when switching away from Workflows or Design Agent tabs
useEffect(() => {
const fromBuilder = sessionStorage.getItem("fromWorkflowBuilder");
const fromDesignAgent = sessionStorage.getItem("fromDesignAgent");
if ((fromBuilder && activeTab !== 'workflows') || (fromDesignAgent && activeTab !== 'design-agent')) {
sessionStorage.removeItem("fromWorkflowBuilder");
sessionStorage.removeItem("fromDesignAgent");
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(() => {
setHasMounted(true);
const stored = localStorage.getItem(STORAGE_KEY);
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) => {
localStorage.setItem(STORAGE_KEY, key);
setApiKey(key);
fetchBalance(key);
document.cookie = `muapi_key=${key}; path=/; max-age=31536000; SameSite=Lax`;
}, [fetchBalance]);
const handleKeyChange = useCallback(() => {
localStorage.removeItem(STORAGE_KEY);
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-[#22d3ee] text-3xl"></div>
</div>
);
if (!apiKey) {
return <ApiKeyModal onSave={handleKeySave} />;
}
return (
<div
className="h-screen bg-[#030303] flex flex-col overflow-hidden text-white relative"
onDragOver={handleDragOver}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* Drag Overlay */}
{isDragging && (
<div className="fixed inset-0 z-[100] bg-[#22d3ee]/10 backdrop-blur-md border-4 border-dashed border-[#22d3ee]/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-[#22d3ee] 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>
)}
{/* Vadoo promo banner */}
{showVadooBanner && (
<div className="flex-shrink-0 w-full bg-indigo-600 flex items-center justify-center px-4 py-2 gap-3 relative z-50">
<a
href="https://vadoo.tv"
target="_blank"
rel="noopener noreferrer"
className="text-[13px] font-bold text-white hover:opacity-80 transition-opacity text-center"
>
Unrestricted AI Images &amp; Videos Auto-Publish as YouTube Shorts &amp; TikToks, Earn
</a>
<button
onClick={() => {
setShowVadooBanner(false);
localStorage.setItem('vadoo_banner_dismissed', '1');
}}
className="absolute right-3 text-white/60 hover:text-white transition-colors text-lg leading-none"
aria-label="Dismiss"
>
</button>
</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 gap-4">
{/* Left: Logo */}
<div className="flex-shrink-0 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 Container with fade edges */}
<div className="flex-1 min-w-0 mx-4 sm:mx-6 relative overflow-hidden h-full flex items-center justify-start lg:justify-center">
{/* Fade Left Overlay */}
<div className="absolute left-0 top-0 bottom-0 w-8 bg-gradient-to-r from-[#030303] to-transparent pointer-events-none z-10 block lg:hidden" />
<nav className="flex items-center gap-4 overflow-x-auto scrollbar-none w-full lg:w-auto h-full px-4 lg:px-0">
{TABS.map((tab) => (
<button
key={tab.id}
onClick={() => handleTabChange(tab.id)}
className={`relative text-[13px] font-medium transition-all duration-300 whitespace-nowrap px-1 flex-shrink-0 flex items-center h-full ${
activeTab === tab.id
? 'text-[#22d3ee]'
: 'text-white/50 hover:text-white'
}`}
>
<span className="relative z-10">{tab.label}</span>
{activeTab === tab.id && (
<div className="absolute bottom-0 left-0 right-0 h-[2px] bg-gradient-to-r from-[#22d3ee] to-[#a855f7] rounded-full shadow-[0_0_8px_rgba(34,211,238,0.5)]" />
)}
</button>
))}
</nav>
{/* Fade Right Overlay */}
<div className="absolute right-0 top-0 bottom-0 w-8 bg-gradient-to-l from-[#030303] to-transparent pointer-events-none z-10 block lg:hidden" />
</div>
{/* Right: Actions */}
<div className="flex-shrink-0 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>
<button
onClick={() => setShowSettings(true)}
title="Settings — API key, local models, preferences"
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"
>
<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>
</div>
</header>
)}
{/* Studio Content */}
<div className="flex-1 min-h-0 relative overflow-hidden">
<div className={activeTab === 'image' ? "h-full w-full" : "hidden"}>
<ImageStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} />
</div>
<div className={activeTab === 'video' ? "h-full w-full" : "hidden"}>
<VideoStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} />
</div>
<div className={activeTab === 'clipping' ? "h-full w-full" : "hidden"}>
<ClippingStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} />
</div>
<div className={activeTab === 'vibe-motion' ? "h-full w-full" : "hidden"}>
<VibeMotionStudio apiKey={apiKey} />
</div>
<div className={activeTab === 'lipsync' ? "h-full w-full" : "hidden"}>
<LipSyncStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} />
</div>
<div className={activeTab === 'body-swap' ? "h-full w-full" : "hidden"}>
<RecastStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} />
</div>
<div className={activeTab === 'cinema' ? "h-full w-full" : "hidden"}>
<CinemaStudio apiKey={apiKey} />
</div>
<div className={activeTab === 'audio' ? "h-full w-full" : "hidden"}>
<AudioStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} />
</div>
<div className={activeTab === 'marketing' ? "h-full w-full" : "hidden"}>
<MarketingStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} />
</div>
<div className={activeTab === 'workflows' ? "h-full w-full" : "hidden"}>
<WorkflowStudio apiKey={apiKey} isHeaderVisible={isHeaderVisible} onToggleHeader={setIsHeaderVisible} />
</div>
<div className={activeTab === 'agents' ? "h-full w-full" : "hidden"}>
<AgentStudio apiKey={apiKey} isHeaderVisible={isHeaderVisible} onToggleHeader={setIsHeaderVisible} />
</div>
<div className={activeTab === 'design-agent' ? "h-full w-full" : "hidden"}>
{activeTab === 'design-agent' && (
<DesignAgentStudio apiKey={apiKey} isHeaderVisible={isHeaderVisible} onToggleHeader={setIsHeaderVisible} />
)}
</div>
<div className={activeTab === 'apps' ? "h-full w-full" : "hidden"}>
<AppsStudio apiKey={apiKey} />
</div>
<div className={activeTab === 'ai-influencer' ? "h-full w-full" : "hidden"}>
<AiInfluencerStudio apiKey={apiKey} />
</div>
</div>
{/* Settings Modal */}
{showSettings && (
<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-[#0a0a0a] border border-white/10 rounded-xl p-8 w-full max-w-sm shadow-2xl">
<h2 className="text-white font-bold text-lg mb-2">Settings</h2>
<p className="text-white/40 text-[13px] mb-8">
Manage your AI studio preferences and authentication.
</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">
<button
onClick={handleKeyChange}
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 Key
</button>
<button
onClick={() => setShowSettings(false)}
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
</button>
</div>
</div>
</div>
)}
</div>
);
}
+9
View 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
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

+602
View File
@@ -0,0 +1,602 @@
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 {
getBundledBinaryResourceDir,
pickBinaryAssetForPlatform,
} = require('./localInferenceAssets');
const {
formatStartupProgressMessage,
parseGenerationProgressChunk,
resolveGenerationSteps,
resolveGuidanceScale,
} = require('./localInferenceRuntime');
const {
LOCAL_AI_DIR_ENV,
resolveLocalAiPaths,
} = require('./localInferencePaths');
// ─── Paths ────────────────────────────────────────────────────────────────────
// Resolved lazily (from register(), after app.whenReady()) so a failure here
// never crashes the process before a window exists — see #232.
let DATA_DIR, BIN_DIR, MODELS_DIR, TMP_DIR;
function ensureLocalAiPaths() {
if (BIN_DIR) return;
const resolved = resolveLocalAiPaths({ userDataPath: app.getPath('userData') });
DATA_DIR = resolved.dataDir;
BIN_DIR = resolved.binDir;
MODELS_DIR = resolved.modelsDir;
TMP_DIR = resolved.tmpDir;
BINARY_PATH = path.join(BIN_DIR, BINARY_NAME);
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';
let BINARY_PATH;
// ─── 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 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}${parsed.pathname}`));
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;
}
function ensureBinaryPermissions() {
if (process.platform === 'win32') return;
for (const fileName of [
BINARY_NAME,
'sd-server',
'libstable-diffusion.dylib',
'libstable-diffusion.so',
]) {
const fullPath = findFile(BIN_DIR, fileName);
if (fullPath) fs.chmodSync(fullPath, 0o755);
}
}
function ensureBundledBinaryInstalled() {
if (fs.existsSync(BINARY_PATH)) {
ensureBinaryPermissions();
return true;
}
if (!app.isPackaged) return false;
const bundledDir = getBundledBinaryResourceDir({
resourcesPath: process.resourcesPath,
platform: process.platform,
arch: process.arch,
});
const bundledBinaryPath = path.join(bundledDir, BINARY_NAME);
if (!fs.existsSync(bundledBinaryPath)) return false;
fs.cpSync(bundledDir, BIN_DIR, { recursive: true, force: true });
ensureBinaryPermissions();
return fs.existsSync(BINARY_PATH);
}
async function getBinaryStatus() {
const exists = ensureBundledBinaryInstalled() || fs.existsSync(BINARY_PATH);
return {
exists,
path: BINARY_PATH,
dataDir: DATA_DIR,
modelsDir: MODELS_DIR,
envVar: LOCAL_AI_DIR_ENV,
};
}
// 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 });
if (ensureBundledBinaryInstalled()) {
send({ phase: 'done', progress: 1 });
return { ok: true, source: 'bundled' };
}
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 = pickBinaryAssetForPlatform({
platform: process.platform,
arch: process.arch,
zipNames: 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.');
}
if (process.platform === 'linux' && process.arch === 'arm64') {
throw new Error('No upstream stable-diffusion.cpp binary found for linux-arm64. Install a build that bundles local-ai/linux-arm64/bin or provide the binary manually.');
}
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);
}
ensureBinaryPermissions();
// 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 });
try {
await downloadFile(model.downloadUrl, destPath, (p) => {
send({ phase: 'downloading', progress: p });
});
} catch (err) {
throw new Error(`Failed to download "${model.name}" (id: ${model.id}, url: ${model.downloadUrl}): ${err.message}`);
}
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 });
try {
await downloadFile(aux.downloadUrl, destPath, (p) => {
send({ phase: 'downloading', progress: p });
});
} catch (err) {
throw new Error(`Failed to download "${aux.displayName}" (id: ${aux.id}, url: ${aux.downloadUrl}): ${err.message}`);
}
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);
ensureBundledBinaryInstalled();
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 = resolveGenerationSteps(params, model);
const cfgScale = resolveGuidanceScale(params, model);
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) => {
const startupStartedAt = Date.now();
let startupHeartbeat = null;
let samplingStarted = false;
const sendStartupProgress = () => {
send({
step: 0,
totalSteps: steps,
status: 'starting',
progress: 0,
message: formatStartupProgressMessage(Date.now() - startupStartedAt),
});
};
const stopStartupHeartbeat = () => {
if (startupHeartbeat) {
clearInterval(startupHeartbeat);
startupHeartbeat = null;
}
};
sendStartupProgress();
startupHeartbeat = setInterval(() => {
if (!samplingStarted) sendStartupProgress();
}, 5000);
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 progressState = { tail: '', lastStep: 0, lastTotalSteps: 0 };
const outputLines = [];
const handleOutput = (data) => {
const line = data.toString();
outputLines.push(line.trimEnd());
const progressEvents = parseGenerationProgressChunk(line, progressState);
for (const event of progressEvents) {
samplingStarted = true;
stopStartupHeartbeat();
send({ ...event, status: 'generating' });
}
};
activeProcess.stdout.on('data', handleOutput);
activeProcess.stderr.on('data', handleOutput);
activeProcess.on('close', (code) => {
stopStartupHeartbeat();
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');
const killed = code === null;
const hint = killed
? 'sd-cli was terminated before finishing (often OOM on Z-Image/SDXL — try a smaller SD 1.5 model or close other apps). '
: '';
reject(new Error(`${hint}sd-cli exited (code ${code ?? 'signal'}):\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: steps, totalSteps: steps, status: 'done', progress: 1 });
resolve({ url: dataUrl, seed });
} catch (err) {
reject(err);
}
});
activeProcess.on('error', (err) => {
stopStartupHeartbeat();
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() {
ensureLocalAiPaths();
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,
};
+49
View File
@@ -0,0 +1,49 @@
const path = require('path');
function pickBinaryAssetForPlatform({ platform, arch, zipNames }) {
const isSdCliZip = (name) => name.startsWith('sd-master-') || name.includes('-bin-');
const candidates = zipNames.filter(isSdCliZip);
if (platform === 'darwin') {
if (arch !== 'arm64') return null;
return candidates.find((name) => name.includes('Darwin') && name.includes('arm64')) || null;
}
if (platform === 'win32') {
const winCandidates = candidates.filter((name) => /win-(avx2?|avx512|noavx|cuda12|cu12)-x64/.test(name));
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((name) => name.includes(tag));
if (hit) return hit;
}
return null;
}
if (platform === 'linux' && arch === 'arm64') {
const linuxArmCandidates = candidates.filter((name) =>
name.includes('Linux') && (name.includes('aarch64') || name.includes('arm64'))
);
const plain = linuxArmCandidates.find((name) => !name.includes('rocm') && !name.includes('vulkan'));
return plain
|| linuxArmCandidates.find((name) => name.includes('vulkan'))
|| linuxArmCandidates.find((name) => name.includes('rocm'))
|| null;
}
const linuxCandidates = candidates.filter((name) => name.includes('Linux') && name.includes('x86_64'));
const plain = linuxCandidates.find((name) => !name.includes('rocm') && !name.includes('vulkan'));
return plain
|| linuxCandidates.find((name) => name.includes('vulkan'))
|| linuxCandidates.find((name) => name.includes('rocm'))
|| null;
}
function getBundledBinaryResourceDir({ resourcesPath, platform, arch }) {
const pathLib = platform === 'win32' ? path.win32 : path.posix;
return pathLib.join(resourcesPath, 'local-ai', `${platform}-${arch}`, 'bin');
}
module.exports = {
getBundledBinaryResourceDir,
pickBinaryAssetForPlatform,
};
+29
View File
@@ -0,0 +1,29 @@
const path = require('path');
const LOCAL_AI_DIR_ENV = 'OPEN_GENERATIVE_AI_LOCAL_AI_DIR';
function normalizeDirOverride(value) {
return typeof value === 'string' ? value.trim() : '';
}
function resolveLocalAiPaths({ userDataPath, env = process.env } = {}) {
const customDir = normalizeDirOverride(env[LOCAL_AI_DIR_ENV]);
if (!customDir && !userDataPath) {
throw new Error(`userDataPath is required when ${LOCAL_AI_DIR_ENV} is not set`);
}
const dataDir = path.resolve(customDir || path.join(userDataPath, 'local-ai'));
return {
dataDir,
binDir: path.join(dataDir, 'bin'),
modelsDir: path.join(dataDir, 'models'),
tmpDir: path.join(dataDir, 'tmp'),
};
}
module.exports = {
LOCAL_AI_DIR_ENV,
resolveLocalAiPaths,
};
+104
View File
@@ -0,0 +1,104 @@
function coerceFiniteNumber(value) {
if (value === null || value === undefined || value === '') return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function resolveGenerationSteps(params, model) {
const requested = coerceFiniteNumber(params?.steps);
if (requested !== null && requested > 0) {
return Math.max(1, Math.round(requested));
}
const modelDefault = coerceFiniteNumber(model?.defaultSteps);
if (modelDefault !== null && modelDefault > 0) {
return Math.max(1, Math.round(modelDefault));
}
return 20;
}
function resolveGuidanceScale(params, model) {
const requested = coerceFiniteNumber(params?.guidance_scale);
if (requested !== null) return requested;
const modelDefault = coerceFiniteNumber(model?.defaultGuidance);
if (modelDefault !== null) return modelDefault;
return 7.5;
}
function formatStartupProgressMessage(elapsedMs) {
const seconds = Math.max(0, Math.floor((Number(elapsedMs) || 0) / 1000));
if (seconds < 10) return 'Starting local model...';
if (seconds < 60) return `Loading local model (${seconds}s)...`;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `Loading local model (${minutes}m ${remainingSeconds}s)...`;
}
function stripAnsiSequences(text) {
return text.replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, '');
}
function extractProgressEvents(text) {
const events = [];
const patterns = [
/step\s+(\d+)\s*\/\s*(\d+)/ig,
/(\d+)\s*\/\s*(\d+)\s*-\s*[\d.]+s\/it/ig,
];
for (const pattern of patterns) {
let match = pattern.exec(text);
while (match) {
const step = Number.parseInt(match[1], 10);
const totalSteps = Number.parseInt(match[2], 10);
if (Number.isFinite(step) && Number.isFinite(totalSteps) && totalSteps > 0) {
events.push({
step,
totalSteps,
progress: Math.min(1, step / totalSteps),
});
}
match = pattern.exec(text);
}
}
events.sort((left, right) => {
if (left.totalSteps !== right.totalSteps) return left.totalSteps - right.totalSteps;
return left.step - right.step;
});
return events;
}
function parseGenerationProgressChunk(chunk, state = { tail: '', lastStep: 0, lastTotalSteps: 0 }) {
const normalizedChunk = stripAnsiSequences(String(chunk)).replace(/\r/g, '\n');
const combined = `${state.tail}${normalizedChunk}`;
const events = extractProgressEvents(combined);
const freshEvents = [];
for (const event of events) {
if (event.totalSteps !== state.lastTotalSteps) {
state.lastTotalSteps = event.totalSteps;
state.lastStep = 0;
}
if (event.step > state.lastStep) {
state.lastStep = event.step;
freshEvents.push(event);
}
}
state.tail = combined.slice(-1024);
return freshEvents;
}
module.exports = {
coerceFiniteNumber,
extractProgressEvents,
formatStartupProgressMessage,
parseGenerationProgressChunk,
resolveGenerationSteps,
resolveGuidanceScale,
stripAnsiSequences,
};
+131
View 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 };
+33
View File
@@ -0,0 +1,33 @@
function withWan2gpAvailability(model, probeResult, cachedResolution) {
if (!probeResult?.ok) {
return {
...model,
ready: false,
unavailableReason: probeResult?.error || 'Wan2GP probe failed',
};
}
const apiNames = Array.isArray(cachedResolution?.apiNames) ? cachedResolution.apiNames : [];
const realFn = cachedResolution?.resolved?.get?.(model.id) || null;
if (realFn) {
return { ...model, ready: true, fn: realFn };
}
if (apiNames.length === 0) {
return {
...model,
ready: true,
fn: model.fn,
availabilityNote: 'Wan2GP did not expose endpoint metadata; using the default api_name.',
};
}
return {
...model,
ready: false,
unavailableReason: `Wan2GP server has no api_name matching "${model.fn}". Check Wan2GP version or load this model in its UI.`,
};
}
module.exports = { withWan2gpAvailability };
+456
View File
@@ -0,0 +1,456 @@
// 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 { withWan2gpAvailability } = require('./wan2gpModelAvailability');
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 => withWan2gpAvailability(m, probeRes, cached));
}
// ─── 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 };
+95
View File
@@ -0,0 +1,95 @@
const { app, BrowserWindow, shell, dialog } = require('electron');
const path = require('path');
const { register: registerLocalInference } = require('./lib/localInference');
const { register: registerWan2gp } = require('./lib/wan2gpProvider');
process.on('uncaughtException', (err) => {
console.error('Uncaught exception:', err);
try {
dialog.showErrorBox('Open Generative AI — Unexpected Error', err && err.stack ? err.stack : String(err));
} catch (_) {
// dialog unavailable this early; the console log above is the fallback
}
});
// Ubuntu 24.04+ sets kernel.apparmor_restrict_unprivileged_userns=1 which
// 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;
function createWindow() {
const isMac = process.platform === 'darwin';
mainWindow = new BrowserWindow({
width: 1440,
height: 900,
minWidth: 1024,
minHeight: 640,
webPreferences: {
webSecurity: true,
contextIsolation: true,
nodeIntegration: false,
preload: path.join(__dirname, 'preload.js'),
},
...(isMac ? { titleBarStyle: 'hiddenInset' } : {}),
backgroundColor: '#0d0d0d',
show: false,
title: 'Open Generative AI',
});
const indexPath = path.join(__dirname, '../dist/index.html');
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);
});
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: 'deny' };
});
mainWindow.once('ready-to-show', () => {
mainWindow.show();
});
mainWindow.on('closed', () => {
mainWindow = null;
});
}
app.whenReady().then(() => {
createWindow();
try {
registerLocalInference();
registerWan2gp();
} catch (err) {
console.error('Failed to register local-ai/wan2gp handlers:', err);
dialog.showErrorBox(
'Local AI features unavailable',
`Open Generative AI started, but local model support failed to initialize:\n\n${err.message}`
);
}
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
+41
View 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);
},
});
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Open Generative AI — a free, open-source AI image and video generation studio. Generate AI images and cinematic shots with 20+ models including Flux, SDXL, Ideogram, and Midjourney." />
<meta name="keywords" content="open source ai, ai image generator, ai cinema studio, flux ai, ai video generation, open source ai image generation" />
<title>Open Generative AI — Free Open-Source AI Image and Video Studio</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./*"],
"ai-agent": ["./packages/Open-Poe-AI/packages/agents/src/index.js"],
"workflow-builder": ["./packages/Vibe-Workflow/packages/workflow-builder/src/index.js"],
"design-agent": ["./packages/Open-AI-Design-Agent/packages/design-agent/src/index.js"]
}
}
}
+54
View File
@@ -0,0 +1,54 @@
import { NextResponse } from 'next/server';
function addSecurityHeaders(response) {
// Prevent MIME type sniffing (CWE-693)
response.headers.set('X-Content-Type-Options', 'nosniff');
// Prevent clickjacking (CWE-1021)
response.headers.set('X-Frame-Options', 'DENY');
// Enable XSS filter in legacy browsers
response.headers.set('X-XSS-Protection', '1; mode=block');
// Referrer policy
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
// Content Security Policy - restricts script sources to prevent XSS (CWE-79).
// connect-src covers *.muapi.ai (not just api.muapi.ai) because generated
// media, model thumbnails, and other assets are served from cdn.muapi.ai
// and other muapi subdomains that the renderer fetches directly.
response.headers.set(
'Content-Security-Policy',
"default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; media-src 'self' data: blob: https:; connect-src 'self' https://muapi.ai https://*.muapi.ai; font-src 'self' data:;"
);
return response;
}
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) {
// Exclude paths that have their own dedicated route handlers with custom logic
const isHandledByRoute = url.pathname.startsWith('/api/v1/creative-agent') ||
url.pathname.startsWith('/api/v1/get_upload_url') ||
url.pathname.startsWith('/api/v1/upload-binary');
if (url.pathname.startsWith('/api/v1') && !isHandledByRoute) {
const targetUrl = new URL(url.pathname + url.search, 'https://api.muapi.ai');
const rewriteResponse = NextResponse.rewrite(targetUrl);
return addSecurityHeaders(rewriteResponse);
}
}
// Add security headers to all responses
return addSecurityHeaders(NextResponse.next());
}
// Match all paths for security headers. Exclude Next.js internal paths.
export const config = {
matcher: [
'/api/:path*',
'/((?!_next/static|_next/image|favicon.ico|__nextjs_original-stack-frame).*)',
],
};
+2194
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
transpilePackages: ['studio', 'ai-agent', 'workflow-builder', 'design-agent'],
};
export default nextConfig;
+16873
View File
File diff suppressed because it is too large Load Diff
+140
View File
@@ -0,0 +1,140 @@
{
"name": "open-generative-ai",
"description": "Open-source alternative to HF AI — AI image, video, cinema and lip sync studio",
"homepage": "https://github.com/Anil-matcha/Open-Generative-AI",
"private": true,
"version": "2.0.0",
"license": "MIT",
"workspaces": [
"packages/studio",
"packages/Vibe-Workflow/packages/workflow-builder",
"packages/Open-Poe-AI/packages/agents",
"packages/Open-AI-Design-Agent/packages/design-agent"
],
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"build:studio": "npm run build -w studio",
"build:workflow": "npm run build -w workflow-builder",
"build:agent": "npm run build -w ai-agent",
"build:design": "npm run build -w design-agent",
"build:packages": "npm run build:workflow && npm run build:agent && npm run build:design && npm run build:studio",
"setup": "git submodule update --init --recursive && npm install && npm run build:packages",
"vite:dev": "vite",
"vite:build": "vite build",
"electron:dev": "npm run vite:build && electron .",
"electron:build": "vite build && electron-builder --mac",
"electron:build:win": "vite build && electron-builder --win",
"electron:build:linux": "vite build && electron-builder --linux",
"electron:build:linux:dir": "vite build && electron-builder --linux dir",
"electron:build:linux:arm64:dir": "vite build && electron-builder --linux dir --arm64",
"stage:local-ai": "node scripts/stage-local-ai-binary.js",
"package:linux:deb": "node scripts/package-linux-deb.js",
"package:linux:deb:arm64": "node scripts/package-linux-deb.js --arch arm64",
"package:linux:deb:x64": "node scripts/package-linux-deb.js --arch x64",
"electron:build:all": "vite build && electron-builder --mac --win --linux"
},
"build": {
"appId": "ai.generative.open",
"productName": "Open Generative AI",
"copyright": "Copyright © 2025",
"directories": {
"output": "release"
},
"afterPack": "./afterPack.js",
"files": [
"dist/**/*",
"electron/**/*"
],
"extraResources": [
{
"from": "build/local-ai",
"to": "local-ai",
"filter": [
"**/*"
]
}
],
"mac": {
"category": "public.app-category.graphics-design",
"icon": "public/banner.png",
"gatekeeperAssess": false,
"target": [
{
"target": "dmg",
"arch": [
"x64",
"arm64"
]
}
]
},
"win": {
"icon": "public/banner.png",
"signAndEditExecutable": false,
"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": {
"axios": "^1.7.0",
"next": "^15.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hot-toast": "^2.4.1",
"studio": "*",
"workflow-builder": "file:./packages/Vibe-Workflow/packages/workflow-builder",
"ai-agent": "file:./packages/Open-Poe-AI/packages/agents"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/vite": "^4.1.18",
"autoprefixer": "^10.4.24",
"electron": "^33.4.11",
"electron-builder": "^25.1.8",
"eslint": "^9",
"eslint-config-next": "^15.0.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.0",
"vite": "^5.4.0"
},
"main": "electron/main.js"
}
+6
View File
@@ -0,0 +1,6 @@
{
"presets": [
"@babel/preset-env",
["@babel/preset-react", { "runtime": "automatic" }]
]
}
+3645
View File
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
{
"name": "studio",
"version": "1.0.0",
"description": "Open Generative AI studio components for Muapi",
"main": "src/index.js",
"module": "src/index.js",
"files": [
"src",
"dist"
],
"scripts": {
"build:css": "tailwindcss -i ./src/tailwind.css -o ./dist/tailwind.css --minify",
"build": "npm run build:css && babel src --out-dir dist --extensions .js,.jsx"
},
"license": "MIT",
"dependencies": {
"@xyflow/react": "^12.10.2",
"axios": "^1.7.0",
"lucide-react": "^1.8.0",
"react-hot-toast": "^2.4.1",
"react-icons": "^5.0.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",
"design-agent": "file:../Open-AI-Design-Agent/packages/design-agent"
},
"peerDependencies": {
"react": ">=18.0.0",
"react-dom": ">=18.0.0"
},
"devDependencies": {
"@babel/cli": "^7.28.3",
"@babel/preset-env": "^7.28.5",
"@babel/preset-react": "^7.28.5",
"autoprefixer": "^10.4.14",
"postcss": "^8.4.24",
"tailwindcss": "^3.3.3"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
@@ -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-[#22d3ee]/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-[#22d3ee] uppercase tracking-wider mb-1 opacity-80">
{agent.category || "AI Assistant"}
</div>
<h3 className="text-sm font-bold text-white truncate group-hover:text-[#22d3ee] 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-[#22d3ee] 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-[#22d3ee]/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-[#22d3ee] 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-[#22d3ee]">
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-[#22d3ee] 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-[#22d3ee] 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-[#22d3ee] hover:text-white border border-[#22d3ee]/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>
);
}
@@ -0,0 +1,746 @@
"use client";
import { useState, useCallback } from "react";
import { generateImage } from "../muapi.js";
const CDN = "https://cdn.muapi.ai/influencer";
// ── Default image generation model ──────────────────────────────────────────
const INFLUENCER_MODEL = "nano-banana-pro";
const TABS_CONFIG = {
face: {
label: "Face",
subcategories: [
{
id: "character_type",
label: "Character Type",
options: [
{ id: "human", label: "Human", img: `${CDN}/character_type_human.webp`, promptVal: "human features" },
{ id: "elf", label: "Elf", img: `${CDN}/character_type_elf.webp`, promptVal: "elf with pointed ears" },
{ id: "alien", label: "Alien", img: `${CDN}/character_type_alien.webp`, promptVal: "alien creature" },
{ id: "amphibian", label: "Amphibian",img: `${CDN}/character_type_amphibian.webp`, promptVal: "amphibian humanoid" },
{ id: "reptile", label: "Reptile", img: `${CDN}/character_type_reptile.webp`, promptVal: "reptilian creature" },
{ id: "mantis", label: "Mantis", img: `${CDN}/character_type_mantis.webp`, promptVal: "mantis hybrid character" },
{ id: "bee", label: "Bee", img: `${CDN}/character_type_bee.webp`, promptVal: "bee insect hybrid character" },
{ id: "octopus", label: "Octopus", img: `${CDN}/character_type_octopus.webp`, promptVal: "aquatic octopus hybrid" },
{ id: "crocodile", label: "Crocodile",img: `${CDN}/character_type_crocodile.webp`, promptVal: "crocodile humanoid" },
{ id: "iguana", label: "Iguana", img: `${CDN}/character_type_iguana.webp`, promptVal: "iguana humanoid" },
{ id: "lizard", label: "Lizard", img: `${CDN}/character_type_lizard.webp`, promptVal: "lizard humanoid" },
{ id: "rhinoceros_beetle", label: "Beetle", img: `${CDN}/character_type_rhinoceros_beetle.webp`, promptVal: "rhinoceros beetle humanoid" },
{ id: "ant", label: "Ant", img: `${CDN}/character_type_ant.webp`, promptVal: "ant hybrid character" },
],
},
{
id: "gender",
label: "Gender",
options: [
{ id: "female", label: "Female", img: `${CDN}/gender_female.webp`, promptVal: "female" },
{ id: "male", label: "Male", img: `${CDN}/gender_male.webp`, promptVal: "male" },
{ id: "non_binary", label: "Non-binary", img: `${CDN}/gender_non_binary.webp`, promptVal: "non-binary character" },
{ id: "trans_man", label: "Trans Man", img: `${CDN}/gender_trans_man.webp`, promptVal: "transgender man" },
{ id: "trans_woman", label: "Trans Woman", img: `${CDN}/gender_trans_woman.webp`, promptVal: "transgender woman" },
],
},
{
id: "ethnicity_origin_base",
label: "Ethnicity / Origin",
options: [
{ id: "african", label: "African", img: `${CDN}/ethnicity_origin_base_african.webp`, promptVal: "african heritage" },
{ id: "asian", label: "Asian", img: `${CDN}/ethnicity_origin_base_recreate_in_east_asian_supermodel__korea.webp`, promptVal: "East Asian supermodel, Korean K-Pop Idol phenotype" },
{ id: "european", label: "European", img: `${CDN}/ethnicity_origin_base_scandinavian_supermodel.webp`, promptVal: "Scandinavian Supermodel" },
{ id: "indian", label: "Indian", img: `${CDN}/ethnicity_origin_base_indian.webp`, promptVal: "south asian indian heritage" },
{ id: "middle_eastern", label: "Middle Eastern",img: `${CDN}/ethnicity_origin_base_middle_eastern.webp`, promptVal: "middle eastern heritage" },
{ id: "mixed", label: "Mixed", img: `${CDN}/ethnicity_origin_base_mixed.webp`, promptVal: "multiracial mixed heritage" },
],
},
{
id: "eye_color",
label: "Eye Color",
options: [
{ id: "eye_blue", label: "Blue", img: `${CDN}/eye_color_eye_blue.webp`, promptVal: "striking blue eyes" },
{ id: "eye_brown", label: "Brown", img: `${CDN}/eye_color_eye_brown.webp`, promptVal: "warm brown eyes" },
{ id: "eye_green", label: "Green", img: `${CDN}/eye_color_eye_green.webp`, promptVal: "emerald green eyes" },
{ id: "eye_amber", label: "Amber", img: `${CDN}/eye_color_eye_amber.webp`, promptVal: "amber eyes" },
{ id: "eye_grey", label: "Grey", img: `${CDN}/eye_color_eye_grey.webp`, promptVal: "grey eyes" },
{ id: "eye_red", label: "Red", img: `${CDN}/eye_color_eye_red.webp`, promptVal: "red eyes" },
{ id: "eye_purple", label: "Purple", img: `${CDN}/eye_color_eye_purple.webp`, promptVal: "violet purple eyes" },
{ id: "eye_black", label: "Black", img: `${CDN}/eye_color_eye_black.webp`, promptVal: "black eyes" },
{ id: "eye_deep_brown", label: "Deep Brown", img: `${CDN}/eye_color_eye_deep_brown.webp`, promptVal: "deep dark brown eyes" },
{ id: "eye_white", label: "White", img: `${CDN}/eye_color_eye_white.webp`, promptVal: "white eyes" },
{ id: "eye_black_void", label: "Solid Black", img: `${CDN}/eye_color_eye_black_void.webp`, promptVal: "solid black void eyes" },
{ id: "eye_white_void", label: "Blind / Empty",img: `${CDN}/eye_color_eye_white_void.webp`, promptVal: "blind empty white eyes" },
],
},
{
id: "eyes_type",
label: "Eye Type",
options: [
{ id: "eyes_human", label: "Human", img: `${CDN}/eyes_type_eyes_human.webp`, promptVal: "normal human eyes" },
{ id: "eyes_reptile", label: "Reptile", img: `${CDN}/eyes_type_eyes_reptile.webp`, promptVal: "reptile slit-pupil eyes" },
{ id: "eyes_mechanical", label: "Mechanical",img: `${CDN}/eyes_type_eyes_mechanical.webp`, promptVal: "mechanical cyborg eyes" },
],
},
{
id: "eyes_details",
label: "Eye Features",
options: [
{ id: "eyes_different_colors", label: "Heterochromia", img: `${CDN}/eyes_details_eyes_different_colors.webp`, promptVal: "heterochromia different eye colors" },
{ id: "eyes_blind", label: "Blind Eye", img: `${CDN}/eyes_details_eyes_blind.webp`, promptVal: "one cloudy blind eye" },
{ id: "eyes_scarred", label: "Scarred Eye", img: `${CDN}/eyes_details_eyes_scarred.webp`, promptVal: "scar running across one eye" },
{ id: "eyes_glowing", label: "Glowing Eye", img: `${CDN}/eyes_details_eyes_glowing.webp`, promptVal: "glowing magical eyes" },
],
},
{
id: "mouth",
label: "Mouth & Teeth",
options: [
{ id: "mouth_small", label: "Small Mouth", img: `${CDN}/mouth_mouth_small.webp`, promptVal: "small delicate mouth" },
{ id: "mouth_large", label: "Large Mouth", img: `${CDN}/mouth_mouth_large.webp`, promptVal: "wide expressive mouth" },
{ id: "mouth_no_teeth", label: "No Teeth", img: `${CDN}/mouth_mouth_no_teeth.webp`, promptVal: "no visible teeth" },
{ id: "mouth_different_teeth", label: "Unique Teeth", img: `${CDN}/mouth_mouth_different_teeth.webp`, promptVal: "unusual tooth structure" },
{ id: "mouth_sharp_teeth", label: "Sharp Teeth", img: `${CDN}/mouth_mouth_sharp_teeth.webp`, promptVal: "sharp predatory fangs" },
{ id: "mouth_forked_tongue", label: "Forked Tongue", img: `${CDN}/mouth_mouth_forked_tongue.webp`, promptVal: "reptilian forked tongue" },
{ id: "mouth_two_tongues", label: "Two Tongues", img: `${CDN}/mouth_mouth_two_tongues.webp`, promptVal: "two separate tongues" },
],
},
{
id: "ears",
label: "Ears",
options: [
{ id: "ears_human", label: "Human", img: `${CDN}/ears_ears_human.webp`, promptVal: "normal human ears" },
{ id: "ears_elf", label: "Elf Ears", img: `${CDN}/ears_ears_elf.webp`, promptVal: "pointed elf ears" },
{ id: "ears_no", label: "No Ears", img: `${CDN}/ears_ears_no.webp`, promptVal: "no visible ears" },
{ id: "ears_wings", label: "Wing Ears", img: `${CDN}/ears_ears_wings.webp`, promptVal: "wing ears" },
],
},
{
id: "horns",
label: "Horns",
options: [
{ id: "small_horns", label: "Small Horns", img: `${CDN}/horns_small_horns.webp`, promptVal: "small horns on forehead" },
{ id: "big_horns", label: "Big Horns", img: `${CDN}/horns_big_horns.webp`, promptVal: "large curved horns" },
{ id: "antlers", label: "Antlers", img: `${CDN}/horns_antlers.webp`, promptVal: "deer antlers on head" },
],
},
{
id: "skin_conditions",
label: "Skin Conditions",
options: [
{ id: "condition_vitiligo", label: "Vitiligo", img: `${CDN}/skin_conditions_condition_vitiligo.webp`, promptVal: "vitiligo skin condition" },
{ id: "condition_pigmentation", label: "Pigmentation", img: `${CDN}/skin_conditions_condition_pigmentation.webp`, promptVal: "hyperpigmentation" },
{ id: "condition_freckles", label: "Freckles", img: `${CDN}/skin_conditions_condition_freckles.webp`, promptVal: "freckled skin" },
{ id: "condition_birthmarks", label: "Birthmarks", img: `${CDN}/skin_conditions_condition_birthmarks.webp`, promptVal: "visible birthmarks" },
{ id: "condition_scars", label: "Scars", img: `${CDN}/skin_conditions_condition_scars.webp`, promptVal: "scarred skin" },
{ id: "condition_burns", label: "Burns", img: `${CDN}/skin_conditions_condition_burns.webp`, promptVal: "burn marks on skin" },
{ id: "condition_albinism", label: "Albinism", img: `${CDN}/skin_conditions_condition_albinism.webp`, promptVal: "albinism pale white skin" },
{ id: "condition_cracked", label: "Cracked Skin", img: `${CDN}/skin_conditions_condition_cracked.webp`, promptVal: "cracked dry skin texture" },
{ id: "condition_wrinkled", label: "Wrinkled", img: `${CDN}/skin_conditions_condition_wrinkled.webp`, promptVal: "wrinkled aged skin" },
],
},
],
},
body: {
label: "Body",
subcategories: [
{
id: "face_skin_material",
label: "Face Skin Material",
options: [
{ id: "face_skin_human", label: "Human Skin", img: `${CDN}/face_skin_material_face_skin_human.webp`, promptVal: "smooth human skin" },
{ id: "face_skin_scales", label: "Scales", img: `${CDN}/face_skin_material_face_skin_scales.webp`, promptVal: "shimmering scales" },
{ id: "face_skin_fur", label: "Fur", img: `${CDN}/face_skin_material_face_skin_fur.webp`, promptVal: "soft fur covered face" },
{ id: "face_skin_amphibian", label: "Amphibian", img: `${CDN}/face_skin_material_face_skin_amphibian.webp`, promptVal: "smooth moist amphibian skin" },
{ id: "face_skin_fish", label: "Fish Skin", img: `${CDN}/face_skin_material_face_skin_fish.webp`, promptVal: "iridescent fish scale skin" },
{ id: "face_skin_metallic", label: "Metallic", img: `${CDN}/face_skin_material_face_skin_metallic.webp`, promptVal: "polished metallic skin" },
],
},
{
id: "face_surface_pattern",
label: "Skin Pattern",
options: [
{ id: "face_pattern_solid", label: "Solid", img: `${CDN}/face_surface_pattern_face_pattern_solid.webp`, promptVal: "solid color skin" },
{ id: "face_pattern_stripes", label: "Stripes", img: `${CDN}/face_surface_pattern_face_pattern_stripes.webp`, promptVal: "exotic striped skin pattern" },
{ id: "face_pattern_spots", label: "Spots", img: `${CDN}/face_surface_pattern_face_pattern_spots.webp`, promptVal: "dappled spotted skin" },
{ id: "face_pattern_chess", label: "Chess", img: `${CDN}/face_surface_pattern_face_pattern_chess.webp`, promptVal: "checkerboard skin pattern" },
{ id: "face_pattern_veins", label: "Veins", img: `${CDN}/face_surface_pattern_face_pattern_veins.webp`, promptVal: "translucent skin with neon veins" },
{ id: "face_pattern_gradient", label: "Gradient", img: `${CDN}/face_surface_pattern_face_pattern_gradient.webp`, promptVal: "gradient skin coloring" },
{ id: "face_pattern_giraffe", label: "Giraffe", img: `${CDN}/face_surface_pattern_face_pattern_giraffe.webp`, promptVal: "giraffe print skin markings" },
],
},
{
id: "body_type",
label: "Body Type",
options: [
{ id: "body_slim", label: "Slim", img: `${CDN}/body_type_body_slim.webp`, promptVal: "slim slender physique" },
{ id: "body_lean", label: "Lean", img: `${CDN}/body_type_body_lean.webp`, promptVal: "lean toned physique" },
{ id: "body_athletic", label: "Athletic", img: `${CDN}/body_type_body_athletic.webp`, promptVal: "fit athletic body" },
{ id: "body_muscular", label: "Muscular", img: `${CDN}/body_type_body_muscular.webp`, promptVal: "strong muscular build" },
{ id: "body_curvy", label: "Curvy", img: `${CDN}/body_type_body_curvy.webp`, promptVal: "curvy body type" },
{ id: "body_heavy", label: "Heavy", img: `${CDN}/body_type_body_heavy.webp`, promptVal: "heavy set build" },
{ id: "body_skinny", label: "Skinny", img: `${CDN}/body_type_body_skinny.webp`, promptVal: "very skinny thin build" },
],
},
{
id: "left_arm",
label: "Left Arm",
options: [
{ id: "left_arm_normal", label: "Normal", img: `${CDN}/left_arm_left_arm_normal.webp`, promptVal: "normal left arm" },
{ id: "left_arm_cute", label: "Cute Prosthetic",img: `${CDN}/left_arm_make_left_arm_stylish_pink_prosthetic_wi.webp`, promptVal: "stylish pink prosthetic left arm with cute stickers" },
{ id: "left_arm_robotic", label: "Robotic", img: `${CDN}/left_arm_left_arm_robotic.webp`, promptVal: "robotic left arm" },
{ id: "left_arm_prosthetic", label: "Prosthetic", img: `${CDN}/left_arm_left_arm_prosthetic.webp`, promptVal: "prosthetic left arm" },
{ id: "left_arm_mechanical", label: "Mechanical", img: `${CDN}/left_arm_left_arm_mechanical.webp`, promptVal: "mechanical left arm" },
{ id: "left_arm_none", label: "None", img: `${CDN}/left_arm_left_arm_none.webp`, promptVal: "no left arm" },
],
},
{
id: "right_arm",
label: "Right Arm",
options: [
{ id: "right_arm_normal", label: "Normal", img: `${CDN}/right_arm_right_arm_normal.webp`, promptVal: "normal right arm" },
{ id: "right_arm_cute", label: "Cute Prosthetic",img: `${CDN}/right_arm_make_right_arm_stylish_pink_prosthetic_w.webp`, promptVal: "stylish pink prosthetic right arm with cute stickers" },
{ id: "right_arm_robotic", label: "Robotic", img: `${CDN}/right_arm_right_arm_robotic.webp`, promptVal: "robotic right arm" },
{ id: "right_arm_prosthetic", label: "Prosthetic", img: `${CDN}/right_arm_right_arm_prosthetic.webp`, promptVal: "prosthetic right arm" },
{ id: "right_arm_mechanical", label: "Mechanical", img: `${CDN}/right_arm_right_arm_mechanical.webp`, promptVal: "mechanical right arm" },
{ id: "right_arm_none", label: "None", img: `${CDN}/right_arm_right_arm_none.webp`, promptVal: "no right arm" },
],
},
{
id: "left_leg",
label: "Left Leg",
options: [
{ id: "left_leg_normal", label: "Normal", img: `${CDN}/left_leg_left_leg_normal.webp`, promptVal: "normal left leg" },
{ id: "left_leg_cute", label: "Cute Prosthetic",img: `${CDN}/left_leg_make_left_leg_stylish_pink_prosthetic_wi.webp`, promptVal: "stylish pink prosthetic left leg with cute stickers" },
{ id: "left_leg_robotic", label: "Robotic", img: `${CDN}/left_leg_left_leg_robotic.webp`, promptVal: "robotic left leg" },
{ id: "left_leg_prosthetic", label: "Prosthetic", img: `${CDN}/left_leg_left_leg_prosthetic.webp`, promptVal: "prosthetic left leg" },
{ id: "left_leg_mechanical", label: "Mechanical", img: `${CDN}/left_leg_left_leg_mechanical.webp`, promptVal: "mechanical left leg" },
{ id: "left_leg_none", label: "None", img: `${CDN}/left_leg_left_leg_none.webp`, promptVal: "no left leg" },
],
},
{
id: "right_leg",
label: "Right Leg",
options: [
{ id: "right_leg_normal", label: "Normal", img: `${CDN}/right_leg_right_leg_normal.webp`, promptVal: "normal right leg" },
{ id: "right_leg_cute", label: "Cute Prosthetic",img: `${CDN}/right_leg_make_right_leg_stylish_pink_prosthetic_w.webp`, promptVal: "stylish pink prosthetic right leg with cute stickers" },
{ id: "right_leg_robotic", label: "Robotic", img: `${CDN}/right_leg_right_leg_robotic.webp`, promptVal: "robotic right leg" },
{ id: "right_leg_prosthetic", label: "Prosthetic", img: `${CDN}/right_leg_right_leg_prosthetic.webp`, promptVal: "prosthetic right leg" },
{ id: "right_leg_mechanical", label: "Mechanical", img: `${CDN}/right_leg_right_leg_mechanical.webp`, promptVal: "mechanical right leg" },
{ id: "right_leg_none", label: "None", img: `${CDN}/right_leg_right_leg_none.webp`, promptVal: "no right leg" },
],
},
],
},
style: {
label: "Style",
subcategories: [
{
id: "hair",
label: "Hair / Head Growth",
options: [
{ id: "hair_bald", label: "Bald", img: `${CDN}/hair_hair_bald.webp`, promptVal: "bald head" },
{ id: "hair_short", label: "Short Hair", img: `${CDN}/hair_hair_short.webp`, promptVal: "short hair" },
{ id: "hair_long", label: "Long Hair", img: `${CDN}/hair_hair_long.webp`, promptVal: "long flowing hair" },
{ id: "hair_afro", label: "Afro", img: `${CDN}/hair_hair_afro.webp`, promptVal: "afro hairstyle" },
{ id: "hair_punk", label: "Punk", img: `${CDN}/hair_hair_punk.webp`, promptVal: "punk mohawk hairstyle" },
{ id: "hair_fur", label: "Fur / Mane", img: `${CDN}/hair_hair_fur.webp`, promptVal: "fur mane on head" },
{ id: "hair_tentacles", label: "Tentacles", img: `${CDN}/hair_hair_tentacles.webp`, promptVal: "tentacles as hair" },
{ id: "hair_spines", label: "Spines", img: `${CDN}/hair_hair_spines.webp`, promptVal: "spines as hair" },
],
},
{
id: "accessories",
label: "Accessories & Markings",
options: [
{ id: "accessory_tattoos", label: "Tattoos", img: `${CDN}/accessories_accessory_tattoos.webp`, promptVal: "covered in tattoos" },
{ id: "accessory_piercing", label: "Piercings", img: `${CDN}/accessories_accessory_piercing.webp`, promptVal: "multiple piercings" },
{ id: "accessory_scarification", label: "Scarification", img: `${CDN}/accessories_accessory_scarification.webp`, promptVal: "ritual scarification marks" },
{ id: "accessory_symbols", label: "Symbols / Markings", img: `${CDN}/accessories_accessory_symbols.webp`, promptVal: "symbolic tribal markings" },
{ id: "accessory_cyber", label: "Cyber Markings", img: `${CDN}/accessories_accessory_cyber.webp`, promptVal: "cyberpunk circuit markings" },
],
},
{
id: "rendering_style",
label: "Rendering Style",
options: [
{ id: "style_hyper_realistic", label: "Hyper-Realistic", img: `${CDN}/character_type_human.webp`, promptVal: "hyper-realistic 8k photograph" },
{ id: "style_anime", label: "Anime", img: `${CDN}/character_type_elf.webp`, promptVal: "anime art style" },
{ id: "style_cartoon", label: "Cartoon", img: `${CDN}/character_type_mantis.webp`, promptVal: "cartoon illustration style" },
{ id: "style_2d", label: "2D Illustration", img: `${CDN}/character_type_alien.webp`, promptVal: "2D flat illustration style" },
],
},
],
},
};
// ─── SVG Icon Components ────────────────────────────────────────────────────
const ShuffleIcon = () => (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="16 3 21 3 21 8" /><line x1="4" y1="20" x2="21" y2="3" />
<polyline points="21 16 21 21 16 21" /><line x1="15" y1="15" x2="21" y2="21" />
</svg>
);
const BoltIcon = () => (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<path d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
);
const CheckIcon = () => (
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
);
const DownloadIcon = () => (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" /><polyline points="7 10 12 15 17 10" /><line x1="12" y1="15" x2="12" y2="3" />
</svg>
);
// ─── Hover Pill — shows label, reveals image on hover ───────────────────────
function HoverPill({ label, img, onClick }) {
const [hovered, setHovered] = useState(false);
return (
<div
className="relative shrink-0"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{/* Image tooltip */}
{hovered && img && (
<div
className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 z-50 pointer-events-none"
style={{ filter: "drop-shadow(0 4px 16px rgba(0,0,0,0.6))" }}
>
<div className="w-[72px] h-[72px] rounded-xl overflow-hidden border border-white/20 bg-[#1a1a1a]"
style={{ transform: "rotate(-3deg)" }}>
<img src={img} alt={label} className="w-full h-full object-cover" />
</div>
</div>
)}
{/* Pill */}
<button
type="button"
onClick={onClick}
className="h-[22px] px-2 rounded-md bg-white/[0.07] hover:bg-white/[0.13] border border-white/[0.10] text-[11px] font-medium text-gray-200 whitespace-nowrap transition-all cursor-pointer"
>
{label}
</button>
</div>
);
}
// ─── Main Component ─────────────────────────────────────────────────────────
export default function AiInfluencerStudio({ apiKey, onGenerate, isGenerating: externalIsGenerating }) {
const [activeTab, setActiveTab] = useState("face");
const [selectedOptions, setSelectedOptions] = useState(() => {
const init = {};
Object.values(TABS_CONFIG).forEach((tab) =>
tab.subcategories.forEach((sub) => {
if (sub.options?.length > 0) init[sub.id] = sub.options[0].id;
})
);
return init;
});
const [aspectRatio, setAspectRatio] = useState("3:4");
const [customPrompt, setCustomPrompt] = useState("");
const [isGeneratingInternal, setIsGeneratingInternal] = useState(false);
const [currentResult, setCurrentResult] = useState(null); // latest generated image
const [history, setHistory] = useState([]); // all generated images
const [selectedHistoryIdx, setSelectedHistoryIdx] = useState(null);
const [errorMsg, setErrorMsg] = useState("");
const isGenerating = externalIsGenerating || isGeneratingInternal;
// ── Build prompt from selections ──────────────────────────────────────────
const buildPrompt = useCallback(() => {
const parts = [];
Object.values(TABS_CONFIG).forEach((tab) =>
tab.subcategories.forEach((sub) => {
const opt = sub.options.find((o) => o.id === selectedOptions[sub.id]);
if (opt?.promptVal) parts.push(opt.promptVal);
})
);
let prompt = "Ultra-realistic professional portrait photograph of an AI influencer character, 8k resolution, cinematic lighting, sharp detail";
if (parts.length) prompt += ", " + parts.join(", ");
if (customPrompt.trim()) prompt += ", " + customPrompt.trim();
return prompt;
}, [selectedOptions, customPrompt]);
// ── Option selection ───────────────────────────────────────────────────────
const handleOptionSelect = (subcatId, optId) =>
setSelectedOptions((p) => ({ ...p, [subcatId]: optId }));
// ── Shuffle all options randomly ───────────────────────────────────────────
const handleShuffle = () => {
const next = {};
Object.values(TABS_CONFIG).forEach((tab) =>
tab.subcategories.forEach((sub) => {
if (sub.options?.length > 0)
next[sub.id] = sub.options[Math.floor(Math.random() * sub.options.length)].id;
})
);
setSelectedOptions(next);
};
// ── Generate ──────────────────────────────────────────────────────────────
const handleGenerate = async () => {
if (isGenerating) return;
setIsGeneratingInternal(true);
setErrorMsg("");
const prompt = buildPrompt();
try {
let res;
if (onGenerate) {
res = await onGenerate({ prompt, aspectRatio, selections: selectedOptions });
} else {
res = await generateImage(apiKey, {
model: INFLUENCER_MODEL,
prompt,
aspect_ratio: aspectRatio,
});
}
if (res?.url) {
setCurrentResult(res.url);
setHistory((prev) => [{ url: res.url, ts: Date.now() }, ...prev]);
setSelectedHistoryIdx(0);
}
} catch (err) {
setErrorMsg(err?.message || "Generation failed. Please try again.");
} finally {
setIsGeneratingInternal(false);
}
};
// ── Download helper ───────────────────────────────────────────────────────
const downloadImg = async (url) => {
try {
const res = await fetch(url);
const blob = await res.blob();
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `ai-influencer-${Date.now()}.webp`;
a.click();
URL.revokeObjectURL(a.href);
} catch {
window.open(url, "_blank");
}
};
// Preview image = selected history or current result
const previewUrl =
selectedHistoryIdx !== null && history[selectedHistoryIdx]
? history[selectedHistoryIdx].url
: currentResult;
const arMap = { "3:4": "3/4", "1:1": "1/1", "9:16": "9/16", "16:9": "16/9" };
// ── Collect all selected options as flat list for the pill tags bar ─────────
const selectedTags = [];
Object.keys(TABS_CONFIG).forEach((tabKey) => {
TABS_CONFIG[tabKey].subcategories.forEach((sub) => {
const selId = selectedOptions[sub.id];
const opt = sub.options.find((o) => o.id === selId);
if (opt) selectedTags.push({ subcatId: sub.id, label: opt.label, img: opt.img });
});
});
const [showAllTags, setShowAllTags] = useState(false);
const TAGS_VISIBLE = 7; // how many pills to show before "show more"
return (
<div className="flex h-full bg-[#0a0a0a] text-white overflow-hidden select-none font-sans">
{/* ════════════════════════════════════════════════════════════
LEFT — Builder / Options Panel
════════════════════════════════════════════════════════════ */}
<div className="flex flex-col w-[320px] shrink-0 border-r border-white/[0.07] bg-[#111111] overflow-hidden">
{/* Builder header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-white/[0.07] shrink-0">
<span className="text-[13px] font-bold text-white tracking-tight">Builder</span>
<button
onClick={() => setSelectedOptions((() => {
const init = {};
Object.values(TABS_CONFIG).forEach((tab) =>
tab.subcategories.forEach((sub) => {
if (sub.options?.length > 0) init[sub.id] = sub.options[0].id;
})
);
return init;
})())}
className="text-[11px] text-gray-500 hover:text-white transition-colors font-medium"
>
Reset
</button>
</div>
{/* Tab pills */}
<div className="flex gap-1 px-3 py-2 border-b border-white/[0.07] shrink-0">
{Object.keys(TABS_CONFIG).map((key) => (
<button
key={key}
onClick={() => setActiveTab(key)}
className={`flex-1 py-1.5 rounded-lg text-[12px] font-semibold transition-all ${
activeTab === key
? "bg-white text-black shadow"
: "text-gray-500 hover:text-white hover:bg-white/[0.06]"
}`}
>
{TABS_CONFIG[key].label}
</button>
))}
</div>
{/* Subcategory options scroll area */}
<div className="flex-1 overflow-y-auto p-3 space-y-5">
{TABS_CONFIG[activeTab]?.subcategories?.map((subcat) => (
<div key={subcat.id}>
<p className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-2 px-0.5">
{subcat.label}
</p>
<div className="grid grid-cols-3 gap-1.5">
{subcat.options?.map((opt) => {
const sel = selectedOptions[subcat.id] === opt.id;
return (
<button
key={opt.id}
onClick={() => handleOptionSelect(subcat.id, opt.id)}
className={`group relative aspect-square rounded-xl overflow-hidden border transition-all ${
sel
? "border-white/80 ring-1 ring-white/30 shadow-lg"
: "border-white/[0.08] hover:border-white/25"
}`}
>
<img
src={opt.img}
alt={opt.label}
loading="lazy"
className="w-full h-full object-cover"
onError={(e) => { e.target.onerror = null; e.target.src = `${CDN}/character_type_human.webp`; }}
/>
{/* Label overlay */}
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent pt-4 pb-1 px-1">
<span className="text-[9px] font-semibold text-white leading-none">{opt.label}</span>
</div>
{/* Selected check badge */}
{sel && (
<div className="absolute top-1 right-1 w-4 h-4 rounded-full bg-white text-black flex items-center justify-center">
<CheckIcon />
</div>
)}
</button>
);
})}
</div>
</div>
))}
</div>
</div>
{/* ════════════════════════════════════════════════════════════
CENTER — Current Character Preview
════════════════════════════════════════════════════════════ */}
<div className="flex flex-col flex-1 min-w-0 overflow-hidden bg-[#0a0a0a]">
{/* Center top bar: aspect ratio + generate */}
<div className="flex items-center justify-between px-6 py-3 border-b border-white/[0.07] shrink-0">
{/* Aspect ratio */}
<div className="flex gap-0.5 bg-white/[0.05] border border-white/[0.08] rounded-xl p-1">
{["3:4", "1:1", "9:16", "16:9"].map((r) => (
<button
key={r}
onClick={() => setAspectRatio(r)}
className={`px-3 py-1.5 rounded-lg text-[11px] font-bold transition-all ${
aspectRatio === r
? "bg-violet-600 text-white shadow-md shadow-violet-600/40"
: "text-gray-500 hover:text-white"
}`}
>
{r}
</button>
))}
</div>
<div className="flex items-center gap-2">
{/* Shuffle */}
<button
onClick={handleShuffle}
className="flex items-center gap-1.5 px-3 py-2 rounded-xl bg-white/[0.05] border border-white/[0.08] text-gray-400 hover:text-white hover:bg-white/10 text-[12px] font-semibold transition-all"
>
<ShuffleIcon />
Shuffle
</button>
{/* Generate */}
<button
onClick={handleGenerate}
disabled={isGenerating}
className={`flex items-center gap-2 px-5 py-2 rounded-xl text-[13px] font-bold transition-all shadow-lg ${
isGenerating
? "bg-violet-600/40 text-white/60 cursor-not-allowed"
: "bg-gradient-to-r from-violet-600 to-indigo-600 hover:from-violet-500 hover:to-indigo-500 text-white shadow-violet-600/30 hover:shadow-violet-500/40"
}`}
>
{isGenerating ? (
<>
<svg className="animate-spin" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" strokeOpacity="0.3" />
<path d="M21 12a9 9 0 00-9-9" />
</svg>
Generating
</>
) : (
<><BoltIcon />Generate Character</>
)}
</button>
</div>
</div>
{/* Preview area */}
<div className="flex-1 flex items-center justify-center p-6 overflow-hidden">
<div
className="relative rounded-2xl overflow-hidden bg-[#141414] border border-white/[0.07] shadow-2xl flex items-center justify-center"
style={{ aspectRatio: arMap[aspectRatio] ?? "3/4", maxHeight: "100%", maxWidth: "100%" }}
>
{isGenerating ? (
<div className="flex flex-col items-center gap-4 text-center px-8 py-12">
<div className="w-12 h-12 border-[3px] border-violet-500/20 border-t-violet-500 rounded-full animate-spin" />
<p className="text-sm text-gray-400 font-medium">Generating your AI influencer</p>
</div>
) : previewUrl ? (
<>
<img src={previewUrl} alt="Generated AI Character" className="w-full h-full object-cover" />
{/* Download overlay button */}
<button
onClick={() => downloadImg(previewUrl)}
className="absolute bottom-3 right-3 flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-black/60 backdrop-blur-sm border border-white/10 text-white text-[11px] font-semibold hover:bg-black/80 transition-all"
>
<DownloadIcon />
Save
</button>
</>
) : (
<div className="flex flex-col items-center gap-3 text-center px-8 py-12">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="0.8" className="text-gray-700">
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" /><circle cx="12" cy="7" r="4" />
</svg>
<p className="text-sm text-gray-600 font-medium">Your AI influencer lives here.</p>
<p className="text-xs text-gray-700">Design and build your AI influencer<br />from scratch</p>
</div>
)}
</div>
</div>
{/* ── Selected option pills ──────────────────────────────────── */}
{selectedTags.length > 0 && (
<div className="px-6 pb-3 shrink-0">
<div className="flex flex-wrap gap-1.5 items-center">
{(showAllTags ? selectedTags : selectedTags.slice(0, TAGS_VISIBLE)).map((tag) => (
<HoverPill
key={tag.subcatId}
label={tag.label}
img={tag.img}
onClick={() => {
// Jump builder panel to the tab that owns this subcategory
const ownerTab = Object.keys(TABS_CONFIG).find((tk) =>
TABS_CONFIG[tk].subcategories.some((s) => s.id === tag.subcatId)
);
if (ownerTab) setActiveTab(ownerTab);
}}
/>
))}
{selectedTags.length > TAGS_VISIBLE && (
<button
type="button"
onClick={() => setShowAllTags((v) => !v)}
className="h-[22px] px-2 rounded-md bg-white/[0.04] hover:bg-white/[0.09] border border-white/[0.08] text-[11px] text-gray-500 hover:text-gray-300 whitespace-nowrap transition-all"
>
{showAllTags ? "hide" : `show more`}
</button>
)}
</div>
</div>
)}
{/* Error */}
{errorMsg && (
<div className="mx-6 mb-4 px-4 py-3 rounded-xl bg-red-500/10 border border-red-500/20 text-red-400 text-[12px] shrink-0">
{errorMsg}
</div>
)}
{/* Custom prompt bar at bottom */}
<div className="px-6 pb-4 shrink-0">
<input
type="text"
value={customPrompt}
onChange={(e) => setCustomPrompt(e.target.value)}
placeholder="Add extra details… e.g. neon cyberpunk lighting, dramatic shadows"
className="w-full h-9 bg-[#161616] border border-white/[0.07] rounded-xl px-3 text-[12px] text-gray-200 placeholder-gray-600 outline-none focus:border-violet-500/40 transition-colors"
/>
</div>
</div>
{/* ════════════════════════════════════════════════════════════
RIGHT — Generated Characters History Gallery
════════════════════════════════════════════════════════════ */}
<div className="flex flex-col w-[160px] shrink-0 border-l border-white/[0.07] bg-[#111111] overflow-hidden">
{/* Gallery header */}
<div className="px-3 py-3 border-b border-white/[0.07] shrink-0">
<p className="text-[11px] font-bold text-white tracking-tight">Generated</p>
<p className="text-[9px] text-gray-600 mt-0.5">{history.length} characters</p>
</div>
{/* Gallery scroll */}
<div className="flex-1 overflow-y-auto p-2 space-y-2">
{history.length === 0 ? (
<div className="flex flex-col items-center justify-center h-32 text-center px-2">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" className="text-gray-700 mb-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>
<p className="text-[9px] text-gray-700 leading-relaxed">Generated characters<br />appear here</p>
</div>
) : (
history.map((item, idx) => (
<div
key={item.ts}
role="button"
tabIndex={0}
onClick={() => setSelectedHistoryIdx(idx)}
onKeyDown={(e) => e.key === "Enter" && setSelectedHistoryIdx(idx)}
className={`group relative w-full aspect-[3/4] rounded-xl overflow-hidden border transition-all cursor-pointer ${
selectedHistoryIdx === idx
? "border-violet-500 ring-1 ring-violet-500/40"
: "border-white/[0.08] hover:border-white/20"
}`}
>
<img src={item.url} alt={`Character ${idx + 1}`} className="w-full h-full object-cover" />
{/* Download on hover */}
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-end justify-center pb-2">
<div
role="button"
tabIndex={0}
onClick={(e) => { e.stopPropagation(); downloadImg(item.url); }}
onKeyDown={(e) => { if (e.key === "Enter") { e.stopPropagation(); downloadImg(item.url); } }}
className="p-1.5 rounded-lg bg-white/10 backdrop-blur-sm border border-white/20 text-white hover:bg-white/20 transition-all cursor-pointer"
>
<DownloadIcon />
</div>
</div>
{/* Index badge */}
<div className="absolute top-1 left-1 px-1.5 py-0.5 rounded-md bg-black/60 backdrop-blur-sm text-[8px] text-gray-300 font-bold">
#{history.length - idx}
</div>
</div>
))
)}
</div>
</div>
</div>
);
}
@@ -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-[#22d3ee] 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-[#22d3ee]/10 text-[#22d3ee] rounded-md text-[11px] font-bold uppercase tracking-wider flex items-center justify-center gap-2 hover:bg-[#22d3ee]/20 transition-all border border-[#22d3ee]/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-[#22d3ee]/10 text-[#22d3ee] rounded-md text-[11px] font-bold uppercase tracking-wider flex items-center justify-center gap-2 hover:bg-[#22d3ee]/20 transition-all border border-[#22d3ee]/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-[#22d3ee]/10 border border-[#22d3ee]/20 rounded-full">
<FaDollarSign className="text-[#22d3ee] text-xs" />
<span className="text-[10px] font-black text-[#22d3ee] 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-[#22d3ee] 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-[#22d3ee] 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-[#22d3ee]/10 border border-[#22d3ee]/20 flex items-center justify-center text-4xl text-[#22d3ee] 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&apos;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-[#22d3ee] text-black rounded-md text-sm font-black uppercase tracking-widest flex items-center justify-center gap-2 hover:bg-[#22d3ee]/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
@@ -0,0 +1,999 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { runClipping, uploadFile } from "../muapi.js";
// ---------------------------------------------------------------------------
// Inline SVG Icons
// ---------------------------------------------------------------------------
const ScissorsIcon = ({ className = "text-[#22d3ee]" }) => (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<circle cx="6" cy="6" r="3" />
<circle cx="6" cy="18" r="3" />
<line x1="9.8" y1="8.2" x2="21" y2="19.4" />
<line x1="9.8" y1="15.8" x2="21" y2="4.6" />
</svg>
);
const TrashIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<line x1="10" y1="11" x2="10" y2="17" />
<line x1="14" y1="11" x2="14" y2="17" />
</svg>
);
const PlayIcon = () => (
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z" />
</svg>
);
const DownloadIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3" />
</svg>
);
const CopyIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
);
const ClockIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
);
const CheckIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#22d3ee" strokeWidth="3">
<polyline points="20 6 9 17 4 12" />
</svg>
);
const ChevronDownIcon = () => (
<svg
width="8"
height="8"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="4"
className="opacity-20 group-hover:opacity-100 transition-opacity ml-1"
>
<path d="M6 9l6 6 6-6" />
</svg>
);
const getAspectClass = (ar) => {
switch (ar) {
case "16:9": return "aspect-video";
case "1:1": return "aspect-square";
case "4:5": return "aspect-[4/5]";
case "4:3": return "aspect-[4/3]";
case "3:4": return "aspect-[3/4]";
case "9:16":
default:
return "aspect-[9/16]";
}
};
// ---------------------------------------------------------------------------
// Main Clipping Studio Component
// ---------------------------------------------------------------------------
export default function ClippingStudio({
apiKey,
onGenerationComplete,
droppedFiles,
onFilesHandled,
}) {
const PERSIST_KEY = "hg_clipping_studio_persistent";
// ── Clipping Parameters State ───────────────────────────────────────────
const [videoUrl, setVideoUrl] = useState("");
const [numHighlights, setNumHighlights] = useState(3);
const [aspectRatio, setAspectRatio] = useState("9:16");
const [returnCoordinatesOnly, setReturnCoordinatesOnly] = useState(false);
const [prompt, setPrompt] = useState("");
// ── Dropdowns state ──
const [aspectDropdownOpen, setAspectDropdownOpen] = useState(false);
const [highlightsDropdownOpen, setHighlightsDropdownOpen] = useState(false);
const dropdownRef = useRef(null);
const highlightsDropdownRef = useRef(null);
const textareaRef = useRef(null);
const promptTextareaRef = useRef(null);
// ── Upload State ──
const [videoUploading, setVideoUploading] = useState(false);
const [videoProgress, setVideoProgress] = useState(0);
const videoFileInputRef = useRef(null);
// ── Generation State ─────────────────────────────────────────────────────
const [isGenerating, setIsGenerating] = useState(false);
const [generateError, setGenerateError] = useState(null);
const [fullscreenUrl, setFullscreenUrl] = useState(null);
const [elapsedTime, setElapsedTime] = useState(0);
const timerRef = useRef(null);
// ── Output State ─────────────────────────────────────────────────────────
const [result, setResult] = useState(null); // stores parsed completed API output
const [activeHighlightIndex, setActiveHighlightIndex] = useState(0);
const mainVideoRef = useRef(null);
// ── History State ────────────────────────────────────────────────────────
const [history, setHistory] = useState([]);
const ASPECT_RATIOS = [
{ label: "9:16 (TikTok / Reels / Shorts)", value: "9:16" },
{ label: "16:9 (YouTube / TV)", value: "16:9" },
{ label: "1:1 (Instagram Square)", value: "1:1" },
{ label: "4:5 (Instagram Portrait)", value: "4:5" },
{ label: "4:3 (Classic Video)", value: "4:3" },
{ label: "3:4 (Portrait)", value: "3:4" },
];
// Close dropdown when clicking outside
useEffect(() => {
function handleClickOutside(event) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
setAspectDropdownOpen(false);
}
if (highlightsDropdownRef.current && !highlightsDropdownRef.current.contains(event.target)) {
setHighlightsDropdownOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
// Timer effect for generation progress
useEffect(() => {
if (isGenerating) {
setElapsedTime(0);
timerRef.current = setInterval(() => {
setElapsedTime((prev) => prev + 1);
}, 1000);
} else {
if (timerRef.current) {
clearInterval(timerRef.current);
}
}
return () => {
if (timerRef.current) clearInterval(timerRef.current);
};
}, [isGenerating]);
// ── Load Persistent State from localStorage ──────────────────────────────
useEffect(() => {
try {
const stored = localStorage.getItem(PERSIST_KEY);
if (stored) {
const data = JSON.parse(stored);
if (data.videoUrl) setVideoUrl(data.videoUrl);
if (data.numHighlights) setNumHighlights(data.numHighlights);
if (data.aspectRatio) setAspectRatio(data.aspectRatio);
if (data.returnCoordinatesOnly !== undefined) setReturnCoordinatesOnly(data.returnCoordinatesOnly);
if (data.history) setHistory(data.history);
if (data.result) setResult(data.result);
}
} catch (err) {
console.warn("Failed to load ClippingStudio persistent state:", err);
}
}, []);
// ── Save Persistent State to localStorage ───────────────────────────────
useEffect(() => {
const timer = setTimeout(() => {
try {
const state = {
videoUrl,
numHighlights,
aspectRatio,
returnCoordinatesOnly,
history,
result,
};
localStorage.setItem(PERSIST_KEY, JSON.stringify(state));
} catch (err) {
console.warn("Failed to save ClippingStudio persistent state:", err);
}
}, 500);
return () => clearTimeout(timer);
}, [videoUrl, numHighlights, aspectRatio, returnCoordinatesOnly, history, result]);
// ── Handle Dropped Files ────────────────────────────────────────────────
useEffect(() => {
if (droppedFiles && droppedFiles.length > 0) {
const videoFiles = droppedFiles.filter(f => f.type.startsWith('video/'));
if (videoFiles.length > 0) {
setVideoUploading(true);
setVideoProgress(0);
uploadFile(apiKey, videoFiles[0], (pct) => {
setVideoProgress(pct);
})
.then(url => {
setVideoUrl(url);
setVideoUploading(false);
})
.catch(err => {
setVideoUploading(false);
alert(`Failed to upload dropped file: ${err.message}`);
});
}
onFilesHandled?.();
}
}, [droppedFiles, onFilesHandled, apiKey]);
// Adjust URL textarea height dynamically
useEffect(() => {
const timer = setTimeout(() => {
if (textareaRef.current) {
const el = textareaRef.current;
el.style.height = "auto";
const maxH = window.innerWidth < 768 ? 150 : 250;
el.style.height = Math.min(el.scrollHeight, maxH) + "px";
}
}, 150);
return () => clearTimeout(timer);
}, [videoUrl]);
// ── Highlight Seeking Helper ─────────────────────────────────────────────
const seekToHighlight = (startSec) => {
if (mainVideoRef.current) {
mainVideoRef.current.currentTime = startSec;
mainVideoRef.current.play().catch(() => {});
}
};
// Helper formatting seconds to MM:SS
const formatSeconds = (totalSeconds) => {
if (isNaN(totalSeconds) || totalSeconds === null || totalSeconds === undefined) return "0:00";
const mins = Math.floor(totalSeconds / 60);
const secs = Math.floor(totalSeconds % 60);
return `${mins}:${secs < 10 ? "0" : ""}${secs}`;
};
// ── Copy Link & Download Helpers ─────────────────────────────────────────
const copyToClipboard = (text) => {
navigator.clipboard.writeText(text);
alert("URL copied to clipboard!");
};
const downloadVideo = async (url, title = "clipped_video") => {
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 = `${title.replace(/\s+/g, '_')}.mp4`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(blobUrl);
} catch {
window.open(url, "_blank");
}
};
const handleUrlInput = (e) => {
setVideoUrl(e.target.value);
const el = e.target;
el.style.height = "auto";
const maxH = window.innerWidth < 768 ? 150 : 250;
el.style.height = Math.min(el.scrollHeight, maxH) + "px";
};
const handlePromptInput = (e) => {
const val = e.target.value;
if (val.trim().match(/^https?:\/\/[^\s]+$/i)) {
setVideoUrl(val.trim());
setPrompt("");
return;
}
setPrompt(val);
const el = e.target;
el.style.height = "auto";
const maxH = window.innerWidth < 768 ? 150 : 250;
el.style.height = Math.min(el.scrollHeight, maxH) + "px";
};
// ── Video File Handlers ──
const handleVideoFileChange = async (e) => {
const file = e.target.files[0];
if (!file) return;
if (file.size > 100 * 1024 * 1024) {
alert("Video exceeds 100MB limit.");
return;
}
setVideoUploading(true);
setVideoProgress(0);
try {
const url = await uploadFile(apiKey, file, (pct) => {
setVideoProgress(pct);
});
setVideoUrl(url);
} catch (err) {
console.error("[ClippingStudio] Video upload failed:", err);
alert(`Video upload failed: ${err.message}`);
} finally {
setVideoUploading(false);
setVideoProgress(0);
if (videoFileInputRef.current) videoFileInputRef.current.value = "";
}
};
const clearVideoUpload = () => {
setVideoUrl("");
};
// ── Dispatch Run / Call submitAndPoll ────────────────────────────────────
const handleGenerate = async () => {
if (!videoUrl) {
alert("Please upload a video or paste a video URL first.");
return;
}
setIsGenerating(true);
setGenerateError(null);
setResult(null);
try {
const params = {
video_url: videoUrl,
num_highlights: numHighlights,
aspect_ratio: aspectRatio,
return_coordinates_only: returnCoordinatesOnly,
};
const res = await runClipping(apiKey, params);
// Parse the result
const clips = res.outputs || [];
const outputCoordinates = res.output?.coordinates || res.coordinates || res.output?.timings || res.timings || [];
const newResult = {
id: res.id || Date.now().toString(),
videoUrl: videoUrl,
clips: clips,
coordinates: Array.isArray(outputCoordinates) ? outputCoordinates : (res.output?.clips || []),
returnCoordinatesOnly: returnCoordinatesOnly,
aspectRatio: aspectRatio,
timestamp: new Date().toISOString(),
};
// Mock coordinates if API succeeded but modal coordinates are empty in coordinate-only mode
if (returnCoordinatesOnly && newResult.coordinates.length === 0) {
newResult.coordinates = Array.from({ length: numHighlights }).map((_, idx) => ({
label: `Highlight #${idx + 1}`,
start_time: idx * 15,
end_time: (idx + 1) * 15,
start: idx * 15,
end: (idx + 1) * 15,
score: 0.95 - (idx * 0.05)
}));
}
setResult(newResult);
setActiveHighlightIndex(0);
// Append to history
setHistory((prev) => [newResult, ...prev].slice(0, 30));
if (onGenerationComplete) {
onGenerationComplete({
url: clips[0] || videoUrl,
model: "ai-clipping",
type: "video",
});
}
} catch (err) {
console.error("[ClippingStudio] Error generating clips:", err);
setGenerateError(err.message || "Failed to process AI clipping.");
} finally {
setIsGenerating(false);
}
};
const handleSelectHistory = (entry) => {
setResult(entry);
setActiveHighlightIndex(0);
setVideoUrl(entry.videoUrl);
setNumHighlights(entry.numHighlights || 3);
setAspectRatio(entry.aspectRatio || "9:16");
setReturnCoordinatesOnly(entry.returnCoordinatesOnly || false);
};
return (
<div className="w-full h-full flex flex-col items-center justify-center bg-app-bg text-white relative overflow-hidden">
{/* ─── CENTRAL AREA ─── */}
<div className="flex-1 w-full max-w-7xl mx-auto overflow-y-auto custom-scrollbar pb-40 lg:pb-32 px-2">
{/* Error Message */}
{generateError && (
<div className="bg-red-500/10 border border-red-500/20 text-red-400 p-4 rounded text-xs font-semibold leading-relaxed mb-6">
{generateError}
</div>
)}
{/* 1. Empty State (No history, no result active) */}
{!result && history.length === 0 && (
<div className="flex-grow flex flex-col items-center justify-center animate-fade-in-up transition-all duration-700 min-h-[55vh]">
{/* Overlapping floating cards */}
<div className="flex items-center justify-center gap-1.5 md:gap-3 mb-10 select-none scale-90 sm:scale-100">
<div className="w-18 h-22 sm:w-24 sm:h-28 rounded-2xl border border-white/10 shadow-2xl -rotate-[12deg] transform hover:rotate-0 hover:scale-110 hover:z-20 transition-all duration-300 overflow-hidden bg-white/[0.01] flex-shrink-0">
<img
src="https://d3adwkbyhxyrtq.cloudfront.net/webassets/videomodels/sdxl-image.avif"
alt="Creative asset 1"
className="w-full h-full object-cover"
/>
</div>
<div className="w-18 h-22 sm:w-24 sm:h-28 rounded-2xl border border-white/10 shadow-2xl -rotate-[4deg] transform hover:rotate-0 hover:scale-110 hover:z-20 transition-all duration-300 overflow-hidden bg-white/[0.01] -ml-3 sm:-ml-4 flex-shrink-0">
<img
src="https://d3adwkbyhxyrtq.cloudfront.net/webassets/videomodels/chroma-image.avif"
alt="Creative asset 2"
className="w-full h-full object-cover"
/>
</div>
<div className="w-18 h-18 sm:w-24 sm:h-24 rounded-full border border-white/10 shadow-2xl rotate-[6deg] transform hover:rotate-0 hover:scale-110 hover:z-20 transition-all duration-300 overflow-hidden bg-white/[0.01] -ml-3 sm:-ml-4 flex-shrink-0">
<img
src="https://d3adwkbyhxyrtq.cloudfront.net/webassets/videomodels/neta-lumina.avif"
alt="Creative asset 3"
className="w-full h-full object-cover"
/>
</div>
<div className="w-18 h-22 sm:w-24 sm:h-28 rounded-2xl border border-white/10 shadow-2xl rotate-[12deg] transform hover:rotate-0 hover:scale-110 hover:z-20 transition-all duration-300 overflow-hidden bg-white/[0.01] -ml-3 sm:-ml-4 flex-shrink-0">
<img
src="https://d3adwkbyhxyrtq.cloudfront.net/webassets/videomodels/perfect-pony-xl.avif"
alt="Creative asset 4"
className="w-full h-full object-cover"
/>
</div>
</div>
<h1 className="text-2xl sm:text-4xl md:text-5xl font-extrabold tracking-tight mb-4 text-center px-4 flex flex-col items-center">
<span className="text-white font-black uppercase text-xl sm:text-3xl tracking-wide mb-1 opacity-90">START CREATING WITH</span>
<span className="text-[#22d3ee] font-black uppercase text-2xl sm:text-4xl sm:mt-1 tracking-tight">
AI CLIPPING STUDIO
</span>
</h1>
<p className="text-white/40 text-xs sm:text-sm font-medium tracking-wide text-center max-w-lg leading-relaxed px-4">
Extract viral highlights and precise timings from your videos automatically.
</p>
</div>
)}
{/* 2. History Gallery List (Active result is null, history has items) */}
{!result && history.length > 0 && (
<div className="space-y-6 pt-4">
<div className="flex items-center justify-between border-b border-white/5 pb-4">
<h2 className="text-sm font-black text-white uppercase tracking-widest flex items-center gap-2">
<ScissorsIcon className="text-primary w-4 h-4" />
Clipping History Runs
</h2>
<span className="text-xs font-bold text-zinc-400 bg-white/5 border border-white/5 px-2.5 py-1 rounded">
{history.length} Saved Generations
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 w-full animate-fade-in-up">
{history.map((entry, idx) => (
<div
key={entry.id || idx}
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"
>
<div className="aspect-video bg-zinc-950 flex items-center justify-center border-b border-white/5 relative overflow-hidden">
<video
src={entry.videoUrl}
className="w-full h-full object-cover opacity-60 group-hover:opacity-85 transition-opacity cursor-pointer animate-fade-in"
preload="metadata"
muted
loop
playsInline
onClick={() => handleSelectHistory(entry)}
onMouseOver={(e) => e.target.play()}
onMouseOut={(e) => {
e.target.pause();
e.target.currentTime = 0;
}}
/>
{/* Overlay actions */}
<div className="absolute top-2 right-2 flex flex-col gap-2 opacity-0 group-hover:opacity-100 transition-opacity z-10">
<button
type="button"
title="Delete from history"
onClick={(e) => {
e.stopPropagation();
setHistory((prev) => prev.filter((h) => h.id !== entry.id));
}}
className="p-2 bg-black/60 backdrop-blur-md rounded-full text-white hover:bg-red-500 hover:text-white transition-all border border-white/10"
>
<TrashIcon />
</button>
</div>
</div>
<div
onClick={() => handleSelectHistory(entry)}
className="p-3 bg-black/80 backdrop-blur-sm border-t border-white/5 flex-1 flex flex-col justify-between gap-2 cursor-pointer"
>
<div className="flex flex-col gap-1">
<h4 className="text-xs font-bold text-white truncate" title={entry.videoUrl.split('/').pop()}>
{entry.videoUrl.split('/').pop() || "source_video.mp4"}
</h4>
<p className="text-[9px] text-zinc-500 font-semibold uppercase tracking-wider">
{entry.returnCoordinatesOnly ? "Timeline Seek Mode" : "Clips Gallery Mode"}
</p>
</div>
<div className="flex items-center justify-between mt-1">
<span className="text-[10px] font-bold text-primary px-2 py-0.5 bg-primary/10 rounded border border-primary/20">
{entry.aspectRatio}
</span>
<span className="text-[10px] text-white/40">
{entry.returnCoordinatesOnly ? `${entry.coordinates?.length || 0} Highlights` : `${entry.clips?.length || 0} Clips`}
</span>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* 3. Active Result Preview (Result is loaded) */}
{result && (
<div className="flex-1 flex flex-col min-h-0">
{/* Header / Back Action */}
<div className="flex items-center justify-between mb-6 pb-4 border-b border-white/5">
<button
type="button"
onClick={() => setResult(null)}
className="flex items-center gap-2 text-xs font-bold text-zinc-400 hover:text-white transition-colors"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="19" y1="12" x2="5" y2="12" />
<polyline points="12 19 5 12 12 5" />
</svg>
Back to History
</button>
<div className="flex items-center gap-2">
<span className="text-[10px] font-bold text-primary bg-primary/10 border border-primary/20 px-2.5 py-0.5 rounded">
{result.returnCoordinatesOnly ? "Timeline Seek Mode" : "Clips Gallery Mode"}
</span>
<span className="text-[10px] text-zinc-400 bg-white/5 border border-white/5 px-2.5 py-0.5 rounded">
{result.aspectRatio}
</span>
</div>
</div>
{/* Render coordinates Timeline player */}
{result.returnCoordinatesOnly ? (
<div className="flex-1 flex flex-col lg:flex-row gap-6 min-h-0">
{/* Left Side: Original Player */}
<div className="flex-1 bg-black border border-zinc-900 rounded-lg overflow-hidden flex flex-col shadow-2xl relative min-h-[300px] lg:min-h-0">
<div className="absolute top-4 left-4 bg-black/60 backdrop-blur-md px-3 py-1.5 rounded-md border border-white/5 z-10 text-[10px] uppercase font-bold tracking-wider text-primary">
Original Video Player
</div>
<video
ref={mainVideoRef}
src={result.videoUrl}
controls
className="w-full flex-1 object-contain bg-zinc-950"
preload="auto"
/>
</div>
{/* Right Side: Highlights list */}
<div className="w-full lg:w-[350px] border border-zinc-900 bg-zinc-950/40 backdrop-blur-md rounded-lg p-5 flex flex-col min-h-[350px] lg:min-h-0">
<div className="pb-4 border-b border-zinc-900 flex items-center justify-between">
<h3 className="text-xs font-black text-white uppercase tracking-widest">
Highlights Timeline
</h3>
<span className="text-[10px] font-bold text-zinc-400 bg-zinc-900 px-2 py-0.5 rounded border border-zinc-800">
{result.coordinates?.length || 0} Matches
</span>
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar mt-4 space-y-3 pr-1">
{result.coordinates && result.coordinates.length > 0 ? (
result.coordinates.map((hl, i) => {
const start = hl.start_time !== undefined ? hl.start_time : (hl.start || 0);
const end = hl.end_time !== undefined ? hl.end_time : (hl.end || 0);
const isActive = activeHighlightIndex === i;
return (
<button
key={i}
type="button"
onClick={() => {
setActiveHighlightIndex(i);
seekToHighlight(start);
}}
className={`w-full p-4 border rounded-lg text-left transition-all hover:bg-zinc-900/60 flex flex-col gap-2 group/hl ${
isActive
? "border-primary bg-primary/5 shadow-[0_0_12px_rgba(34,211,238,0.03)]"
: "border-zinc-800 bg-zinc-900/30 hover:border-zinc-700"
}`}
>
<div className="flex items-center justify-between w-full">
<span className={`text-xs font-bold transition-colors ${isActive ? "text-primary" : "text-white"}`}>
{hl.label || `Highlight #${i + 1}`}
</span>
{hl.score && (
<span className="text-[9px] font-black text-emerald-400 bg-emerald-400/10 px-1.5 py-0.5 rounded border border-emerald-400/20">
{(hl.score * 100).toFixed(0)}% Score
</span>
)}
</div>
<div className="flex items-center gap-2 text-[10px] text-zinc-400 font-semibold">
<ClockIcon />
<span>{formatSeconds(start)} - {formatSeconds(end)}</span>
<span className="text-zinc-650"></span>
<span className="text-primary/80 font-bold">{(end - start).toFixed(0)}s duration</span>
</div>
<div className="flex items-center gap-1.5 text-[10px] font-bold text-primary mt-1 opacity-0 group-hover/hl:opacity-100 transition-opacity">
<PlayIcon /> Seek & Play
</div>
</button>
);
})
) : (
<div className="text-center py-8 text-xs text-zinc-500 font-semibold">
No highlights extracted.
</div>
)}
</div>
</div>
</div>
) : (
/* Clips Grid Gallery */
<div className="space-y-5">
<div className="flex items-center justify-between border-b border-zinc-900 pb-3.5">
<h3 className="text-xs font-black text-white uppercase tracking-widest">
Extracted Video Clips
</h3>
<span className="text-[10px] font-bold text-zinc-400 bg-zinc-900 px-2.5 py-1 rounded border border-zinc-800">
Aspect Ratio: {result.aspectRatio}
</span>
</div>
{result.clips && result.clips.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-6">
{result.clips.map((clipUrl, i) => (
<div
key={i}
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"
>
<div className="relative group/vid border-b border-white/5 overflow-hidden bg-black/40">
<video
src={clipUrl}
className={`w-full ${getAspectClass(result.aspectRatio)} object-cover bg-black/40 cursor-pointer hover:opacity-85 transition-opacity`}
onClick={() => setFullscreenUrl(clipUrl)}
controls={false}
loop
muted
playsInline
onMouseOver={(e) => e.target.play()}
onMouseOut={(e) => {
e.target.pause();
e.target.currentTime = 0;
}}
/>
{/* Overlay actions */}
<div className="absolute top-2 right-2 flex flex-col gap-2 opacity-0 group-hover/vid:opacity-100 transition-opacity z-10">
<button
type="button"
title="Fullscreen"
onClick={(e) => {
e.stopPropagation();
setFullscreenUrl(clipUrl);
}}
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"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<polyline points="15 3 21 3 21 9" />
<polyline points="9 21 3 21 3 15" />
<line x1="21" y1="3" x2="14" y2="10" />
<line x1="3" y1="21" x2="10" y2="14" />
</svg>
</button>
<button
type="button"
title="Copy Link"
onClick={(e) => {
e.stopPropagation();
copyToClipboard(clipUrl);
}}
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"
>
<CopyIcon />
</button>
<button
type="button"
title="Download"
onClick={(e) => {
e.stopPropagation();
downloadVideo(clipUrl, `clip-${i + 1}.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"
>
<DownloadIcon />
</button>
</div>
<div className="absolute top-2 left-2 bg-black/60 backdrop-blur-md px-2 py-1 rounded border border-white/5 text-[9px] uppercase font-black tracking-wider text-primary">
Clip #{i + 1}
</div>
</div>
<div className="p-3 bg-black/80 backdrop-blur-sm border-t border-white/5 flex-1 flex flex-col justify-between gap-2">
<div className="flex items-center justify-between mt-1">
<span className="text-[10px] font-bold text-primary px-2 py-0.5 bg-primary/10 rounded border border-primary/20 whitespace-nowrap">
Clip #{i + 1}
</span>
<span className="text-[10px] text-white/40">{result.aspectRatio}</span>
</div>
</div>
</div>
))}
</div>
) : (
<div className="py-20 text-center text-xs text-zinc-500 font-semibold border border-zinc-900 rounded bg-zinc-950/20">
No video clips generated. Try re-running.
</div>
)}
</div>
)}
</div>
)}
</div>
{/* ─── FLOATING BOTTOM PROMPT BAR ─── */}
<div className="absolute bottom-4 w-full max-w-[95%] lg:max-w-4xl z-40 animate-fade-in-up" style={{ animationDelay: "0.2s" }}>
<div className="w-full bg-gradient-to-b from-[#18181c]/90 via-[#0f0f12]/90 to-[#0c0c0e]/95 backdrop-blur-2xl rounded-[2rem] border border-white/[0.08] p-4 flex flex-col gap-3 shadow-[0_15px_50px_rgba(0,0,0,0.8)]">
{/* Inline list of uploaded media files */}
{videoUrl && (
<div className="flex items-center gap-2.5 px-1 pb-1">
<div className="relative w-12 h-12 rounded-xl border border-white/10 overflow-hidden shadow-md group">
<video src={videoUrl} className="w-full h-full object-cover" muted playsInline />
<button
type="button"
onClick={clearVideoUpload}
className="absolute top-0.5 right-0.5 w-4 h-4 bg-black/60 hover:bg-black rounded-full flex items-center justify-center text-white/85 hover:text-white text-[8px] border border-white/5"
title="Clear video"
>
×
</button>
</div>
</div>
)}
{/* Upper row: upload button & prompt field */}
<div className="flex items-start gap-3 px-1">
{/* Hidden file input */}
<input
ref={videoFileInputRef}
type="file"
accept="video/*"
className="hidden"
onChange={handleVideoFileChange}
/>
{/* Sleek round upload button */}
{!videoUrl && (
<button
type="button"
title="Upload source video"
onClick={() => videoFileInputRef.current?.click()}
className="w-10 h-10 shrink-0 rounded-full border bg-white/5 border-white/[0.03] hover:bg-white/10 hover:border-[#22d3ee]/40 transition-all flex items-center justify-center relative overflow-hidden group"
>
{videoUploading ? (
<div className="flex flex-col items-center justify-center w-full h-full absolute inset-0 bg-black/85 z-20 backdrop-blur-[1px]">
<svg className="w-8 h-8 -rotate-90">
<circle cx="16" cy="16" r="14" stroke="currentColor" strokeWidth="2" fill="transparent" className="text-white/10" />
<circle
cx="16"
cy="16"
r="14"
stroke="currentColor"
strokeWidth="2"
fill="transparent"
strokeDasharray={88}
strokeDashoffset={88 - (88 * videoProgress) / 100}
className="text-[#22d3ee] transition-all duration-300"
/>
</svg>
<span className="absolute text-[8px] font-black text-[#22d3ee] leading-none">
{videoProgress}%
</span>
</div>
) : null}
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-white/40 group-hover:text-[#22d3ee] transition-colors">
<polygon points="23 7 16 12 23 17 23 7" />
<rect x="1" y="5" width="15" height="14" rx="2" ry="2" />
</svg>
</button>
)}
{/* Prompt textarea (supports direct URL pasting too) */}
<div className="flex-1 flex flex-col gap-1">
<textarea
ref={promptTextareaRef}
value={prompt}
onChange={handlePromptInput}
placeholder="Describe prompt / highlights to extract"
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-[40px] max-h-[150px] overflow-y-auto custom-scrollbar"
/>
</div>
</div>
{/* Bottom row: controls + generate button */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-4 pt-3 border-t border-white/[0.03] relative">
<div className="flex items-center gap-2 relative flex-wrap pb-1 md:pb-0">
{/* Model Identifier (C) */}
<div className="flex items-center gap-2 px-3.5 h-[34px] bg-[#16161a]/60 rounded-md border border-white/[0.06] shadow-inner whitespace-nowrap">
<div className="w-4 h-4 bg-[#22d3ee] rounded flex items-center justify-center shadow-lg shadow-[#22d3ee]/10">
<span className="text-[9px] font-bold text-black uppercase">C</span>
</div>
<span className="text-xs font-semibold text-white/70">
AI Clipping
</span>
</div>
{/* Aspect Ratio selector */}
<div className="relative" ref={dropdownRef}>
<button
type="button"
onClick={() => setAspectDropdownOpen(!aspectDropdownOpen)}
className="h-[34px] flex items-center gap-2 px-3.5 bg-[#16161a]/60 hover:bg-[#202026]/80 rounded-md transition-all border border-white/[0.06] group whitespace-nowrap shadow-inner"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="opacity-40 text-white">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
</svg>
<span className="text-[11px] font-semibold text-white/70 group-hover:text-[#22d3ee] transition-colors">
{aspectRatio}
</span>
<ChevronDownIcon />
</button>
{aspectDropdownOpen && (
<div className="absolute bottom-[calc(100%+12px)] left-0 z-50 bg-[#0c0c0f]/95 rounded-xl p-3.5 shadow-[0_10px_40px_rgba(0,0,0,0.8)] border border-white/[0.08] backdrop-blur-2xl min-w-[160px]">
<div className="text-xs font-bold text-white/20 border-b border-white/[0.03] mb-2 pb-1">
Aspect Ratio
</div>
<div className="flex flex-col gap-1 max-h-60 overflow-y-auto custom-scrollbar">
{ASPECT_RATIOS.map((r) => (
<div
key={r.value}
className="flex items-center justify-between p-2.5 hover:bg-white/5 rounded cursor-pointer transition-all group/opt"
onClick={() => {
setAspectRatio(r.value);
setAspectDropdownOpen(false);
}}
>
<span className="text-[11px] font-semibold text-white/70 group-hover/opt:text-white transition-opacity">
{r.value}
</span>
{aspectRatio === r.value && <CheckIcon />}
</div>
))}
</div>
</div>
)}
</div>
{/* Highlights Limit selector */}
<div className="relative" ref={highlightsDropdownRef}>
<button
type="button"
onClick={() => setHighlightsDropdownOpen(!highlightsDropdownOpen)}
className="h-[34px] flex items-center gap-2 px-3.5 bg-[#16161a]/60 hover:bg-[#202026]/80 rounded-md transition-all border border-white/[0.06] group whitespace-nowrap shadow-inner"
>
<ClockIcon />
<span className="text-[11px] font-semibold text-white/70 group-hover:text-[#22d3ee] transition-colors">
{numHighlights} Highlights
</span>
<ChevronDownIcon />
</button>
{highlightsDropdownOpen && (
<div className="absolute bottom-[calc(100%+12px)] left-0 z-50 bg-[#0c0c0f]/95 rounded-xl p-3.5 shadow-[0_10px_40px_rgba(0,0,0,0.8)] border border-white/[0.08] backdrop-blur-2xl min-w-[180px]">
<div className="text-xs font-bold text-white/20 border-b border-white/[0.03] mb-3 pb-1">
Max Highlights
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs text-white/60">Limit:</span>
<span className="text-xs font-black text-primary bg-primary/10 px-2.5 py-0.5 rounded">
{numHighlights}
</span>
</div>
<input
type="range"
min="1"
max="60"
step="1"
value={numHighlights}
onChange={(e) => setNumHighlights(Number(e.target.value))}
className="w-full h-1 bg-zinc-850 rounded appearance-none cursor-pointer accent-primary"
/>
</div>
</div>
)}
</div>
{/* Return Coordinates Toggle */}
<button
type="button"
onClick={() => setReturnCoordinatesOnly(!returnCoordinatesOnly)}
className={`h-[34px] flex items-center gap-2 px-3.5 rounded-md transition-all border whitespace-nowrap text-[11px] font-semibold shadow-inner ${
returnCoordinatesOnly
? "bg-[#22d3ee]/10 border-[#22d3ee]/20 text-[#22d3ee]"
: "bg-[#16161a]/60 border-white/[0.06] text-white/70 hover:bg-[#202026]/80 hover:text-white"
}`}
>
<ScissorsIcon className="w-3.5 h-3.5 text-current" />
<span>Coordinates Only</span>
</button>
</div>
{/* Generate button */}
<button
type="button"
onClick={handleGenerate}
disabled={isGenerating}
className="bg-[#22d3ee] text-black px-7 py-3 rounded-full font-black text-sm hover:opacity-95 hover:scale-[1.02] active:scale-[0.98] transition-all flex items-center justify-center gap-2 w-full sm:w-auto shadow-lg shadow-[#22d3ee]/20 hover:shadow-[#22d3ee]/35 border border-[#22d3ee]/10 z-10 uppercase tracking-wider"
>
{isGenerating ? (
<>
<span className="animate-spin inline-block text-black"></span>
<span>{elapsedTime}s</span>
</>
) : (
<>
<ScissorsIcon className="text-black w-4 h-4" />
<span>Generate</span>
</>
)}
</button>
</div>
</div>
</div>
<style jsx global>{`
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.08);
border-radius: 99px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.15);
}
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.08) transparent;
}
`}</style>
</div>
);
}
@@ -0,0 +1,44 @@
"use client";
import { useState, useEffect } from 'react';
import { CreativeCanvas } from 'design-agent';
import { getUserBalance } from '../muapi';
export default function DesignAgentStudio({ apiKey, isHeaderVisible, onToggleHeader }) {
const [userData, setUserData] = useState(null);
useEffect(() => {
sessionStorage.setItem("fromDesignAgent", "true");
if (!apiKey) return;
localStorage.setItem("token", apiKey);
const fetchUser = async () => {
try {
const data = await getUserBalance(apiKey);
setUserData({
username: data.email?.split('@')[0] || 'Studio User',
email: data.email,
balance: data.balance || 0
});
} catch (err) {
console.error('Failed to fetch user data for Design Agent:', err);
}
};
fetchUser();
}, [apiKey]);
return (
<div className="h-full w-full bg-black overflow-hidden design-agent-studio">
<CreativeCanvas
user={userData}
isAuthorized={!!userData}
creditConversionRate={200}
theme="dark"
onToggleHeader={onToggleHeader}
isHeaderVisible={isHeaderVisible}
/>
</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
@@ -0,0 +1,640 @@
"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(34, 211, 238, 0.3);
}
`;
// ── Icons ────────────────────────────────────────────────────────────────────
const CheckSvg = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#22d3ee" 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>
<button
type="button"
title="Delete"
onClick={(e) => {
e.stopPropagation();
if (confirm("Are you sure you want to delete this generated item?")) {
setHistory(prev => prev.filter(h => h.id !== entry.id));
}
}}
className="p-2 bg-black/60 backdrop-blur-md rounded-full text-red-400 hover:bg-red-500 hover:text-white transition-all border border-white/10"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" />
<line x1="10" y1="11" x2="10" y2="17" />
<line x1="14" y1="11" x2="14" y2="17" />
</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 min-h-[50vh]">
{/* Overlapping floating cards */}
<div className="flex items-center justify-center gap-1.5 md:gap-3 mb-10 select-none scale-90 sm:scale-100">
<div className="w-18 h-22 sm:w-24 sm:h-28 rounded-2xl border border-white/10 shadow-2xl -rotate-[12deg] transform hover:rotate-0 hover:scale-110 hover:z-20 transition-all duration-300 overflow-hidden bg-white/[0.01] flex-shrink-0">
<img
src="https://d3adwkbyhxyrtq.cloudfront.net/webassets/videomodels/sdxl-image.avif"
alt="Creative asset 1"
className="w-full h-full object-cover"
/>
</div>
<div className="w-18 h-22 sm:w-24 sm:h-28 rounded-2xl border border-white/10 shadow-2xl -rotate-[4deg] transform hover:rotate-0 hover:scale-110 hover:z-20 transition-all duration-300 overflow-hidden bg-white/[0.01] -ml-3 sm:-ml-4 flex-shrink-0">
<img
src="https://d3adwkbyhxyrtq.cloudfront.net/webassets/videomodels/chroma-image.avif"
alt="Creative asset 2"
className="w-full h-full object-cover"
/>
</div>
<div className="w-18 h-18 sm:w-24 sm:h-24 rounded-full border border-white/10 shadow-2xl rotate-[6deg] transform hover:rotate-0 hover:scale-110 hover:z-20 transition-all duration-300 overflow-hidden bg-white/[0.01] -ml-3 sm:-ml-4 flex-shrink-0">
<img
src="https://d3adwkbyhxyrtq.cloudfront.net/webassets/videomodels/neta-lumina.avif"
alt="Creative asset 3"
className="w-full h-full object-cover"
/>
</div>
<div className="w-18 h-22 sm:w-24 sm:h-28 rounded-2xl border border-white/10 shadow-2xl rotate-[12deg] transform hover:rotate-0 hover:scale-110 hover:z-20 transition-all duration-300 overflow-hidden bg-white/[0.01] -ml-3 sm:-ml-4 flex-shrink-0">
<img
src="https://d3adwkbyhxyrtq.cloudfront.net/webassets/videomodels/perfect-pony-xl.avif"
alt="Creative asset 4"
className="w-full h-full object-cover"
/>
</div>
</div>
<h1 className="text-2xl sm:text-4xl md:text-5xl font-extrabold tracking-tight mb-4 text-center px-4 flex flex-col items-center">
<span className="text-white font-black uppercase text-xl sm:text-3xl tracking-wide mb-1 opacity-90">START CREATING WITH</span>
<span className="text-[#22d3ee] font-black uppercase text-2xl sm:text-4xl sm:mt-1 tracking-tight">
MARKETING STUDIO
</span>
</h1>
<p className="text-white/40 text-xs sm:text-sm font-medium tracking-wide text-center max-w-lg leading-relaxed px-4">
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="w-full bg-gradient-to-b from-[#18181c]/90 via-[#0f0f12]/90 to-[#0c0c0e]/95 backdrop-blur-2xl rounded-[2rem] border border-white/[0.08] p-4 flex flex-col gap-3 shadow-[0_15px_50px_rgba(0,0,0,0.8)]">
{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-[40px] max-h-[150px] md:max-h-[250px] overflow-y-auto custom-scrollbar disabled:opacity-40"
/>
</div>
{/* Bottom Row: Uploads + Controls + Generate */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-4 pt-3 border-t border-white/[0.03] relative">
<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-[#22d3ee] text-black px-7 py-3 rounded-full font-black text-sm hover:opacity-95 hover:scale-[1.02] active:scale-[0.98] transition-all flex items-center justify-center gap-2 w-full sm:w-auto shadow-lg shadow-[#22d3ee]/20 hover:shadow-[#22d3ee]/35 border border-[#22d3ee]/10 z-10"
>
{isGenerating ? (
<>
<span className="animate-spin inline-block text-black"></span>
Generating...
</>
) : (
<span>Launch</span>
)}
</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>
);
}
@@ -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-[#22d3ee] 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 &amp; AI agents
</div>
<h1 className="text-4xl md:text-5xl font-bold tracking-tight">MCP &amp; CLI</h1>
<p className="text-white/60 text-base md:text-lg max-w-2xl">
Use Open Generative 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>
);
}
@@ -0,0 +1,845 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { processRecast, uploadFile } from "../muapi.js";
import {
recastModels,
getRecastModelById,
getAspectRatiosForRecastModel,
} from "../models.js";
// ---------------------------------------------------------------------------
// Upload button states
// ---------------------------------------------------------------------------
const UPLOAD_STATE = {
IDLE: "idle",
UPLOADING: "uploading",
READY: "ready",
};
function MediaPickerButton({
accept,
label,
icon,
onUpload,
onClear,
uploadState,
progress,
fileName,
previewUrl,
isVideo,
}) {
const inputRef = useRef(null);
const handleClick = (e) => {
e.stopPropagation();
if (uploadState === UPLOAD_STATE.READY) {
onClear();
return;
}
inputRef.current?.click();
};
const handleChange = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
e.target.value = "";
await onUpload(file);
};
const borderClass =
uploadState === UPLOAD_STATE.READY
? "border-primary/60 bg-primary/5"
: "border-white/[0.03] bg-white/[0.03] hover:bg-white/[0.06] hover:border-primary/40";
return (
<button
type="button"
title={
uploadState === UPLOAD_STATE.READY
? `${fileName} — click to clear`
: `Upload ${label.toLowerCase()} file`
}
onClick={handleClick}
className={`flex-shrink-0 w-10 h-10 rounded-full border transition-all flex items-center justify-center relative overflow-hidden group ${borderClass}`}
>
<input
ref={inputRef}
type="file"
accept={accept}
className="hidden"
onChange={handleChange}
/>
{/* Idle state */}
{uploadState === UPLOAD_STATE.IDLE && (
<div className="flex flex-col items-center justify-center gap-1 w-full h-full">
{icon}
</div>
)}
{/* Uploading indicator */}
{uploadState === UPLOAD_STATE.UPLOADING && (
<div className="flex flex-col items-center justify-center w-full h-full absolute inset-0 bg-black/80 z-20 backdrop-blur-[2px]">
<svg className="w-8 h-8 -rotate-90">
<circle
cx="16"
cy="16"
r="14"
stroke="currentColor"
strokeWidth="2"
fill="transparent"
className="text-white/10"
/>
<circle
cx="16"
cy="16"
r="14"
stroke="currentColor"
strokeWidth="2"
fill="transparent"
strokeDasharray={88}
strokeDashoffset={88 - (88 * progress) / 100}
className="text-primary transition-all duration-300"
/>
</svg>
<span className="absolute text-[9px] font-black text-primary leading-none">
{progress}%
</span>
</div>
)}
{/* Ready state */}
{uploadState === UPLOAD_STATE.READY && (
<div className="flex flex-col items-center justify-center gap-1 w-full h-full absolute inset-0 bg-primary/10 rounded-full group-hover:bg-primary/20 transition-all">
{previewUrl ? (
isVideo ? (
<video
src={previewUrl}
className="w-full h-full object-cover"
muted
/>
) : (
<img
src={previewUrl}
alt=""
className="w-full h-full object-cover"
/>
)
) : (
icon
)}
</div>
)}
</button>
);
}
// ---------------------------------------------------------------------------
// Inline dropdown
// ---------------------------------------------------------------------------
function Dropdown({ isOpen, items, selectedId, onSelect, onClose, anchorRef }) {
const dropRef = useRef(null);
const [style, setStyle] = useState({});
useEffect(() => {
if (!isOpen || !anchorRef?.current || !dropRef.current) return;
const rect = anchorRef.current.getBoundingClientRect();
const ddHeight = dropRef.current.offsetHeight;
const spaceBelow = window.innerHeight - rect.bottom - 8;
const spaceAbove = rect.top - 8;
let top, bottom, maxHeight;
if (spaceBelow >= ddHeight || spaceBelow >= spaceAbove) {
top = rect.bottom + 8;
bottom = "auto";
maxHeight = Math.max(150, spaceBelow - 8);
} else {
top = "auto";
bottom = window.innerHeight - rect.top + 8;
maxHeight = Math.max(150, spaceAbove - 8);
}
const left = Math.min(rect.left, window.innerWidth - 220);
setStyle({ top, bottom, left, maxHeight });
}, [isOpen, anchorRef]);
useEffect(() => {
if (!isOpen) return;
const handler = (e) => {
if (
!dropRef.current?.contains(e.target) &&
!anchorRef?.current?.contains(e.target)
) {
onClose();
}
};
window.addEventListener("click", handler);
return () => window.removeEventListener("click", handler);
}, [isOpen, onClose, anchorRef]);
if (!isOpen) return null;
return (
<div
ref={dropRef}
style={{
position: "fixed",
zIndex: 100,
overflowY: "auto",
...style,
}}
className="bg-[#111] border border-white/10 rounded-lg shadow-3xl p-2 custom-scrollbar w-[calc(100vw-3rem)] max-w-xs"
>
{items.map((item) => (
<button
key={item.id}
type="button"
onClick={() => {
onSelect(item);
onClose();
}}
className={`w-full text-left px-4 py-2 rounded text-sm transition-all hover:bg-white/10 ${
item.id === selectedId
? "text-primary font-bold bg-primary/5"
: "text-white font-medium"
}`}
>
<div>{item.name}</div>
{item.description && (
<div className="text-xs text-muted mt-0.5">
{item.description.slice(0, 60)}...
</div>
)}
</button>
))}
</div>
);
}
// ---------------------------------------------------------------------------
// SVG icons
// ---------------------------------------------------------------------------
const VideoIcon = ({
className = "text-white/40 group-hover:text-primary transition-colors",
}) => (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
className={className}
>
<polygon points="23 7 16 12 23 17 23 7" />
<rect x="1" y="5" width="15" height="14" rx="2" ry="2" />
</svg>
);
const ImageIcon = ({
className = "text-white/40 group-hover:text-primary transition-colors",
}) => (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
className={className}
>
<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>
);
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export default function RecastStudio({
apiKey,
onGenerationComplete,
historyItems,
droppedFiles,
onFilesHandled,
}) {
const PERSIST_KEY = "hg_recast_studio_persistent";
// ── Model state ───────────────────────────────────────────────────────────
const firstModel = recastModels[0];
const [selectedModelId, setSelectedModelId] = useState(firstModel?.id ?? "");
const [selectedAspectRatio, setSelectedAspectRatio] = useState(
firstModel?.inputs?.aspect_ratio?.default ?? "16:9",
);
// ── Upload state ──────────────────────────────────────────────────────────
const [videoState, setVideoState] = useState(UPLOAD_STATE.IDLE);
const [videoName, setVideoName] = useState("");
const [videoUrl, setVideoUrl] = useState(null);
const [videoProgress, setVideoProgress] = useState(0);
const [imageState, setImageState] = useState(UPLOAD_STATE.IDLE);
const [imageName, setImageName] = useState("");
const [imageUrl, setImageUrl] = useState(null);
const [imageProgress, setImageProgress] = useState(0);
// ── Prompt ────────────────────────────────────────────────────────────────
const [prompt, setPrompt] = useState("");
// ── Generation / UI state ─────────────────────────────────────────────────
const [isGenerating, setIsGenerating] = useState(false);
const [generateError, setGenerateError] = useState(null);
const [fullscreenUrl, setFullscreenUrl] = useState(null);
// ── History ───────────────────────────────────────────────────────────────
const [internalHistory, setInternalHistory] = useState([]);
const history = historyItems ?? internalHistory;
// ── Dropdown state ────────────────────────────────────────────────────────
const [openDropdown, setOpenDropdown] = useState(null); // 'model' | 'aspect' | null
const modelBtnRef = useRef(null);
const aspectBtnRef = useRef(null);
const textareaRef = useRef(null);
const hasRestored = useRef(false);
// ── Persistence: Load ──────────────────────────────────────────────────────
useEffect(() => {
try {
const stored = localStorage.getItem(PERSIST_KEY);
if (stored) {
const data = JSON.parse(stored);
if (data.selectedModelId) setSelectedModelId(data.selectedModelId);
if (data.selectedAspectRatio) setSelectedAspectRatio(data.selectedAspectRatio);
if (data.videoUrl) {
setVideoUrl(data.videoUrl);
setVideoState(UPLOAD_STATE.READY);
}
if (data.imageUrl) {
setImageUrl(data.imageUrl);
setImageState(UPLOAD_STATE.READY);
}
if (data.videoName) setVideoName(data.videoName);
if (data.imageName) setImageName(data.imageName);
if (data.prompt) setPrompt(data.prompt);
if (data.internalHistory) setInternalHistory(data.internalHistory);
}
} catch (err) {
console.warn("Failed to load RecastStudio persistence:", err);
} finally {
hasRestored.current = true;
}
}, []);
// ── Persistence: Save ──────────────────────────────────────────────────────
useEffect(() => {
const timer = setTimeout(() => {
try {
localStorage.setItem(
PERSIST_KEY,
JSON.stringify({
selectedModelId,
selectedAspectRatio,
videoUrl,
videoName,
imageUrl,
imageName,
prompt,
internalHistory,
}),
);
} catch (err) {
console.warn("Failed to save RecastStudio persistence:", err);
}
}, 500);
return () => clearTimeout(timer);
}, [
selectedModelId,
selectedAspectRatio,
videoUrl,
videoName,
imageUrl,
imageName,
prompt,
internalHistory,
]);
// ── Derived model info ──────────────────────────────────────────────────────
const selectedModel = getRecastModelById(selectedModelId);
const aspectOptions = getAspectRatiosForRecastModel(selectedModelId);
const showAspect = aspectOptions.length > 0;
const showPrompt = !!selectedModel?.hasPrompt;
// ── Upload handlers ─────────────────────────────────────────────────────────
const handleVideoPick = useCallback(
async (file) => {
if (file.size > 50 * 1024 * 1024) {
alert("Video exceeds 50MB limit.");
return;
}
setVideoState(UPLOAD_STATE.UPLOADING);
setVideoProgress(0);
try {
const url = await uploadFile(apiKey, file, (pct) => setVideoProgress(pct));
setVideoUrl(url);
setVideoName(file.name);
setVideoState(UPLOAD_STATE.READY);
} catch (err) {
setVideoState(UPLOAD_STATE.IDLE);
alert(`Video upload failed: ${err.message}`);
} finally {
setVideoProgress(0);
}
},
[apiKey],
);
const handlePromptInput = (e) => {
setPrompt(e.target.value);
const el = e.target;
el.style.height = "auto";
const maxH = window.innerWidth < 768 ? 150 : 250;
el.style.height = Math.min(el.scrollHeight, maxH) + "px";
};
const handleImageUpload = useCallback(
async (file) => {
if (file.size > 10 * 1024 * 1024) {
alert("Image exceeds 10MB limit.");
return;
}
setImageState(UPLOAD_STATE.UPLOADING);
setImageProgress(0);
try {
const url = await uploadFile(apiKey, file, (pct) => setImageProgress(pct));
setImageUrl(url);
setImageName(file.name);
setImageState(UPLOAD_STATE.READY);
} catch (err) {
setImageState(UPLOAD_STATE.IDLE);
alert(`Image upload failed: ${err.message}`);
} finally {
setImageProgress(0);
}
},
[apiKey],
);
// ── Handle Dropped Files ────────────────────────────────────────────────────
useEffect(() => {
if (droppedFiles && droppedFiles.length > 0) {
const imageFiles = droppedFiles.filter((f) => f.type.startsWith("image/"));
const videoFiles = droppedFiles.filter((f) => f.type.startsWith("video/"));
if (videoFiles.length > 0) handleVideoPick(videoFiles[0]);
if (imageFiles.length > 0) handleImageUpload(imageFiles[0]);
onFilesHandled?.();
}
}, [droppedFiles, onFilesHandled, handleVideoPick, handleImageUpload]);
// ── Model selection ─────────────────────────────────────────────────────────
const handleModelSelect = (model) => {
setSelectedModelId(model.id);
const ratios = getAspectRatiosForRecastModel(model.id);
if (ratios.length > 0) {
setSelectedAspectRatio(model.inputs?.aspect_ratio?.default ?? ratios[0]);
}
};
// ── History helpers ─────────────────────────────────────────────────────────
const addToInternalHistory = useCallback((entry) => {
setInternalHistory((prev) => [entry, ...prev].slice(0, 30));
}, []);
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");
}
};
// ── Generation ──────────────────────────────────────────────────────────────
const handleGenerate = async () => {
if (!videoUrl) {
alert("Please upload a source video first.");
return;
}
if (!imageUrl) {
alert("Please upload a character image first.");
return;
}
setIsGenerating(true);
setGenerateError(null);
try {
const params = {
model: selectedModelId,
video_url: videoUrl,
image_url: imageUrl,
};
if (showAspect) params.aspect_ratio = selectedAspectRatio;
if (prompt && selectedModel?.hasPrompt) params.prompt = prompt;
const res = await processRecast(apiKey, params);
if (!res?.url) throw new Error("No video URL returned by API");
const genId = res.id || Date.now().toString();
const entry = {
id: genId,
url: res.url,
prompt,
model: selectedModel?.name || selectedModelId,
timestamp: new Date().toISOString(),
};
if (!historyItems) addToInternalHistory(entry);
if (onGenerationComplete) {
onGenerationComplete({
url: res.url,
model: selectedModelId,
prompt,
type: "recast",
});
}
} catch (e) {
console.error("[RecastStudio]", e);
setGenerateError(e.message?.slice(0, 80) ?? "Unknown error");
setTimeout(() => setGenerateError(null), 4000);
} finally {
setIsGenerating(false);
}
};
// ── Dropdown item lists ─────────────────────────────────────────────────────
const aspectDropdownItems = aspectOptions.map((r) => ({ id: r, name: r }));
// ── Render ────────────────────────────────────────────────────────────────
return (
<div className="w-full h-full flex flex-col items-center justify-center bg-app-bg relative overflow-hidden">
{/* ── CENTRAL GALLERY AREA ── */}
<div className="flex-1 w-full max-w-7xl mx-auto overflow-y-auto custom-scrollbar pb-40 lg:pb-32 px-2">
{history.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 w-full pt-4 animate-fade-in-up">
{history.map((entry, idx) => (
<div
key={entry.id || idx}
className="relative group rounded-2xl 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 bg-black/40 cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => setFullscreenUrl(entry.url)}
controls={false}
loop
muted
playsInline
onMouseOver={(e) => e.target.play()}
onMouseOut={(e) => {
e.target.pause();
e.target.currentTime = 0;
}}
/>
{/* Overlay actions */}
<div className="absolute top-2 right-2 flex flex-col gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
title="Fullscreen"
onClick={(e) => {
e.stopPropagation();
setFullscreenUrl(entry.url);
}}
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"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<polyline points="15 3 21 3 21 9" />
<polyline points="9 21 3 21 3 15" />
<line x1="21" y1="3" x2="14" y2="10" />
<line x1="3" y1="21" x2="10" y2="14" />
</svg>
</button>
<button
type="button"
title="Download"
onClick={(e) => {
e.stopPropagation();
downloadFile(entry.url, `bodyswap-${entry.id || idx}.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"
>
<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>
<button
type="button"
title="Delete"
onClick={(e) => {
e.stopPropagation();
if (confirm("Are you sure you want to delete this generated item?")) {
setInternalHistory(prev => prev.filter((_, i) => i !== idx));
}
}}
className="p-2 bg-black/60 backdrop-blur-md rounded-full text-red-400 hover:bg-red-500 hover:text-white transition-all border border-white/10"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" />
<line x1="10" y1="11" x2="10" y2="17" />
<line x1="14" y1="11" x2="14" y2="17" />
</svg>
</button>
</div>
{/* Details */}
<div className="p-3 bg-black/80 backdrop-blur-sm border-t border-white/5 flex-1 flex flex-col justify-between gap-2">
<div className="flex items-center justify-between flex-wrap gap-1">
<span className="text-[10px] font-bold text-primary px-2 py-0.5 bg-primary/10 rounded border border-primary/20 whitespace-nowrap">
{entry.model?.name || entry.model || "Body Swap"}
</span>
</div>
</div>
</div>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center h-full animate-fade-in-up transition-all duration-700 min-h-[50vh]">
{/* Overlapping floating cards */}
<div className="flex items-center justify-center gap-1.5 md:gap-3 mb-10 select-none scale-90 sm:scale-100">
<div className="w-18 h-22 sm:w-24 sm:h-28 rounded-2xl border border-white/10 shadow-2xl -rotate-[12deg] transform hover:rotate-0 hover:scale-110 hover:z-20 transition-all duration-300 overflow-hidden bg-white/[0.01] flex-shrink-0">
<img
src="https://d3adwkbyhxyrtq.cloudfront.net/webassets/videomodels/sdxl-image.avif"
alt="Creative asset 1"
className="w-full h-full object-cover"
/>
</div>
<div className="w-18 h-22 sm:w-24 sm:h-28 rounded-2xl border border-white/10 shadow-2xl -rotate-[4deg] transform hover:rotate-0 hover:scale-110 hover:z-20 transition-all duration-300 overflow-hidden bg-white/[0.01] -ml-3 sm:-ml-4 flex-shrink-0">
<img
src="https://d3adwkbyhxyrtq.cloudfront.net/webassets/videomodels/chroma-image.avif"
alt="Creative asset 2"
className="w-full h-full object-cover"
/>
</div>
<div className="w-18 h-18 sm:w-24 sm:h-24 rounded-full border border-white/10 shadow-2xl rotate-[6deg] transform hover:rotate-0 hover:scale-110 hover:z-20 transition-all duration-300 overflow-hidden bg-white/[0.01] -ml-3 sm:-ml-4 flex-shrink-0">
<img
src="https://d3adwkbyhxyrtq.cloudfront.net/webassets/videomodels/neta-lumina.avif"
alt="Creative asset 3"
className="w-full h-full object-cover"
/>
</div>
<div className="w-18 h-22 sm:w-24 sm:h-28 rounded-2xl border border-white/10 shadow-2xl rotate-[12deg] transform hover:rotate-0 hover:scale-110 hover:z-20 transition-all duration-300 overflow-hidden bg-white/[0.01] -ml-3 sm:-ml-4 flex-shrink-0">
<img
src="https://d3adwkbyhxyrtq.cloudfront.net/webassets/videomodels/perfect-pony-xl.avif"
alt="Creative asset 4"
className="w-full h-full object-cover"
/>
</div>
</div>
<h1 className="text-2xl sm:text-4xl md:text-5xl font-extrabold tracking-tight mb-4 text-center px-4 flex flex-col items-center">
<span className="text-white font-black uppercase text-xl sm:text-3xl tracking-wide mb-1 opacity-90">START CREATING WITH</span>
<span className="text-[#22d3ee] font-black uppercase text-2xl sm:text-4xl sm:mt-1 tracking-tight">
BODY SWAP STUDIO
</span>
</h1>
<p className="text-white/40 text-xs sm:text-sm font-medium tracking-wide text-center max-w-lg leading-relaxed px-4">
Swap the character in any video dynamically by choosing a video clip and a target character image.
</p>
</div>
)}
</div>
{/* ── BOTTOM PROMPT BAR ── */}
<div className="absolute bottom-4 w-full max-w-[95%] lg:max-w-4xl z-40 animate-fade-in-up" style={{ animationDelay: "0.2s" }}>
<div className="w-full bg-gradient-to-b from-[#18181c]/90 via-[#0f0f12]/90 to-[#0c0c0e]/95 backdrop-blur-2xl rounded-[2rem] border border-white/[0.08] p-4 flex flex-col gap-3 shadow-[0_15px_50px_rgba(0,0,0,0.8)]">
{/* Uploads row */}
<div className="flex items-center gap-2 px-1">
<div className="flex items-center gap-2">
{/* Source video */}
<MediaPickerButton
accept="video/*"
label="Video"
icon={<VideoIcon className="text-white/40 group-hover:text-[#22d3ee] transition-colors" />}
onUpload={handleVideoPick}
onClear={() => {
setVideoUrl(null);
setVideoState(UPLOAD_STATE.IDLE);
setVideoName("");
}}
uploadState={videoState}
progress={videoProgress}
fileName={videoName}
previewUrl={videoUrl}
isVideo={true}
/>
{/* Character image */}
<MediaPickerButton
accept="image/*"
label="Character image"
icon={<ImageIcon className="text-white/40 group-hover:text-[#22d3ee] transition-colors" />}
onUpload={handleImageUpload}
onClear={() => {
setImageUrl(null);
setImageState(UPLOAD_STATE.IDLE);
setImageName("");
}}
uploadState={imageState}
progress={imageProgress}
fileName={imageName}
previewUrl={imageUrl}
isVideo={false}
/>
</div>
{/* Prompt textarea */}
<div className="flex-1 flex flex-col">
<textarea
ref={textareaRef}
value={prompt}
onChange={handlePromptInput}
placeholder="Optional — describe the motion or scene..."
className="w-full bg-transparent border-none text-white text-sm placeholder:text-white/10 focus:outline-none resize-none pt-1 leading-relaxed min-h-[40px] max-h-[150px] md:max-h-[250px] overflow-y-auto custom-scrollbar disabled:opacity-40"
rows={1}
/>
</div>
</div>
{/* Bottom controls row */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-4 pt-3 border-t border-white/[0.03] relative">
<div className="flex items-center gap-2 px-1">
{/* Model selector */}
<div className="relative">
<button
ref={modelBtnRef}
type="button"
onClick={(e) => {
e.stopPropagation();
setOpenDropdown(openDropdown === "model" ? null : "model");
}}
className="h-[34px] flex items-center gap-2 px-3.5 bg-[#16161a]/60 hover:bg-[#202026]/80 rounded-md transition-all border border-white/[0.06] group whitespace-nowrap shadow-inner"
>
<div className="w-3.5 h-3.5 bg-[#22d3ee] rounded-sm flex items-center justify-center">
<span className="text-[9px] font-black text-black">R</span>
</div>
<span className="text-xs font-semibold text-white/70 group-hover:text-[#22d3ee] transition-colors">
{selectedModel?.name ?? "Select model"}
</span>
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="4"
className="opacity-50 group-hover:opacity-100 transition-opacity flex-shrink-0"
>
<path d="M6 9l6 6 6-6" />
</svg>
</button>
<Dropdown
isOpen={openDropdown === "model"}
items={recastModels}
selectedId={selectedModelId}
onSelect={handleModelSelect}
onClose={() => setOpenDropdown(null)}
anchorRef={modelBtnRef}
/>
</div>
{/* Aspect ratio selector */}
{showAspect && (
<div className="relative">
<button
ref={aspectBtnRef}
type="button"
onClick={(e) => {
e.stopPropagation();
setOpenDropdown(openDropdown === "aspect" ? null : "aspect");
}}
className="h-[34px] flex items-center gap-2 px-3.5 bg-[#16161a]/60 hover:bg-[#202026]/80 rounded-md transition-all border border-white/[0.06] group whitespace-nowrap shadow-inner"
>
<span className="text-xs font-semibold text-white/70 group-hover:text-[#22d3ee] transition-colors">
{selectedAspectRatio}
</span>
</button>
<Dropdown
isOpen={openDropdown === "aspect"}
items={aspectDropdownItems}
selectedId={selectedAspectRatio}
onSelect={(item) => setSelectedAspectRatio(item.id)}
onClose={() => setOpenDropdown(null)}
anchorRef={aspectBtnRef}
/>
</div>
)}
</div>
{/* Generate button */}
<button
type="button"
onClick={handleGenerate}
disabled={isGenerating}
className="bg-[#22d3ee] text-black px-7 py-3 rounded-full font-black text-sm hover:opacity-95 hover:scale-[1.02] active:scale-[0.98] transition-all flex items-center justify-center gap-2 w-full sm:w-auto shadow-lg shadow-[#22d3ee]/20 hover:shadow-[#22d3ee]/35 border border-[#22d3ee]/10 z-10"
>
{isGenerating ? (
<>
<span className="animate-spin inline-block text-black"></span>{" "}
Swapping...
</>
) : generateError ? (
`Error: ${generateError}`
) : (
<span>Swap Body</span>
)}
</button>
</div>
</div>
</div>
{/* ── FULLSCREEN MEDIA MODAL ── */}
{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
type="button"
className="absolute top-6 right-6 p-3 bg-white/10 hover:bg-white/20 rounded-full text-white transition-colors border border-white/10"
onClick={(e) => {
e.stopPropagation();
setFullscreenUrl(null);
}}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
<video
src={fullscreenUrl}
controls
autoPlay
loop
className="max-w-[95vw] max-h-[95vh] rounded-2xl shadow-2xl object-contain animate-scale-up"
onClick={(e) => e.stopPropagation()}
/>
</div>
)}
</div>
);
}
@@ -0,0 +1,667 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { runMotionGraphics, runMotionGraphicsEdit } from "../muapi.js";
// ── helpers ───────────────────────────────────────────────────────────────────
async function downloadFile(url, filename) {
try {
const res = await fetch(url);
const blob = await res.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 formatTime = (s) =>
`${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
// ── icons ─────────────────────────────────────────────────────────────────────
const CheckSvg = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#22d3ee" strokeWidth="4">
<polyline points="20 6 9 17 4 12" />
</svg>
);
// ── Dropdown helper ───────────────────────────────────────────────────────────
function DropdownItem({ label, selected, onClick }) {
return (
<div
className="flex items-center justify-between p-3.5 hover:bg-white/5 rounded cursor-pointer transition-all group"
onClick={onClick}
>
<span className="text-xs font-bold text-white opacity-80 group-hover:opacity-100">
{label}
</span>
{selected && <CheckSvg />}
</div>
);
}
// ── Main Component ────────────────────────────────────────────────────────────
export default function VibeMotionStudio({ apiKey }) {
const PERSIST_KEY = "hg_vibe_motion_studio_persistent";
// ── Params ────────────────────────────────────────────────────────────────
const [prompt, setPrompt] = useState("");
const [aspectRatio, setAspectRatio] = useState("16:9");
const [duration, setDuration] = useState(6);
// ── Edit mode ─────────────────────────────────────────────────────────────
const [editMode, setEditMode] = useState(false);
const [editSourceId, setEditSourceId] = useState(null); // request_id of source
// ── Dropdown open state ───────────────────────────────────────────────────
const [openDropdown, setOpenDropdown] = useState(null); // "ar" | "dur" | "source"
const containerRef = useRef(null);
const textareaRef = useRef(null);
// ── Generation state ──────────────────────────────────────────────────────
const [generating, setGenerating] = useState(false);
const [generateError, setGenerateError] = useState(null);
const [elapsedTime, setElapsedTime] = useState(0);
const timerRef = useRef(null);
const pendingRequestId = useRef(null);
// ── History ───────────────────────────────────────────────────────────────
const [history, setHistory] = useState([]);
const [fullscreenUrl, setFullscreenUrl] = useState(null);
// ── Load from localStorage ─────────────────────────────────────────────────
useEffect(() => {
try {
const saved = JSON.parse(localStorage.getItem(PERSIST_KEY) || "[]");
if (Array.isArray(saved)) {
// Strip any wrongly-persisted canEdit:false flags from old bug — restore all entries as remixable
const restored = saved.map((h) => {
const { canEdit, ...rest } = h;
return rest; // canEdit is only an in-memory hint, never persisted
});
setHistory(restored);
}
} catch (_) {}
}, []);
const saveHistory = useCallback((items) => {
setHistory(items);
// Strip canEdit from persisted data — it is an in-memory hint only
const stripped = items.map(({ canEdit, ...rest }) => rest);
try { localStorage.setItem(PERSIST_KEY, JSON.stringify(stripped)); } catch (_) {}
}, []);
// ── Close dropdowns on outside click ─────────────────────────────────────
useEffect(() => {
const handler = (e) => {
if (containerRef.current && !containerRef.current.contains(e.target)) {
setOpenDropdown(null);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
// ── Timer ─────────────────────────────────────────────────────────────────
const startTimer = () => {
setElapsedTime(0);
timerRef.current = setInterval(() => setElapsedTime((t) => t + 1), 1000);
};
const stopTimer = () => { clearInterval(timerRef.current); timerRef.current = null; };
useEffect(() => () => stopTimer(), []);
// ── Generate ──────────────────────────────────────────────────────────────
const handleGenerate = useCallback(async () => {
if (!prompt.trim() || generating) return;
setGenerating(true);
setGenerateError(null);
startTimer();
try {
let result;
if (editMode) {
result = await runMotionGraphicsEdit(apiKey, {
request_id: editSourceId,
edit_prompt: prompt.trim(),
aspect_ratio: aspectRatio,
duration_seconds: duration,
onRequestId: (id) => { pendingRequestId.current = id; },
});
} else {
result = await runMotionGraphics(apiKey, {
prompt: prompt.trim(),
aspect_ratio: aspectRatio,
duration_seconds: duration,
onRequestId: (id) => { pendingRequestId.current = id; },
});
}
const videoUrl = result?.output?.video || result?.url || result?.outputs?.[0];
const requestId = result?.id || result?.request_id || pendingRequestId.current;
const entry = {
id: requestId || Date.now().toString(),
requestId,
url: videoUrl,
prompt: prompt.trim(),
aspectRatio,
duration,
mode: editMode ? "edit" : "generate",
sourceId: editMode ? editSourceId : null,
timestamp: new Date().toISOString(),
// Mark as editable — only generations created with saved animation code can be remixed
canEdit: true,
};
const next = [entry, ...history].slice(0, 30);
saveHistory(next);
} catch (err) {
// Detect the backend's "animation code not saved" limitation
const raw = err.message || "";
const isStaleEdit =
raw.includes("animation code") ||
raw.includes("does not have saved") ||
raw.includes("Original generation does not");
if (isStaleEdit) {
// Known backend limitation — warn only (not error), keep console clean
console.warn("[VibeMotionStudio] Remix unavailable:", raw.slice(0, 120));
setGenerateError(
"This generation can't be remixed — the animation code wasn't saved server-side. " +
"Generate a new motion graphic first, then remix that result."
);
// Exit edit mode WITHOUT persisting canEdit:false — let user retry after refresh
setEditMode(false);
setEditSourceId(null);
} else {
console.error("[VibeMotionStudio]", err);
setGenerateError(raw.slice(0, 120) || "Generation failed");
}
setTimeout(() => setGenerateError(null), 10000);
} finally {
setGenerating(false);
stopTimer();
}
}, [apiKey, prompt, editMode, editSourceId, aspectRatio, duration, history, saveHistory]);
const handleKeyDown = (e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) handleGenerate();
};
const toggleDropdown = (type) => (e) => {
e.stopPropagation();
setOpenDropdown((prev) => (prev === type ? null : type));
};
const ASPECT_RATIOS = ["16:9", "9:16", "1:1"];
const DURATION_OPTIONS = [5, 6, 8, 10, 12, 15, 20, 25, 30];
// Show all entries with a requestId as editable UNLESS they are explicitly marked canEdit:false
// (entries loaded from localStorage without the flag are treated as optimistically editable)
const editSources = history.filter((h) => h.requestId && h.canEdit !== false);
const sourceEntry = editSources.find((h) => h.requestId === editSourceId);
// ── Render ────────────────────────────────────────────────────────────────
return (
<div
ref={containerRef}
className="w-full h-full flex flex-col items-center justify-center bg-app-bg relative overflow-hidden"
>
{/* ── Fullscreen overlay ── */}
{fullscreenUrl && (
<div
className="fixed inset-0 z-[200] bg-black/95 flex items-center justify-center"
onClick={() => setFullscreenUrl(null)}
>
<video
src={fullscreenUrl}
autoPlay loop controls
className="max-h-[90vh] max-w-[90vw] rounded shadow-2xl"
onClick={(e) => e.stopPropagation()}
/>
<button
className="absolute top-6 right-6 text-white/60 hover:text-white transition-colors text-3xl font-light leading-none"
onClick={() => setFullscreenUrl(null)}
>×</button>
</div>
)}
{/* ── GALLERY AREA ── */}
<div className="flex-1 w-full max-w-7xl mx-auto overflow-y-auto custom-scrollbar pb-40 lg:pb-32 px-2">
{generating && (
/* ── Loading card at top of grid ── */
<div className="w-full pt-6 flex justify-center animate-fade-in-up">
<div className="flex flex-col items-center gap-4 py-16">
<div className="relative w-20 h-20">
<div className="absolute inset-0 rounded-full border-2 border-violet-500/20 animate-ping" />
<div className="absolute inset-2 rounded-full border-2 border-[#22d3ee]/30 animate-spin" />
<div className="absolute inset-4 rounded-full border-2 border-violet-400/50 animate-[spin_1.5s_linear_infinite_reverse]" />
<div className="absolute inset-0 flex items-center justify-center">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-violet-400 animate-pulse">
<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.6z"/>
</svg>
</div>
</div>
<div className="flex flex-col items-center gap-1">
<span className="text-white/80 font-semibold text-sm">
{editMode ? "Remixing motion graphics…" : "Generating motion graphics…"}
</span>
<span className="text-white/30 text-xs">React/Remotion rendering on Modal</span>
</div>
<div className="flex items-center gap-2 text-white/30 text-xs bg-white/[0.03] px-4 py-1.5 rounded-full border border-white/[0.05]">
<svg className="animate-spin" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
<circle cx="12" cy="12" r="10" strokeOpacity="0.2"/>
<path d="M12 2a10 10 0 0 1 10 10"/>
</svg>
{formatTime(elapsedTime)}
</div>
</div>
</div>
)}
{!generating && history.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 w-full pt-4 animate-fade-in-up">
{history.map((entry, idx) => (
<div
key={entry.id || idx}
className="relative group rounded overflow-hidden border border-white/10 bg-[#0a0a0a] shadow-xl hover:border-primary/50 transition-all duration-300 flex flex-col"
>
{/* Video thumbnail */}
<video
src={entry.url}
className="w-full aspect-video object-cover bg-black/40 cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => setFullscreenUrl(entry.url)}
controls={false}
loop
muted
playsInline
onMouseOver={(e) => e.target.play()}
onMouseOut={(e) => { e.target.pause(); e.target.currentTime = 0; }}
/>
{/* ── Mode tag (top-left) ── */}
<div className={`absolute top-2 left-2 px-2 py-0.5 rounded-md text-[9px] font-bold uppercase tracking-wider backdrop-blur-sm border ${
entry.mode === "edit"
? "bg-[#22d3ee]/20 text-[#22d3ee] border-[#22d3ee]/30"
: "bg-violet-600/30 text-violet-300 border-violet-500/30"
}`}>
{entry.mode === "edit" ? "✏ Edit" : "✦ Generated"}
</div>
{/* ── Hover overlay actions ── */}
<div className="absolute top-2 right-2 flex flex-col gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
title="Fullscreen"
onClick={(e) => { e.stopPropagation(); setFullscreenUrl(entry.url); }}
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"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<polyline points="15 3 21 3 21 9" />
<polyline points="9 21 3 21 3 15" />
<line x1="21" y1="3" x2="14" y2="10" />
<line x1="3" y1="21" x2="10" y2="14" />
</svg>
</button>
<button
type="button"
title="Download"
onClick={(e) => { e.stopPropagation(); downloadFile(entry.url, `motion-${entry.id || idx}.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"
>
<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>
{entry.requestId && entry.canEdit !== false ? (
<button
type="button"
title="Remix this generation"
onClick={(e) => {
e.stopPropagation();
setEditMode(true);
setEditSourceId(entry.requestId);
setPrompt("");
setTimeout(() => textareaRef.current?.focus(), 50);
}}
className="p-2 bg-black/60 backdrop-blur-md rounded-full text-white hover:bg-[#22d3ee] hover:text-black transition-all border border-white/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 d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
</button>
) : entry.requestId && entry.canEdit === false ? (
/* Legacy generation — animation code not saved by API, remix not available */
<div
title="Legacy generation — remix not available. Generate a new motion graphic to enable editing."
className="p-2 bg-black/60 backdrop-blur-md rounded-full text-white/20 border border-white/5 cursor-not-allowed"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="opacity-40">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
<line x1="4" y1="4" x2="20" y2="20" stroke="currentColor" strokeWidth="2"/>
</svg>
</div>
) : null}
<button
type="button"
title="Delete"
onClick={(e) => {
e.stopPropagation();
if (confirm("Are you sure you want to delete this generated item?")) {
setHistory(prev => prev.filter((_, i) => i !== idx));
}
}}
className="p-2 bg-black/60 backdrop-blur-md rounded-full text-red-400 hover:bg-red-500 hover:text-white transition-all border border-white/10"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" />
<line x1="10" y1="11" x2="10" y2="17" />
<line x1="14" y1="11" x2="14" y2="17" />
</svg>
</button>
</div>
{/* ── Card footer: prompt + metadata ── */}
<div className="p-3 bg-black/80 backdrop-blur-sm border-t border-white/5 flex-1 flex flex-col justify-between gap-2">
<p className="text-white/70 text-xs line-clamp-3 leading-relaxed" title={entry.prompt}>
{entry.prompt || "No prompt"}
</p>
<div className="flex items-center justify-between mt-1 flex-wrap gap-1">
<span className="text-[10px] font-bold text-primary px-2 py-0.5 bg-primary/10 rounded border border-primary/20 whitespace-nowrap">
motion-graphics{entry.mode === "edit" ? "-edit" : ""}
</span>
<div className="flex gap-2">
{entry.aspectRatio && (
<span className="text-[10px] text-white/40">{entry.aspectRatio}</span>
)}
{entry.duration && (
<span className="text-[10px] text-white/40">{entry.duration}s</span>
)}
</div>
</div>
</div>
</div>
))}
</div>
) : !generating ? (
/* ── Empty State ── */
<div className="flex flex-col items-center justify-center h-full animate-fade-in-up transition-all duration-700 min-h-[50vh]">
{/* Overlapping floating cards */}
<div className="flex items-center justify-center gap-1.5 md:gap-3 mb-10 select-none scale-90 sm:scale-100">
<div className="w-18 h-22 sm:w-24 sm:h-28 rounded-2xl border border-white/10 shadow-2xl -rotate-[12deg] transform hover:rotate-0 hover:scale-110 hover:z-20 transition-all duration-300 overflow-hidden bg-white/[0.01] flex-shrink-0">
<img
src="https://d3adwkbyhxyrtq.cloudfront.net/webassets/videomodels/sdxl-image.avif"
alt="Creative asset 1"
className="w-full h-full object-cover"
/>
</div>
<div className="w-18 h-22 sm:w-24 sm:h-28 rounded-2xl border border-white/10 shadow-2xl -rotate-[4deg] transform hover:rotate-0 hover:scale-110 hover:z-20 transition-all duration-300 overflow-hidden bg-white/[0.01] -ml-3 sm:-ml-4 flex-shrink-0">
<img
src="https://d3adwkbyhxyrtq.cloudfront.net/webassets/videomodels/chroma-image.avif"
alt="Creative asset 2"
className="w-full h-full object-cover"
/>
</div>
<div className="w-18 h-18 sm:w-24 sm:h-24 rounded-full border border-white/10 shadow-2xl rotate-[6deg] transform hover:rotate-0 hover:scale-110 hover:z-20 transition-all duration-300 overflow-hidden bg-white/[0.01] -ml-3 sm:-ml-4 flex-shrink-0">
<img
src="https://d3adwkbyhxyrtq.cloudfront.net/webassets/videomodels/neta-lumina.avif"
alt="Creative asset 3"
className="w-full h-full object-cover"
/>
</div>
<div className="w-18 h-22 sm:w-24 sm:h-28 rounded-2xl border border-white/10 shadow-2xl rotate-[12deg] transform hover:rotate-0 hover:scale-110 hover:z-20 transition-all duration-300 overflow-hidden bg-white/[0.01] -ml-3 sm:-ml-4 flex-shrink-0">
<img
src="https://d3adwkbyhxyrtq.cloudfront.net/webassets/videomodels/perfect-pony-xl.avif"
alt="Creative asset 4"
className="w-full h-full object-cover"
/>
</div>
</div>
<h1 className="text-2xl sm:text-4xl md:text-5xl font-extrabold tracking-tight mb-4 text-center px-4 flex flex-col items-center">
<span className="text-white font-black uppercase text-xl sm:text-3xl tracking-wide mb-1 opacity-90">START CREATING WITH</span>
<span className="text-[#22d3ee] font-black uppercase text-2xl sm:text-4xl sm:mt-1 tracking-tight">
VIBE MOTION STUDIO
</span>
</h1>
<p className="text-white/40 text-xs sm:text-sm font-medium tracking-wide text-center max-w-lg leading-relaxed px-4">
Generate animated motion graphics from a text prompt kinetic typography, data charts, logo reveals, and more.
</p>
</div>
) : null}
</div>
{/* ── BOTTOM PROMPT BAR — matches VideoStudio exactly ── */}
<div className="absolute bottom-4 w-full max-w-[95%] lg:max-w-4xl z-40 animate-fade-in-up" style={{ animationDelay: "0.2s" }}>
<div className="w-full bg-gradient-to-b from-[#18181c]/90 via-[#0f0f12]/90 to-[#0c0c0e]/95 backdrop-blur-2xl rounded-[2rem] border border-white/[0.08] p-4 flex flex-col gap-3 shadow-[0_15px_50px_rgba(0,0,0,0.8)]">
{/* ── Edit mode banner ── */}
{editMode && (
<div className="flex items-center gap-2 px-3 py-1.5 mx-0 bg-[#22d3ee]/5 border border-[#22d3ee]/10 rounded text-[10px] text-[#22d3ee]/80 font-medium tracking-tight">
<svg width="13" height="13" 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 d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
<span>
{sourceEntry
? `Editing: "${sourceEntry.prompt?.slice(0, 50)}${sourceEntry.prompt?.length > 50 ? "…" : ""}"`
: "Select a source generation from the gallery"}
</span>
<button
onClick={() => { setEditMode(false); setEditSourceId(null); setPrompt(""); }}
className="ml-auto text-[#22d3ee]/40 hover:text-[#22d3ee] transition-colors text-base leading-none"
>×</button>
</div>
)}
{/* ── Textarea row ── */}
<div className="flex items-center gap-2 px-1">
{/* Mode toggle pill */}
<div className="flex items-center gap-1 bg-white/[0.03] border border-white/[0.05] rounded-full p-0.5 flex-shrink-0">
<button
type="button"
onClick={() => { setEditMode(false); setEditSourceId(null); }}
className={`px-2.5 py-1 rounded-full text-[10px] font-semibold transition-all ${
!editMode ? "bg-[#22d3ee] text-black shadow" : "text-white/40 hover:text-white/70"
}`}
>
Generate
</button>
<button
type="button"
onClick={() => setEditMode(true)}
disabled={editSources.length === 0}
className={`px-2.5 py-1 rounded-full text-[10px] font-semibold transition-all disabled:opacity-30 disabled:cursor-not-allowed ${
editMode ? "bg-[#22d3ee] text-black shadow" : "text-white/40 hover:text-white/70"
}`}
>
Edit
</button>
</div>
{/* Prompt textarea */}
<div className="flex-1 flex flex-col gap-1">
<textarea
ref={textareaRef}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
editMode
? "Describe what to change — 'change background to dark navy, make bars gold, add particles…'"
: "Describe the motion graphic — 'Animated sales dashboard with glowing bar charts and rising numbers'"
}
rows={1}
className="w-full bg-transparent border-none text-white text-sm placeholder:text-white/10 focus:outline-none resize-none pt-1 leading-relaxed min-h-[40px] max-h-[150px] md:max-h-[250px] overflow-y-auto custom-scrollbar"
/>
</div>
</div>
{/* ── Error banner ── */}
{generateError && (
<div className="flex items-center gap-2 px-3 py-1.5 bg-red-500/10 border border-red-500/20 rounded text-red-400 text-xs">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><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>
{generateError}
</div>
)}
{/* ── Controls row: dropdowns + generate button ── */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-4 pt-3 border-t border-white/[0.03] relative">
<div className="flex items-center gap-2 relative flex-wrap pb-1 md:pb-0">
{/* ── Aspect Ratio dropdown ── */}
<div className="relative">
<button
type="button"
onClick={toggleDropdown("ar")}
className="flex items-center gap-2 px-3 py-2 bg-white/[0.03] hover:bg-white/[0.06] rounded-md transition-all border border-white/[0.03] group whitespace-nowrap"
>
<div className="w-4 h-4 bg-[#22d3ee] rounded flex items-center justify-center shadow-lg shadow-[#22d3ee]/10">
<span className="text-[9px] font-bold text-black uppercase">A</span>
</div>
<span className="text-[11px] font-semibold text-white/70 group-hover:text-[#22d3ee] transition-colors">
{aspectRatio}
</span>
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="4" className="opacity-20 group-hover:opacity-100 transition-opacity ml-1">
<path d="M6 9l6 6 6-6"/>
</svg>
</button>
{openDropdown === "ar" && (
<div className="absolute bottom-[calc(100%+12px)] left-0 z-50 bg-[#0a0a0a] rounded-lg p-3 shadow-2xl border border-white/[0.05] min-w-[140px]">
<div className="text-xs font-bold text-white/20 border-b border-white/[0.03] mb-2">Aspect Ratio</div>
<div className="flex flex-col gap-1">
{ASPECT_RATIOS.map((ar) => (
<div
key={ar}
className="flex items-center justify-between p-3 hover:bg-white/5 rounded cursor-pointer transition-all group/opt"
onClick={() => { setAspectRatio(ar); setOpenDropdown(null); }}
>
<span className="text-[11px] font-semibold text-white/70 group-hover/opt:text-white transition-opacity">{ar}</span>
{aspectRatio === ar && <CheckSvg />}
</div>
))}
</div>
</div>
)}
</div>
{/* ── Duration dropdown ── */}
<div className="relative">
<button
type="button"
onClick={toggleDropdown("dur")}
className="flex items-center gap-2 px-3 py-2 bg-white/[0.03] hover:bg-white/[0.06] rounded-md transition-all border border-white/[0.03] group whitespace-nowrap"
>
<div className="w-4 h-4 bg-[#22d3ee] rounded flex items-center justify-center shadow-lg shadow-[#22d3ee]/10">
<span className="text-[9px] font-bold text-black uppercase">T</span>
</div>
<span className="text-[11px] font-semibold text-white/70 group-hover:text-[#22d3ee] transition-colors">
{duration}s
</span>
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="4" className="opacity-20 group-hover:opacity-100 transition-opacity ml-1">
<path d="M6 9l6 6 6-6"/>
</svg>
</button>
{openDropdown === "dur" && (
<div className="absolute bottom-[calc(100%+12px)] left-0 z-50 bg-[#0a0a0a] rounded-md p-3 shadow-2xl border border-white/10 min-w-[140px] max-h-52 overflow-y-auto custom-scrollbar">
<div className="text-xs font-bold text-white/20 border-b border-white/[0.03] mb-2">Duration</div>
<div className="flex flex-col gap-1">
{DURATION_OPTIONS.map((d) => (
<div
key={d}
className="flex items-center justify-between p-2 hover:bg-white/5 rounded-md cursor-pointer transition-all group/opt"
onClick={() => { setDuration(d); setOpenDropdown(null); }}
>
<span className="text-xs font-semibold text-white/70 group-hover/opt:text-white">{d}s</span>
{duration === d && <CheckSvg />}
</div>
))}
</div>
</div>
)}
</div>
{/* Edit source picker dropdown — only shown in edit mode */}
{editMode && editSources.length > 0 && (
<div className="relative">
<button
type="button"
onClick={toggleDropdown("source")}
className="flex items-center gap-2 px-3 py-2 bg-[#22d3ee]/[0.04] hover:bg-[#22d3ee]/[0.08] rounded-md transition-all border border-[#22d3ee]/[0.08] group whitespace-nowrap"
>
<div className="w-4 h-4 bg-[#22d3ee]/20 rounded flex items-center justify-center border border-[#22d3ee]/30">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="#22d3ee" strokeWidth="3" 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 d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
</div>
<span className="text-xs font-semibold text-[#22d3ee]/70 group-hover:text-[#22d3ee] transition-colors max-w-[120px] truncate">
{sourceEntry ? `Source: ${sourceEntry.prompt?.slice(0, 20)}` : "Pick source…"}
</span>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" className="opacity-30 flex-shrink-0">
<path d="M6 9l6 6 6-6"/>
</svg>
</button>
{openDropdown === "source" && (
<div className="absolute bottom-[calc(100%+12px)] left-0 z-50 w-64 bg-[#0a0a0a] rounded-lg p-3 shadow-2xl border border-white/[0.05] max-h-64 overflow-y-auto custom-scrollbar">
<div className="text-xs font-bold text-white/20 border-b border-white/[0.03] mb-2">Source Generation</div>
<div className="flex flex-col gap-1">
{editSources.map((src) => (
<div
key={src.requestId}
className="flex items-center gap-3 p-2 hover:bg-white/5 rounded cursor-pointer transition-all group/opt"
onClick={() => { setEditSourceId(src.requestId); setOpenDropdown(null); }}
>
<div className="w-10 h-7 rounded overflow-hidden bg-black/40 flex-shrink-0 border border-white/5">
<video src={src.url} className="w-full h-full object-cover" muted playsInline />
</div>
<div className="flex-1 min-w-0">
<p className="text-[11px] text-white/70 truncate leading-tight group-hover/opt:text-white">{src.prompt}</p>
<p className="text-[9px] text-white/30 mt-0.5">{src.aspectRatio} · {src.duration}s</p>
</div>
{editSourceId === src.requestId && <CheckSvg />}
</div>
))}
</div>
</div>
)}
</div>
)}
<span className="text-[10px] text-white/20 hidden sm:block ml-2">Ctrl+Enter to run</span>
</div>
{/* ── Generate Button — matches VideoStudio exactly ── */}
<button
type="button"
onClick={handleGenerate}
disabled={generating || !prompt.trim() || (editMode && !editSourceId)}
className="bg-[#22d3ee] text-black px-7 py-3 rounded-full font-black text-sm hover:opacity-95 hover:scale-[1.02] active:scale-[0.98] transition-all flex items-center justify-center gap-2 w-full sm:w-auto shadow-lg shadow-[#22d3ee]/20 hover:shadow-[#22d3ee]/35 border border-[#22d3ee]/10 z-10"
>
{generating ? (
<>
<span className="animate-spin inline-block text-black"></span>{" "}
{editMode ? "Remixing..." : "Generating..."}
</>
) : generateError ? (
`Error: ${generateError.slice(0, 40)}`
) : editMode ? (
<span>Remix</span>
) : (
<span>Generate</span>
)}
</button>
</div>
</div>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -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-[#22d3ee] 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-[#22d3ee]/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-[#22d3ee] 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-[#22d3ee] uppercase tracking-wider mb-1 opacity-80">
{workflow.category || "General"}
</div>
<h3 className="text-sm font-bold text-white truncate group-hover:text-[#22d3ee] 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-[#22d3ee] 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-[#22d3ee] text-black shadow-[0_0_15px_rgba(34, 211, 238,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-[#22d3ee] text-black shadow-[0_0_15px_rgba(34, 211, 238,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-[#22d3ee] 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-[#22d3ee] 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-[#22d3ee] 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-[#22d3ee]/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-[#22d3ee]/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-[#22d3ee]/50 transition-colors"
placeholder={
prop.description || `Enter ${key}...`
}
/>
)}
</div>
),
)}
</div>
</div>
<button
type="submit"
disabled={isExecuting || !selectedWorkflow.id}
className="w-full py-4 bg-[#22d3ee] 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(34, 211, 238,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-[#22d3ee] rounded-full animate-spin shadow-[0_0_40px_rgba(34, 211, 238,0.1)]" />
<div className="absolute inset-0 flex items-center justify-center text-[#22d3ee]">
<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-[#22d3ee] 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-[#22d3ee]/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-[#22d3ee] 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-[#22d3ee] 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-[#22d3ee] 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-[#22d3ee] 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(34, 211, 238,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-[#22d3ee] border-[#22d3ee]"
: "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-[#22d3ee] border-[#22d3ee]"
: "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-[#22d3ee] border-[#22d3ee]"
: "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-[#22d3ee] 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-[#22d3ee] 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-[#22d3ee]/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-[#22d3ee] 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>
);
}
@@ -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";
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;
+18
View File
@@ -0,0 +1,18 @@
"use client";
export { default as ImageStudio } from './components/ImageStudio';
export { default as VideoStudio } from './components/VideoStudio';
export { default as ClippingStudio } from './components/ClippingStudio';
export { default as VibeMotionStudio } from './components/VibeMotionStudio';
export { default as LipSyncStudio } from './components/LipSyncStudio';
export { default as RecastStudio } from './components/RecastStudio';
export { default as CinemaStudio } from './components/CinemaStudio';
export { default as AudioStudio } from './components/AudioStudio';
export { default as MarketingStudio } from './components/MarketingStudio';
export { default as WorkflowStudio } from './components/WorkflowStudio';
export { default as AgentStudio } from './components/AgentStudio';
export { default as DesignAgentStudio } from './components/DesignAgentStudio';
export { default as AppsStudio } from './components/AppsStudio';
export { default as McpCliStudio } from './components/McpCliStudio';
export { default as AiInfluencerStudio } from './components/AiInfluencerStudio';
export * from './muapi';
File diff suppressed because it is too large Load Diff
+732
View File
@@ -0,0 +1,732 @@
import { getModelById, getVideoModelById, getI2IModelById, getI2VModelById, getV2VModelById, getRecastModelById, getLipSyncModelById, getAudioModelById } from './models.js';
// In an http(s) browser we route through the host app's proxy (Next.js routes
// under /api/* re-issue the call server-side) so api.muapi.ai CORS is bypassed.
// SSR (no window) and Electron's file:// renderer call the upstream directly.
const BASE_URL = (typeof window !== 'undefined' && window.location?.protocol?.startsWith('http'))
? '/api'
: 'https://api.muapi.ai';
const PROXY_WF_BASE = '/api/workflow';
function notifyAuthRequired(status, detail) {
if (typeof window === 'undefined') return;
if (status !== 401 && status !== 403) return;
window.dispatchEvent(new CustomEvent('muapi:auth-required', { detail: { status, message: detail } }));
}
async function pollForResult(requestId, key, maxAttempts = 900, interval = 2000) {
const pollUrl = `${BASE_URL}/api/v1/predictions/${requestId}/result`;
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': key }
});
if (!response.ok) {
const errText = await response.text();
if (response.status >= 500) continue;
notifyAuthRequired(response.status, errText);
throw new Error(`Poll Failed: ${response.status} - ${errText.slice(0, 100)}`);
}
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(`Generation failed: ${data.error || 'Unknown error'}`);
} catch (error) {
if (attempt === maxAttempts) throw error;
}
}
throw new Error('Generation timed out after polling.');
}
async function submitAndPoll(endpoint, payload, key, onRequestId, maxAttempts = 60) {
const url = `${BASE_URL}/api/v1/${endpoint}`;
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': key },
body: JSON.stringify(payload)
});
if (!response.ok) {
const errText = await response.text();
notifyAuthRequired(response.status, errText);
throw new Error(`API Request Failed: ${response.status} ${response.statusText} - ${errText.slice(0, 100)}`);
}
const submitData = await response.json();
const requestId = submitData.request_id || submitData.id;
if (!requestId) return submitData;
if (onRequestId) onRequestId(requestId);
const result = await pollForResult(requestId, key, maxAttempts);
const outputUrl = result.outputs?.[0] || result.url || result.output?.url;
return { ...result, url: outputUrl };
}
export async function generateImage(apiKey, params) {
const modelInfo = getModelById(params.model);
const endpoint = modelInfo?.endpoint || params.model;
const payload = { prompt: params.prompt };
if (params.aspect_ratio) payload.aspect_ratio = params.aspect_ratio;
if (params.resolution) payload.resolution = params.resolution;
if (params.quality) payload.quality = params.quality;
if (params.image_url) {
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;
return submitAndPoll(endpoint, payload, apiKey, params.onRequestId, 60);
}
export async function generateI2I(apiKey, params) {
const modelInfo = getI2IModelById(params.model);
const endpoint = modelInfo?.endpoint || params.model;
const payload = {};
if (params.prompt) payload.prompt = params.prompt;
const imageField = modelInfo?.imageField || 'image_url';
const imagesList = params.images_list?.length > 0 ? params.images_list : (params.image_url ? [params.image_url] : null);
if (imagesList) {
if (imageField === 'images_list') payload.images_list = imagesList;
else payload[imageField] = imagesList[0];
}
if (modelInfo?.swapField && params.swap_url) {
payload[modelInfo.swapField] = params.swap_url;
}
if (params.aspect_ratio) payload.aspect_ratio = params.aspect_ratio;
if (params.resolution) payload.resolution = params.resolution;
if (params.quality) payload.quality = params.quality;
if (modelInfo?.inputs?.name) {
payload.name = params.name || modelInfo.inputs.name.default;
}
return submitAndPoll(endpoint, payload, apiKey, params.onRequestId, 60);
}
export async function generateVideo(apiKey, params) {
const modelInfo = getVideoModelById(params.model);
const endpoint = modelInfo?.endpoint || params.model;
const payload = {};
if (params.prompt) payload.prompt = params.prompt;
if (params.request_id) payload.request_id = params.request_id;
if (params.aspect_ratio) payload.aspect_ratio = params.aspect_ratio;
if (params.duration) payload.duration = params.duration;
if (params.resolution) payload.resolution = params.resolution;
if (params.quality) payload.quality = params.quality;
if (params.mode) payload.mode = params.mode;
if (params.image_url) payload.image_url = params.image_url;
if (params.images_list?.length > 0) payload.images_list = params.images_list;
if (params.videos_list?.length > 0) payload.videos_list = params.videos_list;
return submitAndPoll(endpoint, payload, apiKey, params.onRequestId, 900);
}
export async function generateI2V(apiKey, params) {
const modelInfo = getI2VModelById(params.model);
const endpoint = modelInfo?.endpoint || params.model;
const payload = {};
if (params.prompt) payload.prompt = params.prompt;
const imageField = modelInfo?.imageField || 'image_url';
if (params.images_list && params.images_list.length > 0) {
if (imageField === 'images_list') payload.images_list = params.images_list;
else payload[imageField] = params.images_list[0];
} else if (params.image_url) {
if (imageField === 'images_list') payload.images_list = [params.image_url];
else payload[imageField] = params.image_url;
}
const lastImageField = modelInfo?.lastImageField;
if (lastImageField && params.last_image) {
if (lastImageField === 'images_list') {
if (!payload.images_list) payload.images_list = [];
if (payload.images_list.indexOf(params.last_image) === -1) {
payload.images_list.push(params.last_image);
}
} else {
payload[lastImageField] = params.last_image;
}
}
if (params.aspect_ratio) payload.aspect_ratio = params.aspect_ratio;
if (params.duration) payload.duration = params.duration;
if (params.resolution) payload.resolution = params.resolution;
if (params.quality) payload.quality = params.quality;
if (params.mode) payload.mode = params.mode;
if (modelInfo?.inputs?.name) {
payload.name = params.name || modelInfo.inputs.name.default;
}
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 processRecast(apiKey, params) {
const modelInfo = getRecastModelById(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;
}
if (params.aspect_ratio) {
payload.aspect_ratio = params.aspect_ratio;
}
return submitAndPoll(endpoint, payload, apiKey, params.onRequestId, 900);
}
export async function processLipSync(apiKey, params) {
const modelInfo = getLipSyncModelById(params.model);
const endpoint = modelInfo?.endpoint || params.model;
const payload = {};
if (params.audio_url) payload.audio_url = params.audio_url;
if (params.image_url) payload.image_url = params.image_url;
if (params.video_url) payload.video_url = params.video_url;
if (modelInfo?.hasPrompt) payload.prompt = params.prompt || '';
if (params.resolution) payload.resolution = params.resolution;
if (params.seed !== undefined && params.seed !== -1) payload.seed = params.seed;
return submitAndPoll(endpoint, payload, apiKey, params.onRequestId, 900);
}
export async function generateAudio(apiKey, params) {
const modelId = params._modelId || params.model;
const modelInfo = getAudioModelById(modelId);
const endpoint = modelInfo?.endpoint || modelId;
const payload = {};
const skipKeys = ['_modelId', 'onRequestId'];
for (const key in params) {
if (!skipKeys.includes(key) && params[key] !== undefined && params[key] !== null) {
payload[key] = params[key];
}
}
return submitAndPoll(endpoint, payload, apiKey, params.onRequestId, 900);
}
export function uploadFile(apiKey, file, onProgress) {
return new Promise((resolve, reject) => {
const url = `${BASE_URL}/api/v1/upload_file`;
const formData = new FormData();
formData.append('file', file);
const xhr = new XMLHttpRequest();
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
}
notifyAuthRequired(xhr.status, detail);
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) {
const errText = await response.text();
notifyAuthRequired(response.status, errText);
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();
return Array.isArray(data) ? data : (data.agents || data.items || []);
};
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();
}
export async function runClipping(apiKey, params) {
const payload = {
video_url: params.video_url,
num_highlights: params.num_highlights || 3,
aspect_ratio: params.aspect_ratio || "9:16",
return_coordinates_only: !!params.return_coordinates_only
};
return submitAndPoll("ai-clipping", payload, apiKey, params.onRequestId, 900);
}
export async function runMotionGraphics(apiKey, params) {
const payload = {
prompt: params.prompt,
aspect_ratio: params.aspect_ratio || "16:9",
duration_seconds: params.duration_seconds || 6,
};
return submitAndPoll("motion-graphics", payload, apiKey, params.onRequestId, 900);
}
export async function runMotionGraphicsEdit(apiKey, params) {
const payload = {
request_id: params.request_id,
edit_prompt: params.edit_prompt,
aspect_ratio: params.aspect_ratio || "16:9",
duration_seconds: params.duration_seconds || 6,
};
return submitAndPoll("motion-graphics-edit", payload, apiKey, params.onRequestId, 900);
}
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+17
View File
@@ -0,0 +1,17 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./src/**/*.{js,jsx}"],
theme: {
extend: {
colors: {
'app-bg': '#050505',
'panel-bg': '#0a0a0a',
'card-bg': '#111111',
primary: '#22d3ee',
secondary: '#a1a1aa',
muted: '#52525b',
},
},
},
plugins: [],
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+99
View File
@@ -0,0 +1,99 @@
# Open Generative AI: Technical Documentation & Context
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
**Open Generative AI** is an ambitious open-source project for AI image and video generation.
- **Core Goal:** To build a feature-complete, self-hosted generative AI studio, 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.
- **Future Direction:** The architecture is designed to scale for video generation, model training interfaces, and advanced editing tools.
- **Stack:** Vite, Vanilla JavaScript, Tailwind CSS v4.
- **Repository:** `https://github.com/Anil-matcha/Open-Generative-AI`
- **Primary Branch:** `main`
## 2. Architecture & File Structure
The project follows a component-based architecture using vanilla JS, where each component is a function that returns a DOM element.
```tree
src/
├── components/
│ ├── ImageStudio.js # Core logic: Prompts, model picking, canvas, history.
│ ├── Header.js # Navigation, user settings, auth status.
│ ├── AuthModal.js # Modal for capturing and validating the API key.
│ ├── SettingsModal.js # Panel for managing settings (clearing API key).
│ └── Sidebar.js # (Currently unused/placeholder) Navigation sidebar.
├── lib/
│ ├── muapi.js # The API Client. Handles auth, submission, and polling.
│ └── models.js # Source of truth for model definitions and endpoints.
├── styles/
│ ├── global.css # Global resets, fonts, and animation keyframes.
│ ├── studio.css # Specific styles for the studio interface.
│ └── variables.css # CSS custom properties (colors, blur amounts).
├── main.js # Entry point. Renders the app layout and Header/Studio.
└── style.css # Tailwind CSS entry file (imports other CSS).
```
## 3. Key Components & Logic
### `ImageStudio.js` (The Brain)
This is the most complex component. It handles:
- **State:** Selected model (`selectedModel`), aspect ratio (`selectedAr`), and generation status.
- **Prompt Input:** A textarea with auto-grow logic and max-height constraints (fixed in `bf2efdb`).
- **Dynamic Controls:**
- **Model Picker:** Lists models from `models.js`.
- **Quality/Resolution:** Only appears for models with explicit resolution support (like `nano-banana-pro`). Hidden for others (like `flux-schnell`).
- **Generation Flow:**
1. Checks for API key in `localStorage`. If missing, opens `AuthModal`.
2. Calls `muapi.generateImage()`.
3. Polling loop waits for result.
4. On success, adds result to `generationHistory` and displays it.
- **History:**
- Stored in `localStorage` key `muapi_history`.
- Slides in from the right sidebar.
- Thumbnails are clickable to re-view; hover to download.
### `muapi.js` (The Engine)
Encapsulates all communication with `api.muapi.ai`.
- **Authentication:** Uses `x-api-key` header (NOT `Authorization: Bearer`).
- **Pattern:** Submit -> Poll.
- `POST` to endpoint (e.g., `/api/v1/nano-banana-pro`).
- API returns a `request_id`.
- `POST` / `GET` loop on `/api/v1/predictions/{id}/result` until status is `completed`, `succeeded`, or `failed`.
- **Normalization:** The polling response structure varies. `muapi.js` normalizes the result to ensure `url` is always populated (extracting from `outputs[0]` if necessary).
### `models.js` (The Data)
Contains the `t2iModels` array.
- Each model has an `id`, `name`, `inputs` schema (resolution, aspect ratio support), and a crucial `endpoint` property.
- **Crucial:** The `endpoint` property maps the internal ID to the API path (e.g., `flux-schnell` -> `flux-schnell-image`).
## 4. UI & Styling (Tailwind v4)
- **Theme:** Dark mode by default (`bg-app-bg` = `#050505`).
- **Accent:** Electric Cyan (`#22d3ee`) used for primary actions and glows.
- **Glassmorphism:** Extensive use of `backdrop-blur` and `bg-white/5` or `bg-black/60` for panels, headers, and modals.
- **Responsiveness:**
- **Mobile:** Stacked layout, simplified controls, hidden sidebar.
- **Desktop:** Wide canvas, floating prompt bar, side-by-side history.
- **Animations:** Custom keyframes in `global.css` for `fade-in-up`, `pulse-glow`, etc.
## 5. Development Setup
- **Vite Proxy:** Local development uses a proxy in `vite.config.js` to route `/api` requests to `https://api.muapi.ai` to avoid CORS issues.
- **Environment:** `muapi.js` detects `import.meta.env.DEV` to decide whether to use the relative `/api` path (proxy) or the full URL (production).
## 6. Known Gotchas & Fixes
- **Prompt Bar Overflow:** Fixed by limiting textarea max-height and enabling scrolling.
- **Flux Resolution Picker:** Fixed logic to only show the resolution picker if the model *explicitly* lists enum values for resolution/megapixels.
- **Hero Visibility:** The "Nano Banana Pro" hero text is completely hidden (`display: none`) when an image is shown to prevent bleed-through.
- **API Key Logging:** Debug logs printing the API key were removed for security.
## 7. Future Roadmap (Potential)
- **Video Generation:** Expand `models.js` and `ImageStudio.js` to support video models (already present in `schema_data` but not wired up).
- **In-painting/Out-painting:** Add canvas editing tools.
- **User Accounts:** Move beyond local storage for history.
Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 383 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 409 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Some files were not shown because too many files have changed in this diff Show More