chore: import upstream snapshot with attribution
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -0,0 +1,573 @@
|
||||
# Open Generative AI — Unrestricted Open-Source Alternative to AI Video Platforms
|
||||
|
||||
[](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 ~1–2 s/step with the Metal dylib active. If you see ~10 s/step instead, the binary may have fallen back to CPU — see verification below.
|
||||
|
||||
### Verifying the SD 1.5 path (the fastest sanity test on Mac)
|
||||
|
||||
If you want to confirm sd.cpp is installed correctly without going through the UI, you can drive `sd-cli` directly. This is the same binary the app uses.
|
||||
|
||||
```bash
|
||||
# 1. App data layout (created on first app launch)
|
||||
APP_DATA="${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 | 2–3 |
|
||||
| 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.*
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`Anil-matcha/Open-Generative-AI`
|
||||
- 原始仓库:https://github.com/Anil-matcha/Open-Generative-AI
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Home() {
|
||||
redirect('/studio');
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Home() {
|
||||
redirect('/studio');
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import StandaloneShell from '@/components/StandaloneShell';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Studio — Open Generative AI',
|
||||
};
|
||||
|
||||
export default function StudioPage() {
|
||||
return <StandaloneShell />;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import StandaloneShell from '@/components/StandaloneShell';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Workflow — Open Generative AI',
|
||||
};
|
||||
|
||||
export default function WorkflowTabPage() {
|
||||
return <StandaloneShell />;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import StandaloneShell from '@/components/StandaloneShell';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Workflow — Open Generative AI',
|
||||
};
|
||||
|
||||
export default function WorkflowPage() {
|
||||
return <StandaloneShell />;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 & Videos → Auto-Publish as YouTube Shorts & 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
services:
|
||||
open-generative-ai:
|
||||
build: .
|
||||
container_name: open-generative-ai
|
||||
ports:
|
||||
- "3001:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
restart: unless-stopped
|
||||
|
After Width: | Height: | Size: 3.9 MiB |
|
After Width: | Height: | Size: 3.4 MiB |
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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).*)',
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
transpilePackages: ['studio', 'ai-agent', 'workflow-builder', 'design-agent'],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"presets": [
|
||||
"@babel/preset-env",
|
||||
["@babel/preset-react", { "runtime": "automatic" }]
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 & AI agents
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tight">MCP & CLI</h1>
|
||||
<p className="text-white/60 text-base md:text-lg max-w-2xl">
|
||||
Use Open 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -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: [],
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -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.
|
||||
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 383 KiB |
|
After Width: | Height: | Size: 409 KiB |
|
After Width: | Height: | Size: 418 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 100 KiB |