Compare commits

...

119 commits

Author SHA1 Message Date
Anil Chandra Naidu Matcha
927e8600d8
Update alternative projects in README
Replaced Open-Poe AI alternative with Open-Lovart AI alternative in README.
2026-05-07 01:01:26 +05:30
Anil Matcha
aa4a4202b5 fix(video-studio): support kling motion-control + correct higgsfield endpoints
Kling motion-control models (kling-v2.6-std, kling-v3.0-std/pro) require
image_url + video_url (and prompt for v2.6), but the v2v flow assumed a
single video upload (watermark-remover style) and called processV2V with
only video_url, getting 422 back from muapi.

- VideoStudio (vanilla + React studio): when a motion-control v2v model
  is selected, keep the image upload picker active alongside the video
  upload, keep the prompt textarea editable, and validate all required
  inputs before generating
- processV2V (both clients): forward image_url + prompt when the model
  declares imageField / hasPrompt; packages/studio adds a dedicated
  processV2V export so the React studio uses it instead of generateVideo
- v2.6 motion-control marked promptRequired so the UI surfaces it
- Higgsfield Soul / Dop endpoints corrected from
  higgsfield-{soul,dop}-* to hf-{soul,dop}-* (matches muapi routes)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 20:37:55 +05:30
Anil Matcha
46abef7bb8 refactor(models): make standalone src/lib/models.js a re-export of studio
The model catalog was duplicated between src/lib/models.js (Electron app)
and packages/studio/src/models.js (Next.js web app), both ~8400 lines.
Catalog edits (e.g. adding lastImageField for first/last frame i2v
support) had to be made in both files, and missing one caused the web
UI to silently lack features. Studio's copy is the canonical source —
the standalone now re-exports from studio/src/models.js via the existing
"studio": "*" workspace dep, collapsing 5 components' imports to a
single source of truth.

muapi.js stays duplicated for now — the two clients have incompatible
APIs (class with localStorage key vs function-with-apiKey-arg) and need
a separate harmonization pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 00:24:25 +05:30
Anil Matcha
5e01f60758 feat(studio): port first/last frame i2v support + t2v→i2v family map to web JSX
The earlier first/last-frame work in commit 8cbaf7f only updated the
standalone Electron VideoStudio.js. The Next.js web app actually
consumes packages/studio/src/components/VideoStudio.jsx (a separate React
component with its own muapi.js + models.js), so the web UI never showed
the END picker on Kling/Veo/Seedance/Wan/Minimax i2v models.

- packages/studio/src/models.js: add lastImageField (last_image or
  end_image_url) to the same 16 i2v entries.
- packages/studio/src/muapi.js: forward params.last_image into the
  per-model server field in generateI2V.
- packages/studio/src/components/VideoStudio.jsx:
  * uploadedEndImageUrl state + endImageFileInputRef + upload handler
  * END-badged picker rendered when imageMode && model.lastImageField
  * thread last_image into i2vParams; clear it in clearImageUpload
  * map t2v selection to i2v sibling by family on image upload (drop
    + file change paths) instead of jumping to i2vModels[0]

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 00:13:03 +05:30
Anil Matcha
b2d331e7d8 fix(video-studio): map t2v selection to i2v sibling by family on image upload
Uploading an image while in t2v mode reset the model to allI2V[0]; with
the i2v list being a concatenation of regular models + effects, that index
shifts as entries are reordered, so users saw seemingly random switches
(e.g. Veo 3.1 → AI Video Effects or seedance lite). Look up the matching
i2v variant by family (veo3.1, kling-v3.0, etc.) and only fall back to
allI2V[0] when no sibling exists.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 22:07:21 +05:30
Anil Matcha
8cbaf7fc3f feat(video-studio): first/last frame support for i2v models
Adds an optional end-frame upload picker for i2v models whose server
schema accepts a second image (kling v2.1/v3.0/o1, veo3.1, seedance
lite/v1.5-pro, wan2.2, minimax-hailuo-02). Catalog entries declare the
server-side param via `lastImageField` ("last_image" or "end_image_url")
so the picker is gated per-model and unsupported models stay unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:09:26 +05:30
Anil Matcha
4fc6606d14 feat(studio): add MCP & CLI tab
Adds a new "MCP & CLI" tab introducing muapi-cli, muapi-mcp-server,
and the Generative Media Skills repos with quick-start, feature cards,
and example commands. Wired into both the Next.js StudioShell (via
packages/studio) and the standalone Vite shell.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 23:39:37 +05:30
Anil Matcha
46f78f633b fix(local-ai): repoint Anything v5 to working HF mirror (fixes #141)
stablediffusionapi/anything-v5 reorganized into diffusers folder format,
so the single-file checkpoint URL now 404s. Switch to Yntec/AnythingV5
which still hosts Anything-v5.0-PRT.safetensors as a single file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 23:39:37 +05:30
Anil Chandra Naidu Matcha
1fffdae326
Fix Discord link in README
Updated Discord link for community support.
2026-05-04 23:20:56 +05:30
Anil Chandra Naidu Matcha
f97bb8708d
Refactor README for clarity and conciseness
Removed duplicate community Discord link and updated project descriptions.
2026-05-01 15:58:05 +05:30
Anil Matcha
94b71fddf7 feat(studio): add gpt-image-2 (t2i + i2i edit) to Image Studio
Wires up the new KIE.ai gpt-image-2 model so users can pick it in
Image Studio. Adds the t2i entry (endpoint gpt-image-2-text-to-image)
and the i2i edit variant (endpoint gpt-image-2-image-to-image, up to
16 input images via images_list). Uses resolution (1K/2K/4K, default
2K) instead of quality, and the wider aspect set (auto/1:1/16:9/9:16/
4:3/3:4) — existing helper functions auto-detect both shapes so the
UI dropdowns switch over without further changes.

Closes #136

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 10:46:53 +05:30
Anil Matcha
a12fd4e858 chore(release): v1.0.10
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 10:20:35 +05:30
Anil Matcha
b3b5744758 fix(lipsync): always send prompt for hasPrompt models so backend doesn't forward null
Infinite Talk i2v (and other hasPrompt lipsync models) rejected requests with
"field 'prompt' failed nullable validation" when the prompt textarea was blank,
because omitting the key caused the muapi backend to forward prompt: null.
Default to an empty string for hasPrompt models; keep omitting it for the rest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 09:53:26 +05:30
Anil Matcha
5b860e512b fix(wan2gp): auto-discover Gradio api_names so calls don't hit FnIndexInferError
Wan2GP renames its Gradio api_names between releases (and Pinokio packages
drop them on some endpoints), so our hardcoded fn strings (ltx_video, flux,
qwen_image, wan22_t2v, etc.) failed against any current server with
"Could not infer function index for API name". Now we pull /info at probe
time, cache the registered api_names per base URL, and remap each catalog
entry via exact fn match → fnAliases → fuzzy family+type match. Unmatched
models report a clear unavailableReason in listModels(), and generate()
throws an error listing the real available endpoints instead of a cryptic
Gradio traceback.

Fixes #135.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 13:08:27 +05:30
Anil Matcha
894b520516 docs(readme): clarify Quick Start — require npm run setup, surface electron:dev, add pages-dir troubleshooting
Issue #131 reported `next dev` failing with "Couldn't find a `pages` directory"
on a fresh clone. Root cause is the README pointing users to `npm install &&
npm run dev` while the project actually needs `npm run setup` (which inits
git submodules + builds workspace packages) and most desktop users should be
running `npm run electron:dev` rather than the Next.js hosted-web entry.

- Lead Quick Start with a callout that prebuilt installers exist for non-contributors.
- Replace `npm install` with `npm run setup` and explain why.
- List both dev entry points (electron:dev for desktop, dev for hosted web).
- Add a troubleshooting note for the exact pages-dir error from #131.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 13:24:01 +05:30
Anil Matcha
6bf34147c1 fix(local): skip muapi upload + auth modal for reference images when a local model is selected
Closes #128. Image Studio's reference-image picker was hard-wired to
muapi.uploadFile, so picking a file with a local model active prompted
for an muapi.ai API key instead of using the file locally. Mirror the
Video Studio fix from d4e645d: pass uploadFn + requireApiKey to
createUploadPicker so local-model uploads return a blob: URL via
URL.createObjectURL and bypass the auth modal entirely.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 13:20:05 +05:30
Anil Chandra Naidu Matcha
be60dfdd13
Merge pull request #129 from jaiprasad04/main
feat: Integrate Explore Apps studio with backend parity and premium d…
2026-04-28 18:04:23 +05:30
Anil Chandra Naidu Matcha
51f37b9e2e
Merge pull request #127 from DoanDuyThanh71/feat/docker-support
feat: add Docker support with port conflict avoidance
2026-04-28 18:04:12 +05:30
Jaya Prasad Kavuru
fe87f4ad6a feat: Integrate Explore Apps studio with backend parity and premium dark theme 2026-04-28 13:33:22 +05:30
Anil Matcha
9a742f6645 release: v1.0.9 — local Wan2GP video generation and uploads
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 19:57:50 +05:30
Anil Matcha
d4e645defc fix(local): route Video Studio uploads + generate through Wan2GP when a local model is selected
Closes the gap reported in #126 where local users hit "Not authorized:
missing or invalid credentials" on upload and couldn't generate video
locally even with WanGP installed. Image, video, and reference uploads
were all hard-wired to the Muapi-hosted upload endpoint, and Video
Studio had no branch into the local Wan2GP provider for generate.

- electron/wan2gpProvider: new wan2gp:upload-file IPC that POSTs to the
  Gradio /upload endpoint, caches the returned path, and rehydrates it
  into a Gradio FileData descriptor on generate. Adds wan2gp:wan22-i2v.
- preload + localInferenceClient: expose uploadFileToWan2gp(file).
- localModels: wan22-i2v entry, isWan2gpModelId, localT2VModels/localI2VModels.
- UploadPicker: accept optional uploadFn + requireApiKey so callers can
  bypass the Muapi auth modal when the active provider is local.
- VideoStudio: merge Wan2GP video models into t2v/i2v lists, route the
  reference-image upload through the local provider when a Wan2GP model
  is selected, skip the Muapi key gate for local generations, call
  localAI.generate, and surface step progress in the button label.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 19:55:35 +05:30
Đoàn Duy Thành
b871842096 feat: add Docker support with port conflict avoidance
Add Dockerfile (multi-stage build) and docker-compose.yml to containerize
the Next.js app. Uses port 3001 on host to avoid conflict with the local
dev server running on 3000.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 19:45:15 +07:00
Anil Matcha
35b7103c26 release: v1.0.8 — Windows build restored after Tailwind regression
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 10:38:57 +05:30
Anil Chandra Naidu Matcha
a72857cb04
Merge pull request #122 from jaiprasad04/main
tailwind version fixed
2026-04-27 10:22:23 +05:30
Jaya Prasad Kavuru
061c1c8213 tailwind version fixed 2026-04-27 10:11:53 +05:30
Anil Matcha
84ca594b92 README: add AI-Youtube-Shorts-Generator to Related projects as Opus Clip alternative
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 07:18:10 +05:30
Anil Matcha
6e70b876f6 docs(readme): bump download links to v1.0.7
The download table was still pointing at v1.0.2 — the version that hides
the Settings button behind an unlabeled key icon (root cause of #113).
Mac links updated to v1.0.7 (Wan2GP + Dreamshaper URL fix). Windows and
Linux fall back to v1.0.6 since this release ships Mac-only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 02:14:36 +05:30
Anil Matcha
ba60960949 release: v1.0.7 — Wan2GP local engine + Dreamshaper URL fix
## Added

- **Wan2GP HTTP provider** alongside the existing sd.cpp engine. Settings →
  Local Models now has a Wan2GP server config bar — point the desktop app
  at any user-run Wan2GP Gradio server (LAN box, RunPod/vast.ai, etc.) and
  the new model entries (Flux, Qwen-Image, Wan 2.2 T2V/I2V, Hunyuan, LTX)
  light up. sd.cpp keeps working as before; the renderer routes per model.

## Fixed

- Dreamshaper 8 download URL — the catalog pointed at a 404'd HF repo slug
  (`Lykon/dreamshaper-8`); switched to the live one (`Lykon/DreamShaper`).

## Docs

- README rewritten for the two-engine model with a copy-paste SD 1.5
  sanity-test recipe that bypasses the UI and validates the local engine
  end-to-end. Hardware notes flag the Z-Image hang on small-RAM Macs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 02:13:58 +05:30
Anil Matcha
c5a3b55c95 docs(readme): document both local engines + add SD 1.5 sanity-test recipe
- Local Inference section split into two engines (sd.cpp bundled + Wan2GP
  BYO server), with a comparison table explaining when each fits.
- Wan2GP setup spelled out: clone, install, run with --listen, paste URL
  into Settings → Local Models. Notes that Wan2GP is CUDA/ROCm-only on the
  server side and explains why a Mac user might still want it.
- Hardware Notes flags that Z-Image is known to hang on base 8 GB Macs;
  recommends SD 1.5 there. Adds expected sd.cpp per-step timing on M2 with
  Metal so users can spot a CPU-fallback regression.
- New "Verifying the SD 1.5 path" subsection: a 3-step curl + sd-cli
  invocation that bypasses the UI and confirms the engine works end-to-end.
  Mirrors what the Electron path issues, so it doubles as a release sanity
  check.
- Features list line for Local Inference rewritten to mention both engines.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 02:13:58 +05:30
Anil Matcha
6214f865a3 fix(local-ai): correct Dreamshaper 8 download URL
The catalog pointed at huggingface.co/Lykon/dreamshaper-8 which now returns
404 — the live HF repo is huggingface.co/Lykon/DreamShaper. Confirmed by
probing both URLs and downloading the file from the corrected one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 02:13:58 +05:30
Anil Matcha
032ab0c693 feat(local-ai): add Wan2GP HTTP provider as a second local engine
The desktop app already has a bundled sd.cpp engine for local generation. This
adds a second, complementary engine: an HTTP client that talks to a user-run
Wan2GP Gradio server (https://github.com/deepbeepmeep/Wan2GP). Useful when
sd.cpp can't run a target model — Wan2GP brings video models (Wan 2.2,
Hunyuan, LTX) and large image models (Flux, Qwen-Image) without bundling
Python or weights into the app.

Architecture
- Each model in the unified catalog now carries a `provider` field
  (`'sdcpp' | 'wan2gp'`). The renderer's `localAI.generate()` routes to the
  right backend based on that flag.
- sd.cpp keeps its existing IPC channels (`local-ai:*`) untouched. Wan2GP
  gets its own channel namespace (`wan2gp:*`) and lives in its own
  `electron/lib/wan2gpProvider.js`.
- Wan2GP server URL is persisted in `userData/local-ai/wan2gp.json`.
  `Settings → Local Models` exposes a config bar to test/save the URL.
- Generation streams Gradio v4 SSE protocol; both engines emit progress on
  the shared `local-ai:progress` channel.

ImageStudio
- Local-model dropdown now filters out video models (`type === 'video'`)
  since the studio is image-only. SD 1.5 / SDXL / Z-Image (sd.cpp) and Flux /
  Qwen-Image (Wan2GP) still surface; Wan/Hunyuan/LTX are hidden until the
  Video Studio is wired up to the same surface.
- Progress event handler updated to tolerate both engines' shapes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 02:13:58 +05:30
Anil Chandra Naidu Matcha
9444d27969
Remove GPT-Image-2 prompts reference from README
Removed GPT-Image-2 prompts section from README.
2026-04-25 23:31:45 +05:30
Anil Matcha
b5ba70409e fix(electron): add homepage so .deb target builds
electron-builder's fpm/.deb target requires `homepage` in package.json;
without it `npm run electron:build:linux` fails with "Please specify
project homepage" after producing the AppImage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 11:05:19 +05:30
Anil Matcha
291201f455 release: v1.0.6 — fix sd.cpp binary download on Linux and Windows
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 10:51:52 +05:30
Anil Matcha
0493e6244b fix(local-ai): walk back releases when latest is partial; broaden Linux match
Fixes #108 — leejet's latest sd.cpp release (master-587-b8bdffc) only
ships Mac arm64, the Windows cudart runtime stub, and Linux ROCm. The
old matcher only ever read `releases/latest` and excluded ROCm/Vulkan,
so Ubuntu/Linux users hit "No binary found for this platform" with no
recovery path. Windows users were also broken: the only Win asset in
the latest release isn't an sd-cli build.

- Walk the last 15 leejet releases until one ships a usable build for
  the current platform, so a partial latest release self-heals to the
  prior tag (master-586 onwards has the full 12-zip matrix).
- Linux: prefer plain x86_64, then vulkan, then rocm (instead of
  rejecting both rocm and vulkan).
- Windows: priority order avx2 > avx > avx512 > noavx > cuda12, and
  skip the standalone `cudart-sd-bin-win-cu12-x64.zip` runtime stub.
- macOS Intel: surface a clear error — leejet only ships arm64.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 10:51:44 +05:30
Anil Matcha
8dd0a6ebc1 release: v1.0.5 — prominent Settings button and submodule-safe setup
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 10:45:00 +05:30
Anil Matcha
f0ac343ee4 fix(ui): label the Settings button in the header
Fixes #111 — the Settings entry point was a bare icon (a key glyph in
the Electron header, a gradient circle in the web shell), which users
mistook for an avatar or didn't notice at all. Replace both with a
gear icon + "Settings" text in a bordered pill button.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 10:27:49 +05:30
Anil Matcha
abfcd2ce49 docs: clone with submodules and run submodule init in setup
Fixes #110 — workflow-builder and ai-agent live in git submodules
(packages/Vibe-Workflow, packages/Open-Poe-AI). A plain `git clone`
leaves those directories empty, so `next build` fails to resolve
the `ai-agent` and `workflow-builder` workspace imports.

- README: instruct `git clone --recurse-submodules` and document
  the `git submodule update --init --recursive` fallback.
- package.json: prepend submodule init to the `setup` script so
  `npm run setup` works on a fresh clone too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 10:24:42 +05:30
Anil Chandra Naidu Matcha
8c50910863
Merge pull request #107 from jaiprasad04/fix/build-and-setup
fix(build): resolve workflow-builder module resolution and improve se…
2026-04-24 15:05:40 +05:30
Jaya Prasad Kavuru
7d2a025b4d fix(build): resolve workflow-builder module resolution and improve setup scripts 2026-04-24 14:28:21 +05:30
Anil Chandra Naidu Matcha
e5531d1f1e
Merge pull request #103 from jaiprasad04/feat/public-submodules-sync
fix(installer): configure custom NSIS installer path and force close
2026-04-24 10:07:38 +05:30
Jaya Prasad Kavuru
0ed86f2d77 fix(installer): configure custom NSIS installer path and force close 2026-04-24 10:04:18 +05:30
Anil Chandra Naidu Matcha
11679c387d
Update README to correct AI names 2026-04-24 09:46:08 +05:30
Anil Matcha
16369973b0 Add early-access callout for Awesome HappyHorse 1.0 API & Prompts
Feature Alibaba's Happy Horse 1.0 (#1 ranked AI video model) above the
existing GPT-Image-2 callout, linking to the Python wrapper + curated
community prompt library.
2026-04-24 09:33:42 +05:30
Anil Chandra Naidu Matcha
2b70cbad73
Fix formatting and enhance README content
Corrected formatting and improved clarity in the README.
2026-04-24 09:10:22 +05:30
Anil Chandra Naidu Matcha
e56d0041e9
Add related projects to README
Added related projects section with links to alternatives.
2026-04-24 09:09:55 +05:30
Anil Chandra Naidu Matcha
f2b7661059
Merge pull request #100 from jaiprasad04/feat/public-submodules-sync
added market studio
2026-04-24 00:38:35 +05:30
Jaya Prasad Kavuru
0f4748829a added market studio 2026-04-23 20:33:05 +05:30
Anil Chandra Naidu Matcha
6bc0a355fb
Merge pull request #99 from jaiprasad04/feat/public-submodules-sync
refactor: sync with public monorepos and add as submodules
2026-04-23 16:19:28 +05:30
Jaya Prasad Kavuru
ebacfd4748 refactor: sync with public monorepos and add as submodules 2026-04-23 16:08:38 +05:30
Anil Matcha
e9f6d3458a docs: promote Generative-Media-Skills for agent-driven automation
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:25:38 +05:30
Anil Matcha
1afefdad0c release: v1.0.4 — add Agents and Workflows tabs to desktop
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 02:38:48 +05:30
Anil Matcha
e832e9d389 feat: add Workflows and Agents tabs to Electron vanilla JS build
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 02:22:35 +05:30
Anil Chandra Naidu Matcha
a45b7a53b6
Merge pull request #97 from octo-patch/feat/add-minimax-provider
feat: add MiniMax Image 01 T2I model
2026-04-22 21:54:14 +05:30
octo-patch
bf32a393cc feat: add MiniMax image-01 T2I model and document MiniMax provider support
- Add minimax-image-01 to t2iModels in src/lib/models.js and
  packages/studio/src/models.js (8 aspect ratios, up to 4 images per
  request, 1500-char prompt)
- Add minimax-image-01 entry to models_dump.json (t2i section)
- Update README: list MiniMax Image 01 in newly-added image models
  and call out existing MiniMax Hailuo 02/2.3 video model support
- Add scripts/test_minimax_provider.js: validates model registration
  and optionally runs a live API smoke test (MUAPI_KEY env var)

MiniMax Hailuo 02/2.3 T2V and I2V models were already present;
this commit brings MiniMax image generation to feature parity.
2026-04-22 23:41:38 +08:00
Anil Matcha
6011fcb0fd feat: use Metal-GPU binary for macOS arm64 local inference
macOS Apple Silicon (darwin-arm64) now downloads the Metal-accelerated
sd-cli from our own GitHub release instead of the stock leejet build,
enabling significantly faster local image generation on M1/M2/M3/M4.
All other platforms continue to use the leejet upstream release.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 18:02:33 +05:30
Anil Matcha
8cf83b1b45 docs: document local model inference feature in README
Add "Local Model Inference" section covering supported models (Z-Image Turbo/Base, Dreamshaper, Realistic Vision, Anything v5, SDXL), auxiliary file requirements for Z-Image, step-by-step usage, and hardware notes for Metal GPU on Apple Silicon. Also add Local Inference bullet to the Features list.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 17:47:17 +05:30
Anil Matcha
36d392ab78 feat: add local model inference via sd.cpp (Electron-only)
- Scaffold full IPC bridge: preload.js exposes localAI API to renderer
- electron/lib/localInference.js: binary download (Metal-enabled macOS build),
  model management, auxiliary file downloads (Qwen3-4B LLM + FLUX VAE for Z-Image),
  and generation via sd-cli with DYLD_LIBRARY_PATH + xattr quarantine stripping
- electron/lib/modelCatalog.js: Z-Image Turbo/Base (featured) + SD 1.5/SDXL models
- Z-Image requires 3 components loaded via --diffusion-model + --llm + --vae flags
- ImageStudio: Local/API toggle (Electron-only), local model selector, progress bar
- SettingsModal: tabbed UI with Local Models tab (hidden on web/hosted version)
- LocalModelManager: engine status bar, per-model download cards, auxiliary
  component download UI (text encoder + VAE) for Z-Image models
- localInferenceClient.js: safe wrapper with isLocalAIAvailable() guard
- All local features gated behind isLocalAIAvailable() — web version unaffected

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 17:47:17 +05:30
Anil Chandra Naidu Matcha
5cbcd88733
Merge pull request #93 from jaiprasad04/feat/modernize-studio-upload
feat: integrate AI agent studio with minimal UI and upgrade Tailwind v4
2026-04-22 17:36:08 +05:30
Jaya Prasad Kavuru
4efb8593a4 feat: modernize Cinema Studio upload UI, implement batch generation in Image Studio, and fix textarea auto-resize across studios 2026-04-22 16:40:51 +05:30
Jaya Prasad Kavuru
911bcdd558 Merge remote-tracking branch 'origin/main' into feat/modernize-studio-upload 2026-04-22 15:46:10 +05:30
Jaya Prasad Kavuru
fddc2ff69f feat: implement drag-and-drop media uploads and modernize upload UI with circular progress indicators 2026-04-22 15:39:22 +05:30
Jaya Prasad Kavuru
62be9ace66 feat: integrate AI agent studio with minimal UI and upgrade Tailwind v4 2026-04-22 15:22:26 +05:30
Anil Matcha
9de0de3430 fix: correct predictions poll URL in studio muapi client
PROXY_APP_BASE ('/api/app') produced /api/app/v1/predictions/... which
after the Next.js rewrite hit 127.0.0.1:8000/app/v1/... → 404.
Poll now uses BASE_URL directly, consistent with how submit works.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 12:04:52 +05:30
Anil Matcha
aa6917e339 release: v1.0.2 — rename Seedance 2.0 models to SD 2, fix Windows build size
- Rename Seedance 2.0 → SD 2 (t2v, extend, i2v models)
- Bump version to 1.0.2
- Update README download links to v1.0.2
- Fix afterPack.js to strip platform-inappropriate @next/swc native binaries,
  reducing Windows installer from ~739 MB unpacked to ~144 MB
- Windows build now targets x64 only (arm64 cross-compile via Wine is broken on macOS)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 06:57:25 +05:30
Anil Matcha
4c48d58159 docs: add Workflow Studio section with Vibe Workflow link
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 00:58:30 +05:30
Anil Chandra Naidu Matcha
7582b89aea
Merge pull request #88 from jaiprasad04/feat/modernize-studio-upload
feat: implement workflow identity persistence, unify api auth, and up…
2026-04-21 20:16:41 +05:30
Jaya Prasad Kavuru
efa772e772 feat: implement workflow identity persistence, unify api auth, and upgrade tailwind to v4 2026-04-21 19:38:34 +05:30
Anil Chandra Naidu Matcha
9f858c0ba8 Add link to Awesome-GPT-Image-2-API-Prompts at top 2026-04-21 17:22:35 +05:30
Anil Chandra Naidu Matcha
6f9cdeeb47
Add follow creator section to README
Added a section to follow the creator for updates.
2026-04-16 10:24:28 +05:30
Anil Matcha
b92bdc5326 docs: highlight uncensored and unrestricted capabilities in README 2026-04-16 09:33:49 +05:30
Anil Chandra Naidu Matcha
57cb24bcbc
Merge pull request #77 from Assem-ElQersh/main
feat: add Linux (Ubuntu) desktop build support
2026-04-15 18:40:00 +05:30
Assem ElQersh
0f4ae55b37 feat: add Linux (Ubuntu) desktop build support
- Add electron:build:linux script and AppImage + .deb targets in package.json
- Fix Electron main.js: ESM → CommonJS, remove macOS-only titleBarStyle on Linux
- Fix createInlineInstructions undefined error in ImageStudio.js
- Fix Cinema Studio asset paths (absolute → relative) for Electron file:// protocol
- Add AppArmor profile for Ubuntu 24.04+ user namespace sandbox fix
- Add Ubuntu install docs and sandbox workaround to README
2026-04-15 00:49:44 +02:00
Anil Matcha
17adf7eca7 build: fix electron desktop build config for v1.0.1
- Rename vite.config.js to vite.config.mjs so ESM-only @tailwindcss/vite
  loads correctly (CJS require() can't import ESM modules)
- Remove tailwindcss from postcss.config.js — handled by @tailwindcss/vite
  in Tailwind v4; the v3 PostCSS plugin conflicts with v4 CSS syntax
- Add "main": "electron/main.js" to package.json so electron-builder
  finds the entry point instead of defaulting to index.js
- Bump version to 1.0.1

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 00:15:58 +05:30
Anil Matcha
1c33c1be7b fix: send required name and prompt fields for AI Video Effects
The generate_wan_ai_effects endpoint requires both `name` (effect type)
and `prompt` (str) fields. The client was sending neither — `name` had
no state/UI/payload entry, and `prompt` was omitted when blank — causing
a 422 Unprocessable Entity error.

- Add selectedEffectName state and getEffectNamesForModel() helper
- Add Effect dropdown button (visible only for ai-video-effects /
  motion-controls) with the full enum list from the model definition
- Wire updateControlsForModel to initialize/reset selectedEffectName
- Pass name in i2vParams at generate time
- Always send prompt (defaults to '') so the required str field is present
- Forward params.name in muapi.js generateI2V payload

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 22:59:06 +05:30
Anil Chandra Naidu Matcha
6c47f0dca3
Update README.md 2026-04-14 10:48:13 +05:30
Anil Chandra Naidu Matcha
963cc7d2e5
Update README to clarify project affiliations
Clarified project independence and third-party affiliations.
2026-04-13 13:24:37 +05:30
Anil Chandra Naidu Matcha
171f02c05f
Revise README to mention Freepik, Krea, and Openart AI
Updated README to include additional alternatives and clarify features.
2026-04-13 00:19:30 +05:30
Anil Matcha
ebcddfa187 Rename project from Open-Higgsfield-AI to Open-Generative-AI
Update all references across source files, config, and docs — including
page titles, app name, package name, Electron window title, and release
asset URLs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 00:14:45 +05:30
Anil Chandra Naidu Matcha
5925256c0a
Fix hosted link and add Discord community info
Updated hosted version link and added community section.
2026-04-12 15:29:14 +05:30
Anil Matcha
38d8ea3228 Merge branch 'main' of https://github.com/Anil-matcha/Open-Higgsfield-AI 2026-04-12 00:05:31 +05:30
Anil Matcha
1c6a0c863e Add Kling 2.6 and 3.0 Motion Control models 2026-04-12 00:01:04 +05:30
Anil Chandra Naidu Matcha
cf0d84d26e
Update hosted version link in README 2026-04-11 21:58:57 +05:30
Anil Matcha
20bf1bce42 Merge: resolve conflict, keep updated styling with /access-keys URLs 2026-04-11 21:15:24 +05:30
Anil Matcha
046f4ac071 Update API key access and add Veo 3.1 lite models 2026-04-11 21:02:57 +05:30
Anil Chandra Naidu Matcha
21a09ebea2
Merge pull request #64 from jaiprasad04/feat/modernize-studio-upload
feat: finalize studio persistence and multiple reference image upload UI
2026-04-10 18:44:59 +05:30
Jaya Prasad Kavuru
91ce11df6f feat: finalize studio persistence and multiple reference image upload UI 2026-04-10 18:40:41 +05:30
Anil Chandra Naidu Matcha
b578108936
Merge pull request #58 from jaiprasad04/feat/modernize-studio-upload
feat: modernize studio upload pipeline with cloud-only previews and p…
2026-04-09 17:33:40 +05:30
Jaya Prasad Kavuru
b924f0caf8 feat: modernize studio upload pipeline with cloud-only previews and progress indicators 2026-04-09 17:25:07 +05:30
Anil Matcha
776a325e77 feat: Integrate prompt tools and hover tooltips (PR #52)
- Add Quick Tools Panel to ImageStudio (Quick Starters + Prompt Enhancer)
- Add Camera Builder Panel to CinemaStudio
- Add Advanced Options panel to ImageStudio
- Move ENHANCE_TAGS, QUICK_PROMPTS, FOCAL_PERSPECTIVE, APERTURE_EFFECT to promptUtils.js
- Add hover tooltips to platform buttons in ImageStudio, VideoStudio, CinemaStudio

Co-Authored-By: Dean Gilmore <deangilmoreremix@users.noreply.github.com>
2026-03-25 23:23:36 +05:30
Developer
2c95a86af8 feat: Add hover tooltips to platform buttons
- Add CSS tooltip system with data-tooltip attribute
- Tooltips appear on hover with smooth animation
- Non-blocking: uses pointer-events: none
- Added to ImageStudio, VideoStudio, and CinemaStudio buttons
2026-03-25 23:23:22 +05:30
Developer
fe2d8dd0ac feat: Integrate prompt tools into ImageStudio and CinemaStudio
- Add Quick Starters and Prompt Enhancer to ImageStudio
  - 8 preset prompts (Portrait, Landscape, Product, Fantasy, Sci-Fi, Food, Architecture, Fashion)
  - 23 enhancement tags across 4 categories (quality, lighting, mood, style)
  - Collapsible panel accessible via 'Tools' button

- Add Camera Builder to CinemaStudio
  - Quick-access camera/lens/focal/aperture selector
  - Live preview using buildNanoBananaPrompt()
  - Accessible via 'Builder' button

- Refactor shared constants to promptUtils.js
  - ENHANCE_TAGS and QUICK_PROMPTS now exported from promptUtils
  - FOCAL_PERSPECTIVE and APERTURE_EFFECT also exported

- Update AssistPage to use shared constants from promptUtils

- Also includes TemplatesPage fix for searchability
2026-03-25 23:22:59 +05:30
Anil Matcha
0c29954889 fix: replace DropdownPanel with inline conditional rendering matching ImageStudio
Removes the DropdownPanel component that depended on the external `glass`
CSS class. Uses inline {open && <div bg-[#111]>} pattern and a single
dropdownRef, consistent with ImageStudio.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 04:36:02 +05:30
Anil Matcha
11f850abfc Fix: only register window click listener when dropdown is open 2026-03-24 03:35:49 +05:30
Anil Matcha
7ba63548d7 Minor fix 2026-03-24 02:53:00 +05:30
Anil Matcha
fc49614a87 fix: remove overflow-x-auto from VideoStudio controls row
overflow-x-auto implicitly sets overflow-y:auto, creating a clipping
context that cut off the upward-opening position:absolute dropdown
panels. Changed to flex-wrap so buttons wrap on narrow screens and
dropdowns display correctly.
2026-03-23 00:16:14 +05:30
Anil Matcha
f4c8b3aae3 docs: add hosted version link at muapi.ai/open-higgsfield-ai
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 19:30:56 +05:30
Anil Matcha
29cff36eef fix: use margin-auto centering for Cinema Studio prompt bar
Replace translate-based centering (md:left-1/2 md:-translate-x-1/2)
with margin-auto approach (md:left-0 md:right-0 md:mx-auto) to fix
prompt bar not being centered when rendered inside Tailwind v4 host app.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 19:04:53 +05:30
Anil Matcha
268102ab46 Add missing muted/secondary colors to studio tailwind config 2026-03-21 16:01:46 +05:30
Anil Chandra Naidu Matcha
87f18dd088
Merge pull request #49 from Anil-matcha/master
Master
2026-03-21 10:58:39 +05:30
Anil Chandra Naidu Matcha
41dc0c2810
Merge pull request #48 from Anil-matcha/master
Disable gatekeeperAssess for unsigned macOS builds
2026-03-18 12:31:23 +05:30
Anil Chandra Naidu Matcha
150cd639cb
Merge pull request #47 from Anil-matcha/master
Add Electron desktop app with macOS DMG and Windows installer support
2026-03-18 12:00:58 +05:30
Anil Chandra Naidu Matcha
38ce0ecbf1
Merge pull request #45 from Anil-matcha/master
Add mode selector for video models and API key UI
2026-03-13 19:40:48 +05:30
Anil Chandra Naidu Matcha
6877dbefe4
Merge pull request #44 from Anil-matcha/master
Add 15s duration and all modes to Grok Imagine video models
2026-03-13 19:39:19 +05:30
Anil Chandra Naidu Matcha
b84e38ef25
Merge pull request #43 from Anil-matcha/master
Fix LipSync uploads: use UploadPicker for image, VideoStudio pattern …
2026-03-12 18:09:31 +05:30
Anil Chandra Naidu Matcha
9e50297717
Merge pull request #42 from Anil-matcha/master
Master
2026-03-12 17:43:52 +05:30
Anil Chandra Naidu Matcha
d7ec9f239a
Merge pull request #41 from Anil-matcha/master
Improve generation reliability and error handling
2026-03-10 14:11:42 +05:30
Anil Chandra Naidu Matcha
be5c759e2a
Merge pull request #40 from Anil-matcha/master
Video generation fix
2026-03-09 19:17:48 +05:30
Anil Chandra Naidu Matcha
d6aafa18e1
Merge pull request #38 from Anil-matcha/master
Seedance 2.0 watermark remover changes added
2026-03-07 20:32:52 +05:30
Anil Chandra Naidu Matcha
692cef85a7
Merge pull request #37 from Anil-matcha/master
Add Seedance 2.0 i2v and video extend support
2026-03-04 15:13:01 +05:30
Anil Chandra Naidu Matcha
07f3eb2179
Merge pull request #36 from Anil-matcha/master
Add Seedance 2.0 text-to-video model
2026-03-02 15:48:35 +05:30
Anil Chandra Naidu Matcha
d9f7151996
Merge pull request #35 from Anil-matcha/master
Master
2026-02-27 19:09:17 +05:30
Anil Chandra Naidu Matcha
cf5ca7ce69
Merge pull request #34 from Anil-matcha/master
Update README with Image Studio, Video Studio, upload history, and 20…
2026-02-23 23:13:29 +05:30
Anil Chandra Naidu Matcha
5b1cfcf347
Merge pull request #33 from Anil-matcha/master
Add image upload history, i2i/i2v model support with dynamic mode swi…
2026-02-23 21:06:35 +05:30
Anil Chandra Naidu Matcha
3364a34111
Merge pull request #28 from Anil-matcha/master
Improved README and description
2026-02-17 23:47:17 +05:30
Anil Chandra Naidu Matcha
4b48d1c976
Merge pull request #27 from Anil-matcha/master
Added Video Studio
2026-02-17 21:53:33 +05:30
Anil Chandra Naidu Matcha
119589ef5d
Merge pull request #25 from Anil-matcha/master
Added comprehensive Medium guide
2026-02-15 07:32:46 +05:30
Anil Chandra Naidu Matcha
06229973f4
Merge pull request #24 from Anil-matcha/master
Resolution change to lower case fix for cinema studio
2026-02-14 15:54:56 +05:30
Anil Chandra Naidu Matcha
c9ae73077d
Merge pull request #23 from Anil-matcha/master
Cinema studio feature supported
2026-02-14 15:03:34 +05:30
78 changed files with 17145 additions and 12557 deletions

14
.gitignore vendored
View file

@ -22,3 +22,17 @@ dist-ssr
*.njsproj
*.sln
*.sw?
# Electron build output
release/
.webpack/
# Next.js
.next/
out/
# Misc
*.pem
.env
.env.*
!.env.example

6
.gitmodules vendored Normal file
View file

@ -0,0 +1,6 @@
[submodule "packages/Vibe-Workflow"]
path = packages/Vibe-Workflow
url = https://github.com/SamurAIGPT/Vibe-Workflow.git
[submodule "packages/Open-Poe-AI"]
path = packages/Open-Poe-AI
url = https://github.com/Anil-matcha/Open-Poe-AI.git

27
Dockerfile Normal file
View file

@ -0,0 +1,27 @@
FROM node:20-alpine AS base
WORKDIR /app
# Install dependencies
FROM base AS deps
COPY package*.json ./
COPY packages/Vibe-Workflow/packages/workflow-builder/package*.json ./packages/Vibe-Workflow/packages/workflow-builder/
COPY packages/Open-Poe-AI/packages/agents/package*.json ./packages/Open-Poe-AI/packages/agents/
COPY packages/studio/package*.json ./packages/studio/
RUN npm install
# Build sub-packages
FROM deps AS builder
COPY . .
RUN npm run build:packages
RUN npm run build
# Production runner
FROM base AS runner
ENV NODE_ENV=production
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
EXPOSE 3000
CMD ["npm", "start"]

264
README.md
View file

@ -1,6 +1,28 @@
# Open Higgsfield AI — Open-Source Alternative to Higgsfield AI
# Open Generative AI — Uncensored Open-Source Alternative to Higgsfield AI, Freepik AI, Krea AI, Openart AI
> **The free, open-source alternative to Higgsfield AI.** Generate AI images and videos using 200+ state-of-the-art models — without the closed ecosystem or subscription fees.
> **The free, open-source, unrestricted alternative to Higgsfield AI, Freepik, Krea, Openart AI.** Generate AI images and videos using 200+ state-of-the-art models — no content filters, no closed ecosystem, no subscription fees.
**Community:** Join [Reddit](reddit.com/r/muapi) & [Discord](https://discord.gg/s7KW4fsqXK) for discussions and support
> 🤖 **Automate Higgsfield, Freepik, Krea, Openart & more with AI coding agents:** [Generative-Media-Skills](https://github.com/SamurAIGPT/Generative-Media-Skills) — a library of skills that let agents like **Claude Code**, **Codex**, and other coding assistants drive 200+ image/video models end-to-end (prompt → generate → edit → stitch) directly from your terminal. Perfect for building automated media pipelines without touching a UI.
### Related projects
> **Open-source Weavy, Flora Fauna Freepik Spaces, Krea nodes alternative** -> https://github.com/SamurAIGPT/Vibe-Workflow
> **Open-source Opus Clip alternative — turn any long-form YouTube video into viral-ready vertical shorts** -> https://github.com/SamurAIGPT/AI-Youtube-Shorts-Generator
> **Open-source Lovart AI alternative, an AI design agent** -> https://github.com/Anil-matcha/Open-Lovart
## 🌐 Try it Online — No Install Required
**Hosted version:** [https://dev.muapi.ai/open-generative-ai](https://dev.muapi.ai/open-generative-ai)
Use all four studios (Image, Video, Lip Sync, Cinema) directly in your browser — no Node.js, no setup. Sign up for a free account to start generating. The hosted version is always up to date with the latest models.
**Follow** the [creator](https://x.com/matchaman11) for updates
---
## ⬇️ Download Desktop App
@ -8,11 +30,12 @@ One-click installers — no Node.js or terminal required.
| Platform | Download |
|---|---|
| macOS Apple Silicon (M1/M2/M3/M4) | [Open Higgsfield AI-1.0.0-arm64.dmg](https://github.com/Anil-matcha/Open-Higgsfield-AI/releases/download/v1.0.0/Open.Higgsfield.AI-1.0.0-arm64.dmg) |
| macOS Intel (x64) | [Open Higgsfield AI-1.0.0.dmg](https://github.com/Anil-matcha/Open-Higgsfield-AI/releases/download/v1.0.0/Open.Higgsfield.AI-1.0.0.dmg) |
| Windows (x64 + ARM64) | [Open Higgsfield AI Setup 1.0.0.exe](https://github.com/Anil-matcha/Open-Higgsfield-AI/releases/download/v1.0.0/Open.Higgsfield.AI.Setup.1.0.0.exe) |
| 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-Higgsfield-AI/releases](https://github.com/Anil-matcha/Open-Higgsfield-AI/releases)
All releases: [github.com/Anil-matcha/Open-Generative-AI/releases](https://github.com/Anil-matcha/Open-Generative-AI/releases)
### macOS Installation Guide
@ -22,7 +45,7 @@ Because the app is not notarized by Apple, macOS Gatekeeper will block it on fir
**Step 2** — Open Terminal and run:
```bash
xattr -cr "/Applications/Open Higgsfield AI.app"
xattr -cr "/Applications/Open Generative AI.app"
```
**Step 3** — Right-click the app in `/Applications` → click **Open** → click **Open** again on the dialog
@ -32,7 +55,7 @@ xattr -cr "/Applications/Open Higgsfield AI.app"
**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 Higgsfield AI was blocked"_
3. Scroll down to find _"Open Generative AI was blocked"_
4. Click **Open Anyway** → **Open**
### Windows Installation — SmartScreen warning fix
@ -44,29 +67,170 @@ Windows SmartScreen may show a warning because the installer is not code-signed:
The app will install silently to `%LocalAppData%` with a Start Menu shortcut.
### Ubuntu / Linux Installation
Linux artifacts are available when building with Electron Builder:
```bash
# Build Linux installers (AppImage + .deb)
npm run electron:build:linux
```
Generated files are written to the `release/` folder:
- **AppImage** — portable, run directly after making executable:
```bash
chmod +x "release/Open Generative AI-*.AppImage"
./release/Open\ Generative\ AI-*.AppImage
```
- **.deb** — install on Debian/Ubuntu:
```bash
sudo apt install ./release/open-generative-ai_*_amd64.deb
```
If AppImage fails to start on older systems, install `libfuse2`:
```bash
sudo apt install libfuse2
```
#### Ubuntu 24.04+ / AppArmor sandbox restriction
Ubuntu 24.04 and later enable a kernel security policy (`apparmor_restrict_unprivileged_userns`) that blocks Chromium's user-namespace sandbox. If the app fails to start silently or crashes immediately, you have two options:
**Option A — Recommended: install the `.deb` instead.**
The `.deb` package ships an AppArmor profile that grants the required permission automatically on install with no system-wide changes.
**Option B — Temporary system fix (AppImage users):**
```bash
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
```
This lasts until next reboot. To make it permanent:
```bash
echo 'kernel.apparmor_restrict_unprivileged_userns=0' | sudo tee /etc/sysctl.d/99-userns.conf
```
---
Open Higgsfield AI is an open-source AI image, video, cinema, and lip sync studio that brings Higgsfield-style creative workflows to everyone. Powered by [Muapi.ai](https://muapi.ai), it supports text-to-image, image-to-image, text-to-video, image-to-video, and audio-driven lip sync generation across models like Flux, Nano Banana, Midjourney, Kling, Sora, Veo, Seedream, Infinite Talk, LTX Lipsync, Wan 2.2, and more — all from a sleek, modern interface you can self-host and customize.
Open Generative AI is a free, uncensored, open-source AI image, video, cinema, and lip sync studio that brings unrestricted creative workflows to everyone. No content filters, no prompt rejections, no guardrails — just full creative freedom. Powered by [Muapi.ai](https://muapi.ai), it supports text-to-image, image-to-image, text-to-video, image-to-video, and audio-driven lip sync generation across models like Flux, Nano Banana, Midjourney, Kling, Sora, Veo, Seedream, Infinite Talk, LTX Lipsync, Wan 2.2, and more — all from a sleek, modern interface you can self-host and customize.
**Why Open Higgsfield AI instead of Higgsfield AI?**
**Why Open Generative AI instead of Higgsfield AI, Freepik, Krea AI, Openart AI?**
- **Uncensored & unrestricted** — no content filters, no nanny guardrails, no prompt rejections
- **Free & open-source** — no subscription, no vendor lock-in
- **Self-hosted** — your data stays on your machine
- **Self-hosted** — your data stays on your machine, full creative control
- **200+ models** — text-to-image, image-to-image, text-to-video, image-to-video, lip sync
- **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/building-open-higgsfield-ai-an-open-source-ai-cinema-studio-83c1e0a2a5f1).
For a deep dive into the technical architecture and the philosophy behind the "Infinite Budget" cinema workflow, see our [comprehensive guide and roadmap](https://medium.com/@anilmatcha/).
![Studio Demo](docs/assets/studio_demo.webp)
## ⚡ Local Model Inference (Desktop App Only)
The desktop app supports **two independent local engines**. Pick whichever fits the machine you actually run on:
| Engine | What it is | Best for |
|---|---|---|
| **sd.cpp** (bundled) | C++ engine from [stable-diffusion.cpp](https://github.com/leejet/stable-diffusion.cpp), runs on the same machine as the app. Metal GPU on Apple Silicon, CUDA/Vulkan/ROCm on Linux/Windows. | Image-only models. Works on Mac M-series. |
| **Wan2GP** (BYO server) | HTTP client to a user-run [Wan2GP](https://github.com/deepbeepmeep/Wan2GP) server. The server runs Python + PyTorch on a CUDA/ROCm GPU; the desktop app only sends prompts and receives results. | Video models (Wan 2.2, Hunyuan, LTX) and large image models (Flux, Qwen-Image). NVIDIA/AMD GPU required on the *server*; the desktop app itself can run on a Mac. |
Both engines share the same UI: open **Settings → Local Models** to configure each.
### Engine 1 — sd.cpp (bundled)
| Model | Type | Size | Notes |
|---|---|---|---|
| **Z-Image Turbo** ⚡ | Diffusion Transformer | 2.5 GB + 2.7 GB aux | 8-step turbo. Heavy on memory. |
| **Z-Image Base** ⚡ | Diffusion Transformer | 3.5 GB + 2.7 GB aux | 50-step high-quality. Heavy on memory. |
| **Dreamshaper 8** | SD 1.5 | 2.1 GB | 20-step versatile. Lightest tested option on Mac. |
| **Realistic Vision v5.1** | SD 1.5 | 2.1 GB | 25-step photorealistic |
| **Anything v5** | SD 1.5 | 2.1 GB | 20-step anime/illustration |
| **SDXL Base 1.0** | SDXL | 6.9 GB | 30-step high-res |
> **Z-Image models** require two shared auxiliary files (downloaded once, shared across both models):
> - **Qwen3-4B Text Encoder** — 2.4 GB
> - **FLUX VAE** — 335 MB
**How to use:**
1. Open **Settings → Local Models** in the desktop app
2. Install the **sd.cpp inference engine** (one click — auto-downloaded)
3. Download your chosen model (and auxiliary files for Z-Image)
4. In **Image Studio**, click the **⚡ Local** toggle next to the model selector
5. Select your local model and generate — no API key needed
All downloads happen inside the app. Nothing is installed system-wide.
### Engine 2 — Wan2GP (remote Gradio server)
The app does **not** bundle Python or model weights for Wan2GP. You run Wan2GP yourself on a machine with a CUDA or ROCm GPU and point the desktop app at its URL.
```bash
# On your GPU machine
git clone https://github.com/deepbeepmeep/Wan2GP
cd Wan2GP
./install.sh # or install.bat on Windows
python wgp.py --listen --server-name 0.0.0.0 # binds to all interfaces
```
Then in the desktop app: **Settings → Local Models → Wan2GP server**, paste the URL (e.g. `http://192.168.1.42:7860`), click **Test**, then **Save**. The Wan2GP models become available — image models in **Image Studio**, video models reachable via the same generation API (Image Studio rejects video output explicitly; full Video Studio wiring is on the roadmap).
| Model | Type | Notes |
|---|---|---|
| **Flux.1 Dev** | Image | 1024px, 28 steps |
| **Qwen Image** | Image | 1024px, 30 steps |
| **Wan 2.2 (T2V / I2V)** | Video | Slow on consumer GPUs |
| **Hunyuan Video** | Video | High-quality T2V |
| **LTX Video** | Video | Fastest video option |
> **Why a separate server?** Wan2GP's runtime (Sage attention, flash-attn, AWQ/GGUF kernels) is CUDA-only — there is no MPS / Apple Silicon path. Treating it as a remote server lets a Mac-only user keep the desktop app while offloading inference to a Linux/Windows GPU box, a gaming PC on the LAN, or a rented RunPod/vast.ai instance.
> **Local inference is only available in the desktop app.** The hosted web version always uses cloud APIs.
### Hardware Notes
- **sd.cpp** runs on CPU (all platforms) and **Metal GPU** on Apple Silicon (M1/M2/M3/M4); CUDA/Vulkan/ROCm on Linux/Windows.
- Metal GPU acceleration is built into the macOS desktop binary — significantly faster than CPU-only.
- Recommended for sd.cpp Z-Image: 16 GB RAM (7.4 GB weights + 2.4 GB compute buffer). On a base 8 GB M-series Mac, **Z-Image is known to hang the system** — stick to SD 1.5 there.
- For SD 1.5 on M2: expect ~12 s/step with the Metal dylib active. If you see ~10 s/step instead, the binary may have fallen back to CPU — see verification below.
### Verifying the SD 1.5 path (the fastest sanity test on Mac)
If you want to confirm sd.cpp is installed correctly without going through the UI, you can drive `sd-cli` directly. This is the same binary the app uses.
```bash
# 1. App data layout (created on first app launch)
APP_DATA="$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** — Higgsfield AI-style interface for photorealistic cinematic shots with pro camera controls (Lens, Focal Length, Aperture)
- **Cinema Studio** — Interface for photorealistic cinematic shots with pro camera controls (Lens, Focal Length, Aperture)
- **Workflow Studio** — Build and run multi-step AI pipelines visually. Chain image, video, and audio models into automated flows. Browse community templates, create your own with a node-based editor, and run them via an interactive playground.
- **Upload History** — Reference images are uploaded once and stored locally. A picker panel lets you reuse any previously uploaded image across sessions — no re-uploading.
- **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)
@ -91,6 +255,7 @@ The Image Studio automatically switches between two model sets:
| **Nano Banana 2 Edit** | Image-to-Image | Up to **14 reference images** · Resolution 1K/2K/4K · Google Search enhancement |
| **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
@ -137,6 +302,7 @@ The Video Studio follows the same pattern:
| **Seedance 2.0 Extend** | Video Extension | ByteDance · Seamlessly continue any Seedance 2.0 generation · Preserves style, motion & audio · Optional continuation prompt · Duration 5 / 10 / 15s · Quality basic/high |
| **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
@ -175,6 +341,20 @@ The **Lip Sync Studio** generates audio-driven talking videos using 9 models acr
Generation history is saved separately in `lipsync_history` and pending jobs resume automatically on page reload.
### 🔀 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:
@ -207,19 +387,34 @@ Every image you upload is saved locally (URL + thumbnail) so you never upload th
### 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
git clone https://github.com/Anil-matcha/Open-Higgsfield-AI.git
cd Open-Higgsfield-AI
# 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
# Install dependencies (installs root + packages/studio workspace)
npm install
# If you already cloned without --recurse-submodules, run this once:
# git submodule update --init --recursive
# Start the development server
npm run dev
# 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
```
Open `http://localhost:3000` in your browser. You'll be prompted to enter your Muapi API key on first use.
You'll be prompted to enter your Muapi API key on first use (skip the key if you only plan to use local models).
> **Troubleshooting — `Couldn't find a 'pages' directory`**: this means Next.js can't see the `app/` folder. Confirm you're running `npm run dev` from the repo root (the directory that contains `app/`, `package.json`, and `next.config.mjs`), and that you cloned with submodules. Re-run `npm run setup` if `packages/Vibe-Workflow` or `packages/Open-Poe-AI` are empty.
### Production Build
@ -239,18 +434,21 @@ 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-Higgsfield-AI/releases).
Installers are output to the `release/` folder. Pre-built binaries are also available on the [Releases page](https://github.com/Anil-matcha/Open-Generative-AI/releases).
## 🏗️ Architecture
The app is a **Next.js monorepo** with a shared `packages/studio` component library.
```
Open-Higgsfield-AI/
Open-Generative-AI/
├── app/ # Next.js App Router
│ ├── layout.js # Root layout (Tailwind, fonts)
│ ├── page.js # Redirects → /studio
@ -262,14 +460,15 @@ Open-Higgsfield-AI/
├── packages/
│ └── studio/ # Shared React component library
│ └── src/
│ ├── index.js # Exports: ImageStudio, VideoStudio, LipSyncStudio, CinemaStudio
│ ├── index.js # Exports: ImageStudio, VideoStudio, LipSyncStudio, CinemaStudio, WorkflowStudio
│ ├── models.js # 200+ model definitions (single source of truth)
│ ├── 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
│ ├── 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"]
@ -308,16 +507,19 @@ Lip sync jobs use the same two-step pattern: a dedicated `processLipSync()` meth
- **npm workspaces** — Monorepo with shared `packages/studio` library
- **Muapi.ai** — AI model API gateway
## 🤔 How is this different from Higgsfield AI?
## 🤔 How is this different from Higgsfield AI, Freepik, Krea, Openart AI?
Higgsfield AI is a proprietary AI video and image generation platform. **Open Higgsfield AI** is a community-driven, open-source alternative that provides similar creative capabilities without the closed ecosystem:
**Open Generative AI** is a community-driven, open-source alternative that provides similar creative capabilities without the closed ecosystem:
| | Higgsfield AI | Open Higgsfield AI |
| | Other providers | Open Generative AI |
| :--- | :--- | :--- |
| **Cost** | Subscription-based | Free (open-source) |
| **Content filters** | Yes — prompts blocked or altered | None — fully uncensored |
| **Restrictions** | Platform guardrails enforced | Unrestricted 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) |
| **Self-hosting** | No | Yes |
| **Customizable** | No | Fully hackable |
| **Data privacy** | Cloud-based | Your data stays local |
@ -332,7 +534,9 @@ MIT
Built with [Muapi.ai](https://muapi.ai) — the unified API for AI image and video generation models.
---
**Deep Dive**: For more details on the "AI Influencer" engine, upcoming "Popcorn" storyboarding features, and the future of this project, read the [full technical overview](https://medium.com/@anilmatcha/building-open-higgsfield-ai-an-open-source-ai-cinema-studio-83c1e0a2a5f1).
**Deep Dive**: For more details on the "AI Influencer" engine, upcoming "Popcorn" storyboarding features, and the future of this project, read the [full technical overview](https://medium.com/@anilmatcha/).
---
*Looking for a free Higgsfield AI alternative? Open Higgsfield AI is an open-source AI image and video generation studio and Higgsfield AI replacement that you can self-host, customize, and extend.*
*Looking for a free, uncensored Higgsfield AI, Freepik, Krea, Openart AI alternative? Open Generative AI is an open-source, unrestricted AI image and video generation studio — a Higgsfield AI, Freepik, Krea, Openart AI replacement with no content filters that you can self-host, customize, and extend.*
This project is an independent, experimental, and open-source initiative and is not affiliated with, endorsed by, or associated with Higgsfield Inc., Freepik, Krea AI, OpenArt AI, or any of their respective companies, products, or services. Any references to third-party platforms, models, or technologies are made solely for interoperability, benchmarking, research, or educational purposes. All trademarks, logos, and brand names are the property of their respective owners. If any content in this repository creates confusion or raises concerns, please contact us and we will promptly review and address it.

View file

@ -1,8 +1,35 @@
import { execSync } from 'child_process';
import path from 'path';
import fs from 'fs';
export default async function afterPack({ appOutDir, packager }) {
if (packager.platform.name !== 'mac') return;
const platformName = packager.platform.name;
// Remove Next.js SWC native binaries that don't belong on this target platform.
// They are bundled because `next` is in dependencies, but only the host-platform
// binary is ever used at runtime in the Electron app.
const nextDir = path.join(appOutDir,
platformName === 'mac'
? `${packager.appInfo.productName}.app/Contents/Resources`
: 'resources',
'app.asar.unpacked/node_modules/@next'
);
if (fs.existsSync(nextDir)) {
const keepPrefix = platformName === 'mac' ? 'swc-darwin'
: platformName === 'windows' ? 'swc-win32'
: 'swc-linux';
for (const entry of fs.readdirSync(nextDir)) {
if (entry.startsWith('swc-') && !entry.startsWith(keepPrefix)) {
const fullPath = path.join(nextDir, entry);
fs.rmSync(fullPath, { recursive: true, force: true });
console.log(` • removed foreign SWC binary path=${fullPath}`);
}
}
}
if (platformName !== 'mac') return;
const appPath = path.join(appOutDir, `${packager.appInfo.productName}.app`);
console.log(` • ad-hoc signing path=${appPath}`);

View file

@ -0,0 +1,83 @@
"use client";
import { AiAgent } from "ai-agent";
import "ai-agent/dist/tailwind.css";
import { useCallback, useEffect, useRef } from "react";
import axios from "axios";
const STORAGE_KEY = "muapi_key";
/**
* AgentChatClient mirrors muapiapp's AgentClient.js.
* Renders the AiAgent library component with server-fetched agent details
* and optional initial history.
*
* IMPORTANT: StandaloneShell is NOT in the tree on /agents/* pages, so we
* must set up our own axios interceptor here to inject the API key into
* all requests made by the AiAgent library.
*/
export default function AgentChatClient({ agentDetails, initialHistory, userData }) {
const interceptorRef = useRef(null);
console.log("[AgentChatClient] Rendering", {
hasAgentDetails: !!agentDetails,
hasHistory: !!initialHistory,
hasUserData: !!userData
});
useEffect(() => {
const getKey = () => {
if (typeof window === "undefined") return null;
const fromStorage = localStorage.getItem(STORAGE_KEY);
if (fromStorage) return fromStorage;
const match = document.cookie.match(/muapi_key=([^;]+)/);
return match ? match[1] : null;
};
const apiKey = getKey();
if (!apiKey) return;
interceptorRef.current = axios.interceptors.request.use((config) => {
const isRelative =
config.url.startsWith("/") || !config.url.startsWith("http");
// Include specific proxy paths to be sure
const isInternalProxy = config.url.includes('/api/app') || config.url.includes('/api/workflow') || config.url.includes('/api/agents') || config.url.includes('/api/api') || config.url.includes('/api/v1');
if (isRelative || isInternalProxy) {
config.headers["x-api-key"] = apiKey;
}
return config;
});
return () => {
if (interceptorRef.current !== null) {
axios.interceptors.request.eject(interceptorRef.current);
}
};
}, []);
const useUser = useCallback(
() => ({
user: {
username: userData?.email?.split("@")[0] || "Studio User",
name: userData?.email?.split("@")[0] || "Studio User",
email: userData?.email || null,
profile_photo: null,
balance: userData?.balance || 0,
},
isAuthorized: !!userData,
}),
[userData]
);
return (
<div className="h-screen w-full bg-black">
<AiAgent
initialAgentDetails={agentDetails}
initialHistory={initialHistory}
useUser={useUser}
usedIn="muapiapp"
/>
</div>
);
}

View file

@ -0,0 +1,111 @@
import { cookies } from "next/headers";
import AgentChatClient from "../AgentChatClient";
/**
* Server component fetches both agentDetails and initialHistory
* from the /api/agents proxy using the muapi_key cookie, then renders
* the client chat component with existing conversation messages pre-loaded.
*
* URL: /agents/[agent_id]/[conversation_id]
*/
export async function generateMetadata({ params }) {
return {
title: `Agent Chat — Open Generative AI`,
};
}
const BASE_URL = 'https://api.muapi.ai';
async function fetchAgentDetails(agentId, apiKey) {
if (!apiKey) return null;
try {
const res = await fetch(
`${BASE_URL}/agents/by-slug/${agentId}`,
{
cache: "no-store",
headers: { "x-api-key": apiKey },
}
);
if (res.ok) return await res.json();
if (agentId.length > 20) {
const resId = await fetch(
`${BASE_URL}/agents/${agentId}`,
{
cache: "no-store",
headers: { "x-api-key": apiKey },
}
);
if (resId.ok) return await resId.json();
}
return null;
} catch {
return null;
}
}
async function fetchHistory(agentId, conversationId, apiKey) {
if (!apiKey) return null;
try {
// Try by slug first
const res = await fetch(
`${BASE_URL}/agents/by-slug/${agentId}/${conversationId}`,
{
cache: "no-store",
headers: { "x-api-key": apiKey },
}
);
if (res.ok) return await res.json();
// Fallback to direct agent ID if needed
if (agentId.length > 20) {
const resId = await fetch(
`${BASE_URL}/agents/${agentId}/${conversationId}`,
{
cache: "no-store",
headers: { "x-api-key": apiKey },
}
);
if (resId.ok) return await resId.json();
}
return null;
} catch {
return null;
}
}
async function fetchUserData(apiKey) {
if (!apiKey) return null;
try {
const res = await fetch(`${BASE_URL}/api/v1/account/balance`, {
cache: "no-store",
headers: { "x-api-key": apiKey },
});
if (!res.ok) return null;
return await res.json();
} catch {
return null;
}
}
export default async function AgentConversationPage({ params }) {
const { agent_id, conversation_id } = await params;
const cookieStore = await cookies();
const apiKey = cookieStore.get("muapi_key")?.value;
console.log(`[ConvPage] Loading for agent: ${agent_id}, conv: ${conversation_id}, hasKey: ${!!apiKey}`);
const [agentDetails, initialHistory, userData] = await Promise.all([
fetchAgentDetails(agent_id, apiKey),
fetchHistory(agent_id, conversation_id, apiKey),
fetchUserData(apiKey)
]);
return (
<AgentChatClient
agentDetails={agentDetails}
initialHistory={initialHistory}
userData={userData}
/>
);
}

View file

@ -0,0 +1,89 @@
import { cookies } from "next/headers";
import AgentChatClient from "./AgentChatClient";
/**
* Server component fetches agentDetails from the /api/agents proxy
* (which forwards to https://api.muapi.ai/agents/by-slug/{id})
* using the muapi_key cookie for auth, then renders the client chat component.
*
* URL: /agents/[agent_id] (new chat no conversation ID yet)
*/
export async function generateMetadata({ params }) {
const { agent_id } = await params;
return {
title: `Agent Chat — Open Generative AI`,
};
}
const BASE_URL = 'https://api.muapi.ai';
async function fetchAgentDetails(agentId, apiKey) {
if (!apiKey) return null;
// Try fetching by slug first
try {
console.log(`[AgentPage] Fetching agent by slug: ${agentId}`);
const res = await fetch(
`${BASE_URL}/agents/by-slug/${agentId}`,
{
cache: "no-store",
headers: { "x-api-key": apiKey },
}
);
if (res.ok) return await res.json();
// If by-slug fails, try fetching by direct ID (if it looks like a UUID)
if (agentId.length > 20) {
console.log(`[AgentPage] Fetch by slug failed, trying by ID: ${agentId}`);
const resId = await fetch(
`${BASE_URL}/agents/${agentId}`,
{
cache: "no-store",
headers: { "x-api-key": apiKey },
}
);
if (resId.ok) return await resId.json();
}
console.warn(`[AgentPage] Failed to fetch agent details for: ${agentId}`);
return null;
} catch (error) {
console.error("[AgentPage] Fetch error:", error);
return null;
}
}
async function fetchUserData(apiKey) {
if (!apiKey) return null;
try {
const res = await fetch(`${BASE_URL}/api/v1/account/balance`, {
cache: "no-store",
headers: { "x-api-key": apiKey },
});
if (!res.ok) return null;
return await res.json();
} catch {
return null;
}
}
export default async function AgentPage({ params }) {
const { agent_id } = await params;
const cookieStore = await cookies();
const apiKey = cookieStore.get("muapi_key")?.value;
console.log(`[AgentPage] Loading page for agent: ${agent_id}, hasKey: ${!!apiKey}`);
const [agentDetails, userData] = await Promise.all([
fetchAgentDetails(agent_id, apiKey),
fetchUserData(apiKey)
]);
return (
<AgentChatClient
agentDetails={agentDetails}
initialHistory={null}
userData={userData}
/>
);
}

View file

@ -0,0 +1,62 @@
"use client";
import { CreateAgentPage } from "ai-agent";
import "ai-agent/dist/tailwind.css";
import { useCallback, useEffect, useRef } from "react";
import axios from "axios";
const STORAGE_KEY = "muapi_key";
export default function AgentCreateClient({ userData }) {
const interceptorRef = useRef(null);
useEffect(() => {
const getKey = () => {
if (typeof window === "undefined") return null;
const fromStorage = localStorage.getItem(STORAGE_KEY);
if (fromStorage) return fromStorage;
const match = document.cookie.match(/muapi_key=([^;]+)/);
return match ? match[1] : null;
};
const apiKey = getKey();
if (!apiKey) return;
interceptorRef.current = axios.interceptors.request.use((config) => {
const isRelative = config.url.startsWith("/") || !config.url.startsWith("http");
const isInternalProxy = config.url.includes('/api/app') || config.url.includes('/api/workflow') || config.url.includes('/api/agents') || config.url.includes('/api/api') || config.url.includes('/api/v1');
if (isRelative || isInternalProxy) {
config.headers["x-api-key"] = apiKey;
}
return config;
});
return () => {
if (interceptorRef.current !== null) {
axios.interceptors.request.eject(interceptorRef.current);
}
};
}, []);
const useUser = useCallback(
() => ({
user: {
username: userData?.email?.split("@")[0] || "Studio User",
name: userData?.email?.split("@")[0] || "Studio User",
email: userData?.email || null,
profile_photo: null,
balance: userData?.balance || 0,
},
isAuthorized: !!userData,
}),
[userData]
);
return (
<CreateAgentPage
useUser={useUser}
usedIn="studio"
/>
);
}

29
app/agents/create/page.js Normal file
View file

@ -0,0 +1,29 @@
import { cookies } from "next/headers";
import AgentCreateClient from "./AgentCreateClient";
const BASE_URL = 'https://api.muapi.ai';
async function fetchUserData(apiKey) {
if (!apiKey) return null;
try {
const res = await fetch(`${BASE_URL}/api/v1/account/balance`, {
cache: "no-store",
headers: { "x-api-key": apiKey },
});
if (!res.ok) return null;
return await res.json();
} catch {
return null;
}
}
export default async function CreateAgentPage() {
const cookieStore = await cookies();
const apiKey = cookieStore.get("muapi_key")?.value;
const userData = await fetchUserData(apiKey);
return (
<AgentCreateClient userData={userData} />
);
}

View file

@ -0,0 +1,62 @@
"use client";
import { EditAgentPage } from "ai-agent";
import "ai-agent/dist/tailwind.css";
import { useCallback, useEffect, useRef } from "react";
import axios from "axios";
const STORAGE_KEY = "muapi_key";
export default function AgentEditClient({ userData }) {
const interceptorRef = useRef(null);
useEffect(() => {
const getKey = () => {
if (typeof window === "undefined") return null;
const fromStorage = localStorage.getItem(STORAGE_KEY);
if (fromStorage) return fromStorage;
const match = document.cookie.match(/muapi_key=([^;]+)/);
return match ? match[1] : null;
};
const apiKey = getKey();
if (!apiKey) return;
interceptorRef.current = axios.interceptors.request.use((config) => {
const isRelative = config.url.startsWith("/") || !config.url.startsWith("http");
const isInternalProxy = config.url.includes('/api/app') || config.url.includes('/api/workflow') || config.url.includes('/api/agents') || config.url.includes('/api/api') || config.url.includes('/api/v1');
if (isRelative || isInternalProxy) {
config.headers["x-api-key"] = apiKey;
}
return config;
});
return () => {
if (interceptorRef.current !== null) {
axios.interceptors.request.eject(interceptorRef.current);
}
};
}, []);
const useUser = useCallback(
() => ({
user: {
username: userData?.email?.split("@")[0] || "Studio User",
name: userData?.email?.split("@")[0] || "Studio User",
email: userData?.email || null,
profile_photo: null,
balance: userData?.balance || 0,
},
isAuthorized: !!userData,
}),
[userData]
);
return (
<EditAgentPage
useUser={useUser}
usedIn="studio"
/>
);
}

View file

@ -0,0 +1,30 @@
import { cookies } from "next/headers";
import AgentEditClient from "./AgentEditClient";
const BASE_URL = 'https://api.muapi.ai';
async function fetchUserData(apiKey) {
if (!apiKey) return null;
try {
const res = await fetch(`${BASE_URL}/api/v1/account/balance`, {
cache: "no-store",
headers: { "x-api-key": apiKey },
});
if (!res.ok) return null;
return await res.json();
} catch {
return null;
}
}
export default async function EditAgentPage({ params }) {
const { id } = await params; // although we don't use id on server here, it's used by useParams in client
const cookieStore = await cookies();
const apiKey = cookieStore.get("muapi_key")?.value;
const userData = await fetchUserData(apiKey);
return (
<AgentEditClient userData={userData} />
);
}

16
app/agents/layout.js Normal file
View file

@ -0,0 +1,16 @@
/**
* Layout for /agents/* pages.
* These pages host the AiAgent component full-screen no studio chrome needed.
* The api key is available via the muapi_key cookie which StandaloneShell sets.
*/
export const metadata = {
title: "Agent Chat — Open Generative AI",
};
export default function AgentsLayout({ children }) {
return (
<div className="h-screen w-full overflow-hidden bg-black">
{children}
</div>
);
}

View file

@ -0,0 +1,110 @@
import { NextResponse } from 'next/server';
const MUAPI_BASE = 'https://api.muapi.ai';
function getApiKey(request) {
// Priority 1: Direct x-api-key header
const headerKey = request.headers.get('x-api-key');
if (headerKey) return headerKey;
// Priority 2: muapi_key cookie
const cookieKey = request.cookies.get('muapi_key')?.value;
return cookieKey;
}
function cleanHeaders(request) {
const headers = new Headers(request.headers);
headers.delete('host');
headers.delete('connection');
headers.delete('cookie'); // CRITICAL: Stop forwarding browser cookies to MuAPI
return headers;
}
// Build the target URL without a trailing slash when path is empty.
// e.g. GET /api/agents?is_template=true → https://api.muapi.ai/agents?is_template=true
// e.g. GET /api/agents/by-slug/foo → https://api.muapi.ai/agents/by-slug/foo
function buildTargetUrl(pathSegments, search) {
const path = pathSegments.join('/');
const base = `${MUAPI_BASE}/agents`;
return path ? `${base}/${path}${search}` : `${base}${search}`;
}
export async function GET(request, { params }) {
const slug = await params;
const pathSegments = slug.path || [];
const { search } = new URL(request.url);
const targetUrl = buildTargetUrl(pathSegments, search);
const headers = cleanHeaders(request);
const apiKey = getApiKey(request);
console.log(`[agents proxy GET] ${targetUrl} | apiKey: ${apiKey ? apiKey.slice(0,8)+'...' : 'MISSING'}`);
if (apiKey) headers.set('x-api-key', apiKey);
try {
const response = await fetch(targetUrl, { headers, method: 'GET' });
const data = await response.json();
return NextResponse.json(data, { status: response.status });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export async function POST(request, { params }) {
const slug = await params;
const pathSegments = slug.path || [];
const { search } = new URL(request.url);
const targetUrl = buildTargetUrl(pathSegments, search);
const headers = cleanHeaders(request);
const apiKey = getApiKey(request);
console.log(`[agents proxy POST] ${targetUrl} | apiKey: ${apiKey ? apiKey.slice(0,8)+'...' : 'MISSING'}`);
if (apiKey) headers.set('x-api-key', apiKey);
try {
const body = await request.arrayBuffer();
const response = await fetch(targetUrl, { method: 'POST', headers, body });
const data = await response.json();
return NextResponse.json(data, { status: response.status });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export async function DELETE(request, { params }) {
const slug = await params;
const pathSegments = slug.path || [];
const { search } = new URL(request.url);
const targetUrl = buildTargetUrl(pathSegments, search);
const headers = cleanHeaders(request);
const apiKey = getApiKey(request);
if (apiKey) headers.set('x-api-key', apiKey);
try {
const response = await fetch(targetUrl, { method: 'DELETE', headers });
const data = await response.json();
return NextResponse.json(data, { status: response.status });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export async function PUT(request, { params }) {
const slug = await params;
const pathSegments = slug.path || [];
const { search } = new URL(request.url);
const targetUrl = buildTargetUrl(pathSegments, search);
const headers = cleanHeaders(request);
const apiKey = getApiKey(request);
if (apiKey) headers.set('x-api-key', apiKey);
try {
const body = await request.arrayBuffer();
const response = await fetch(targetUrl, { method: 'PUT', headers, body });
const data = await response.json();
return NextResponse.json(data, { status: response.status });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View file

@ -0,0 +1,65 @@
import { NextResponse } from 'next/server';
const MUAPI_BASE = 'https://api.muapi.ai';
function getApiKey(request) {
const headerKey = request.headers.get('x-api-key');
if (headerKey) return headerKey;
const cookieKey = request.cookies.get('muapi_key')?.value;
return cookieKey;
}
function cleanHeaders(request) {
const headers = new Headers(request.headers);
headers.delete('host');
headers.delete('connection');
headers.delete('cookie');
return headers;
}
// Proxies /api/api/v1/* -> https://api.muapi.ai/api/v1/*
// This is required because the AiAgent library hardcodes a double /api/api
export async function GET(request, { params }) {
const slug = await params;
const pathSegments = slug.path || [];
const path = pathSegments.join('/');
const { search } = new URL(request.url);
const targetUrl = `${MUAPI_BASE}/api/v1/${path}${search}`;
const headers = cleanHeaders(request);
const apiKey = getApiKey(request);
console.log(`[double-api proxy GET] ${targetUrl} | apiKey: ${apiKey ? apiKey.slice(0,8)+'...' : 'MISSING'}`);
if (apiKey) headers.set('x-api-key', apiKey);
try {
const response = await fetch(targetUrl, { headers, method: 'GET' });
const data = await response.json();
return NextResponse.json(data, { status: response.status });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export async function POST(request, { params }) {
const slug = await params;
const pathSegments = slug.path || [];
const path = pathSegments.join('/');
const { search } = new URL(request.url);
const targetUrl = `${MUAPI_BASE}/api/v1/${path}${search}`;
const headers = cleanHeaders(request);
const apiKey = getApiKey(request);
if (apiKey) headers.set('x-api-key', apiKey);
try {
const body = await request.arrayBuffer();
const response = await fetch(targetUrl, { method: 'POST', headers, body });
const data = await response.json();
return NextResponse.json(data, { status: response.status });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View file

@ -0,0 +1,145 @@
import { NextResponse } from 'next/server';
const MUAPI_BASE = 'https://api.muapi.ai';
function getApiKey(request) {
// Priority 1: Direct x-api-key header
const headerKey = request.headers.get('x-api-key');
if (headerKey) return headerKey;
// Priority 2: muapi_key cookie (used by the fixed builder library)
const cookieKey = request.cookies.get('muapi_key')?.value;
return cookieKey;
}
function cleanHeaders(request) {
const headers = new Headers(request.headers);
headers.delete('host');
headers.delete('connection');
headers.delete('cookie'); // CRITICAL: Stop forwarding browser cookies to MuAPI to avoid auth conflicts
return headers;
}
export async function GET(request, { params }) {
const slug = await params;
const pathSegments = slug.path || [];
const path = pathSegments.join('/');
// Handle alias: get_upload_file -> get_file_upload_url
const effectivePath = path === 'get_upload_file' ? 'get_file_upload_url' : path;
const { search } = new URL(request.url);
const targetUrl = `${MUAPI_BASE}/app/${effectivePath}${search}`;
const headers = cleanHeaders(request);
const apiKey = getApiKey(request);
if (apiKey) headers.set('x-api-key', apiKey);
try {
const response = await fetch(targetUrl, {
headers,
method: 'GET',
});
const data = await response.json();
// SPECIAL CASE: Intercept upload URL and redirect to local binary proxy
if (effectivePath === 'get_file_upload_url' && data.url) {
const originalS3Url = data.url;
// We pass the real S3 URL as a header to our proxy
data.url = `/api/upload-binary`;
// Store target in a temporary way?
// Better: Return the target URL as an extra field that our proxy will look for
data.fields = {
...data.fields,
'x-proxy-target-url': originalS3Url
};
}
return NextResponse.json(data, { status: response.status });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export async function POST(request, { params }) {
const slug = await params;
const pathSegments = slug.path || [];
const path = pathSegments.join('/');
const { search } = new URL(request.url);
const targetUrl = `${MUAPI_BASE}/app/${path}${search}`;
const headers = cleanHeaders(request);
const apiKey = getApiKey(request);
if (apiKey) headers.set('x-api-key', apiKey);
try {
const body = await request.arrayBuffer();
const response = await fetch(targetUrl, {
method: 'POST',
headers,
body
});
const data = await response.json();
return NextResponse.json(data, { status: response.status });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export async function DELETE(request, { params }) {
const slug = await params;
const pathSegments = slug.path || [];
const path = pathSegments.join('/');
const { search } = new URL(request.url);
const targetUrl = `${MUAPI_BASE}/app/${path}${search}`;
const headers = cleanHeaders(request);
const apiKey = getApiKey(request);
if (apiKey) headers.set('x-api-key', apiKey);
try {
const response = await fetch(targetUrl, {
method: 'DELETE',
headers
});
const data = await response.json();
return NextResponse.json(data, { status: response.status });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export async function PUT(request, { params }) {
const slug = await params;
const pathSegments = slug.path || [];
const path = pathSegments.join('/');
const { search } = new URL(request.url);
const targetUrl = `${MUAPI_BASE}/app/${path}${search}`;
const headers = cleanHeaders(request);
const apiKey = getApiKey(request);
if (apiKey) headers.set('x-api-key', apiKey);
try {
const body = await request.arrayBuffer();
const response = await fetch(targetUrl, {
method: 'PUT',
headers,
body
});
const data = await response.json();
return NextResponse.json(data, { status: response.status });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View file

@ -0,0 +1,44 @@
import { NextResponse } from 'next/server';
export async function POST(request) {
try {
const formData = await request.formData();
// Extract the original S3 target URL we injected earlier
const targetUrl = formData.get('x-proxy-target-url');
if (!targetUrl) {
return NextResponse.json({ error: 'Missing proxy target URL' }, { status: 400 });
}
// Reconstruct the FormData for S3 (excluding our internal proxy marker)
const s3FormData = new FormData();
// S3 is very sensitive to field ordering. We must ensure 'file' is likely last
// or at least that all signature fields come before what S3 expects.
// The original library code appends 'file' last, so iterating should preserve that.
for (const [key, value] of formData.entries()) {
if (key !== 'x-proxy-target-url') {
s3FormData.append(key, value);
}
}
// Perform the server-to-server POST to S3
// This bypasses browser CORS/Preflight security entirely
const s3Response = await fetch(targetUrl, {
method: 'POST',
body: s3FormData,
});
if (s3Response.ok || s3Response.status === 204) {
return new Response(null, { status: 204 });
} else {
const errorText = await s3Response.text();
console.error('S3 Proxy Error:', errorText);
return new Response(errorText, { status: s3Response.status });
}
} catch (error) {
console.error('Upload Proxy Exception:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View file

@ -0,0 +1,137 @@
import { NextResponse } from 'next/server';
const MUAPI_BASE = 'https://api.muapi.ai';
function getApiKey(request) {
// Priority 1: Direct x-api-key header
const headerKey = request.headers.get('x-api-key');
if (headerKey) return headerKey;
// Priority 2: muapi_key cookie (used by the fixed builder library)
const cookieKey = request.cookies.get('muapi_key')?.value;
return cookieKey;
}
function cleanHeaders(request) {
const headers = new Headers(request.headers);
headers.delete('host');
headers.delete('connection');
headers.delete('cookie'); // CRITICAL: Stop forwarding browser cookies to MuAPI to avoid auth conflicts
return headers;
}
export async function GET(request, { params }) {
const slug = await params;
const pathSegments = slug.path || [];
const path = pathSegments.join('/');
const { search } = new URL(request.url);
const targetUrl = `${MUAPI_BASE}/workflow/${path}${search}`;
const headers = cleanHeaders(request);
const apiKey = getApiKey(request);
console.log(`[proxy GET] ${targetUrl} | apiKey: ${apiKey ? apiKey.slice(0,8)+'...' : 'MISSING'}`);
if (apiKey) headers.set('x-api-key', apiKey);
try {
const response = await fetch(targetUrl, {
headers,
method: 'GET',
});
const data = await response.json();
if (path.includes('get-workflow-def')) {
console.log(`[proxy GET] get-workflow-def response: is_owner=${data?.is_owner}, workflow_id=${data?.workflow_id}`);
}
return NextResponse.json(data, { status: response.status });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export async function POST(request, { params }) {
const slug = await params;
const pathSegments = slug.path || [];
const path = pathSegments.join('/');
const { search } = new URL(request.url);
const targetUrl = `${MUAPI_BASE}/workflow/${path}${search}`;
const headers = cleanHeaders(request);
const apiKey = getApiKey(request);
console.log(`[proxy POST] ${targetUrl} | apiKey: ${apiKey ? apiKey.slice(0,8)+'...' : 'MISSING'} | cookie: ${request.cookies.get('muapi_key')?.value?.slice(0,8) || 'NONE'} | header: ${request.headers.get('x-api-key')?.slice(0,8) || 'NONE'}`);
if (apiKey) headers.set('x-api-key', apiKey);
try {
const body = await request.arrayBuffer();
// Decode body to see what workflow_id is being sent
try {
const parsed = JSON.parse(Buffer.from(body).toString('utf-8'));
console.log(`[proxy POST] body: workflow_id=${parsed.workflow_id}, source_workflow_id=${parsed.source_workflow_id}, name=${parsed.name}`);
} catch(e) { /* ignore decode errors */ }
const response = await fetch(targetUrl, {
method: 'POST',
headers,
body
});
const data = await response.json();
console.log(`[proxy POST] response: status=${response.status}`, JSON.stringify(data).slice(0, 200));
return NextResponse.json(data, { status: response.status });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export async function DELETE(request, { params }) {
const slug = await params;
const pathSegments = slug.path || [];
const path = pathSegments.join('/');
const { search } = new URL(request.url);
const targetUrl = `${MUAPI_BASE}/workflow/${path}${search}`;
const headers = cleanHeaders(request);
const apiKey = getApiKey(request);
if (apiKey) headers.set('x-api-key', apiKey);
try {
const response = await fetch(targetUrl, {
method: 'DELETE',
headers
});
const data = await response.json();
return NextResponse.json(data, { status: response.status });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export async function PUT(request, { params }) {
const slug = await params;
const pathSegments = slug.path || [];
const path = pathSegments.join('/');
const { search } = new URL(request.url);
const targetUrl = `${MUAPI_BASE}/workflow/${path}${search}`;
const headers = cleanHeaders(request);
const apiKey = getApiKey(request);
if (apiKey) headers.set('x-api-key', apiKey);
try {
const body = await request.arrayBuffer();
const response = await fetch(targetUrl, {
method: 'PUT',
headers,
body
});
const data = await response.json();
return NextResponse.json(data, { status: response.status });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View file

@ -2,21 +2,32 @@
@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: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
-webkit-font-smoothing: antialiased;
}
:root {
--color-primary: #d9ff00;
--bg-app: #050505;
--bg-app: #030303;
--bg-panel: #0a0a0a;
--bg-card: #111111;
--border-color: rgba(255,255,255,0.08);
--border-radius-xl: 1rem;
--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; }
@ -24,7 +35,7 @@ body {
.custom-scrollbar::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 2px; }
@keyframes fade-in-up {
from { opacity: 0; transform: translateY(16px); }
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fade-in-up { animation: fade-in-up 0.4s ease forwards; }
.animate-fade-in-up { animation: fade-in-up 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards; }

View file

@ -1,14 +1,20 @@
import './globals.css';
import { Inter } from "next/font/google";
const inter = Inter({
variable: "--font-inter",
subsets: ["latin"],
});
export const metadata = {
title: 'Open Higgsfield AI — Free AI Image & Video Studio',
title: 'Open Generative AI — Free AI Image & Video Studio',
description: 'Generate AI images and videos using 200+ models — Flux, Midjourney, Kling, Veo, Seedance and more. Free open-source alternative to Higgsfield AI.',
};
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>{children}</body>
<body className={inter.variable}>{children}</body>
</html>
);
}

View file

@ -1,7 +1,7 @@
import StandaloneShell from '@/components/StandaloneShell';
export const metadata = {
title: 'Studio — Open Higgsfield AI',
title: 'Studio — Open Generative AI',
};
export default function StudioPage() {

View file

@ -0,0 +1,9 @@
import StandaloneShell from '@/components/StandaloneShell';
export const metadata = {
title: 'Workflow — Open Generative AI',
};
export default function WorkflowTabPage() {
return <StandaloneShell />;
}

View file

@ -0,0 +1,9 @@
import StandaloneShell from '@/components/StandaloneShell';
export const metadata = {
title: 'Workflow — Open Generative AI',
};
export default function WorkflowPage() {
return <StandaloneShell />;
}

7
build/installer.nsh Normal file
View file

@ -0,0 +1,7 @@
!macro customInit
; Override default installation directory to be directly in LocalAppData
StrCpy $INSTDIR "$LOCALAPPDATA\Open Generative AI"
; Forcefully kill the app if it's running to prevent installation failure
nsExec::ExecToStack 'taskkill /F /IM "Open Generative AI.exe"'
!macroend

View file

@ -0,0 +1,7 @@
abi <abi/4.0>,
include <tunables/global>
profile open-generative-ai /opt/Open\ Generative\ AI/open-generative-ai flags=(unconfined) {
userns,
include if exists <local/open-generative-ai>
}

View file

@ -14,48 +14,50 @@ export default function ApiKeyModal({ onSave }) {
};
return (
<div className="min-h-screen bg-[#050505] flex items-center justify-center px-4">
<div className="w-full max-w-md bg-[#0a0a0a] border border-white/10 rounded-3xl p-8">
<div className="flex flex-col items-center text-center mb-8">
<div className="w-16 h-16 bg-[#d9ff00]/10 rounded-2xl flex items-center justify-center border border-[#d9ff00]/20 mb-6">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#d9ff00" strokeWidth="1.5">
<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"/>
<div className="min-h-screen bg-[#030303] flex items-center justify-center px-4 font-inter">
<div className="w-full max-w-sm bg-[#0a0a0a]/40 backdrop-blur-xl border border-white/10 rounded-xl p-10 shadow-2xl">
<div className="flex flex-col items-center text-center mb-10">
<div className="w-14 h-14 bg-[#d9ff00]/5 rounded-2xl flex items-center justify-center border border-[#d9ff00]/10 mb-6 group hover:border-[#d9ff00]/30 transition-colors">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#d9ff00" strokeWidth="1.5" className="group-hover:scale-110 transition-transform">
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L12 17.25l-4.5-4.5L15.5 7.5z" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
<h1 className="text-2xl font-black text-white uppercase tracking-wider mb-2">
Open Higgsfield AI
<h1 className="text-xl font-bold text-white tracking-tight mb-2">
Open Generative AI
</h1>
<p className="text-white/40 text-sm">
Enter your <a href="https://muapi.ai" target="_blank" rel="noreferrer" className="text-[#d9ff00] hover:underline">Muapi.ai</a> API key to start generating
<p className="text-white/40 text-[13px] leading-relaxed px-4">
Enter your <a href="https://muapi.ai/access-keys" target="_blank" rel="noreferrer" className="text-[#d9ff00] hover:text-[#e5ff33] transition-colors">Muapi.ai</a> API key to start creating
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-bold text-white/40 uppercase tracking-widest mb-2">
Muapi API Key
<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="Enter your API key..."
className="w-full bg-black/40 border border-white/5 rounded-xl px-4 py-3 text-white placeholder:text-white/20 focus:outline-none focus:border-[#d9ff00]/40 transition-colors"
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-[#d9ff00]/30 focus:bg-white/[0.07] transition-all"
suppressHydrationWarning
/>
{error && <p className="mt-1 text-red-400 text-xs">{error}</p>}
{error && <p className="mt-2 text-red-500/80 text-[11px] font-medium ml-1">{error}</p>}
</div>
<button
type="submit"
className="w-full bg-[#d9ff00] text-black font-black py-3 rounded-xl hover:opacity-90 transition-opacity"
className="w-full bg-[#d9ff00] text-black font-medium py-2.5 rounded-md hover:bg-[#e5ff33] hover:scale-[1.02] active:scale-[0.98] transition-all shadow-lg shadow-[#d9ff00]/5"
suppressHydrationWarning
>
Launch Studio
Get Started
</button>
<p className="text-center text-xs text-white/30">
Don&apos;t have a key?{' '}
<a href="https://muapi.ai" target="_blank" rel="noreferrer" className="text-[#d9ff00] hover:underline">
Get one free at Muapi.ai
<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-[#d9ff00] transition-colors font-medium">
Get one free
</a>
</p>
</form>

View file

@ -1,7 +1,9 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { ImageStudio, VideoStudio, LipSyncStudio, CinemaStudio } from 'studio';
import { useParams, useRouter } from 'next/navigation';
import { ImageStudio, VideoStudio, LipSyncStudio, CinemaStudio, MarketingStudio, WorkflowStudio, AgentStudio, AppsStudio, getUserBalance } from 'studio';
import axios from 'axios';
import ApiKeyModal from './ApiKeyModal';
const TABS = [
@ -9,96 +11,338 @@ const TABS = [
{ id: 'video', label: 'Video Studio' },
{ id: 'lipsync', label: 'Lip Sync' },
{ id: 'cinema', label: 'Cinema Studio' },
{ id: 'marketing', label: 'Marketing Studio' },
{ id: 'workflows', label: 'Workflows' },
{ id: 'agents', label: 'Agents' },
{ id: 'apps', label: 'Explore Apps' },
];
const STORAGE_KEY = 'muapi_key';
export default function StandaloneShell() {
const params = useParams();
const router = useRouter();
const slug = params?.slug || [];
const idFromParams = params?.id;
const tabFromParams = params?.tab;
// Helper to extract workflow details precisely from either route structure
const getWorkflowInfo = useCallback(() => {
if (idFromParams) {
return { id: idFromParams, tab: tabFromParams || null };
}
const wfIndex = slug.findIndex(s => s === 'workflows' || s === 'workflow');
if (wfIndex === -1) return { id: null, tab: null };
return {
id: slug[wfIndex + 1] || null,
tab: slug[wfIndex + 2] || null
};
}, [slug, idFromParams, tabFromParams]);
const { id: urlWorkflowId } = getWorkflowInfo();
// Initialize activeTab from URL slug/params or default to 'image'
const getInitialTab = () => {
if (idFromParams || slug.includes('workflow')) return 'workflows';
if (slug.includes('agents')) return 'agents';
if (slug.includes('apps')) return 'apps';
const firstSegment = slug[0];
if (firstSegment && TABS.find(t => t.id === firstSegment)) return firstSegment;
return 'image';
};
const [apiKey, setApiKey] = useState(null);
const [activeTab, setActiveTab] = useState('image');
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);
// Drag and Drop State
const [isDragging, setIsDragging] = useState(false);
const [droppedFiles, setDroppedFiles] = useState(null);
// Sync tab with URL if user navigates manually or via browser back/forward
useEffect(() => {
const info = getWorkflowInfo();
if (info.id) {
setActiveTab('workflows');
} else if (slug.includes('agents')) {
setActiveTab('agents');
} else if (slug.includes('apps')) {
setActiveTab('apps');
} else {
const firstSegment = slug[0];
if (firstSegment && TABS.find(t => t.id === firstSegment)) {
setActiveTab(firstSegment);
}
}
}, [slug, getWorkflowInfo]);
const handleTabChange = (tabId) => {
setActiveTab(tabId);
router.push(`/studio/${tabId}`);
};
// Auto-hide header when inside a specific workflow view
useEffect(() => {
const isEditingWorkflow = (activeTab === 'workflows' || !!idFromParams) && urlWorkflowId;
if (isEditingWorkflow) {
setIsHeaderVisible(false);
} else {
setIsHeaderVisible(true);
}
}, [activeTab, urlWorkflowId, idFromParams]);
// Global builder CSS cleanup when switching away from Workflows tab
useEffect(() => {
const fromBuilder = sessionStorage.getItem("fromWorkflowBuilder");
if (fromBuilder && activeTab !== 'workflows') {
sessionStorage.removeItem("fromWorkflowBuilder");
window.location.reload();
}
}, [activeTab]);
const fetchBalance = useCallback(async (key) => {
try {
const data = await getUserBalance(key);
setBalance(data.balance);
} catch (err) {
console.error('Balance fetch failed:', err);
}
}, []);
useEffect(() => {
setHasMounted(true);
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) setApiKey(stored);
}, []);
if (stored) {
setApiKey(stored);
fetchBalance(stored);
// Sync cookie immediately on mount to establish identity for background requests
document.cookie = `muapi_key=${stored}; path=/; max-age=31536000; SameSite=Lax`;
}
}, [fetchBalance]);
const handleKeySave = useCallback((key) => {
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-[#d9ff00] text-3xl"></div>
</div>
);
if (!apiKey) {
return <ApiKeyModal onSave={handleKeySave} />;
}
return (
<div className="h-screen bg-[#050505] flex flex-col overflow-hidden">
{/* Header */}
<header className="flex-shrink-0 flex items-center justify-between px-4 pt-4 pb-0 border-b border-white/5">
<div className="flex items-center gap-3">
<span className="text-white font-black text-lg tracking-wider uppercase">
Open Higgsfield AI
</span>
<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-[#d9ff00]/10 backdrop-blur-md border-4 border-dashed border-[#d9ff00]/50 flex items-center justify-center pointer-events-none transition-all duration-300">
<div className="bg-[#0a0a0a] p-8 rounded-3xl border border-white/10 shadow-2xl flex flex-col items-center gap-4 scale-110 animate-pulse">
<div className="w-20 h-20 bg-[#d9ff00] rounded-2xl flex items-center justify-center">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="black" strokeWidth="2.5">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12"/>
</svg>
</div>
<div className="flex flex-col items-center">
<span className="text-xl font-bold text-white">Drop your media here</span>
<span className="text-sm text-white/40">Images, videos, or audio files</span>
</div>
</div>
</div>
)}
{/* Header */}
{isHeaderVisible && (
<header className="flex-shrink-0 h-14 border-b border-white/[0.03] flex items-center justify-between px-6 bg-black/20 backdrop-blur-md z-40">
{/* Left: Logo */}
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-white rounded-lg flex items-center justify-center">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="black" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
</svg>
</div>
<span className="text-sm font-bold tracking-tight hidden sm:block">OpenGenerativeAI</span>
</div>
{/* Center: Navigation */}
<nav className="absolute left-1/2 -translate-x-1/2 flex items-center gap-6">
{TABS.map((tab) => (
<button
key={tab.id}
onClick={() => handleTabChange(tab.id)}
className={`relative py-4 text-[13px] font-medium transition-all whitespace-nowrap px-1 ${
activeTab === tab.id
? 'text-[#d9ff00]'
: 'text-white/50 hover:text-white'
}`}
>
{tab.label}
{activeTab === tab.id && (
<div className="absolute bottom-0 left-0 right-0 h-[2px] bg-[#d9ff00] rounded-full" />
)}
</button>
))}
</nav>
{/* Right: Actions */}
<div className="flex items-center gap-4">
<div className="flex items-center gap-3 bg-white/5 px-3 py-1.5 rounded-full border border-white/5 transition-colors">
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
<div className="flex flex-col">
<span className="text-xs font-bold text-white/90">
${balance !== null ? `${balance}` : '---'}
</span>
</div>
</div>
{/* Tabs */}
<nav className="flex items-center gap-1">
{TABS.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 text-sm font-medium rounded-t-lg transition-colors ${
activeTab === tab.id
? 'bg-[#d9ff00] text-black'
: 'text-white/50 hover:text-white'
}`}
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"
>
{tab.label}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
<span>Settings</span>
</button>
))}
</nav>
{/* Settings */}
<button
onClick={() => setShowSettings(true)}
className="text-white/40 hover:text-white text-sm transition-colors"
>
Settings
</button>
</header>
</div>
</header>
)}
{/* Studio Content */}
<div className="flex-1">
{activeTab === 'image' && <ImageStudio apiKey={apiKey} />}
{activeTab === 'video' && <VideoStudio apiKey={apiKey} />}
{activeTab === 'lipsync' && <LipSyncStudio apiKey={apiKey} />}
<div className="flex-1 min-h-0 relative overflow-hidden">
{activeTab === 'image' && <ImageStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} />}
{activeTab === 'video' && <VideoStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} />}
{activeTab === 'lipsync' && <LipSyncStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} />}
{activeTab === 'cinema' && <CinemaStudio apiKey={apiKey} />}
{activeTab === 'marketing' && <MarketingStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} />}
{activeTab === 'workflows' && <WorkflowStudio apiKey={apiKey} isHeaderVisible={isHeaderVisible} onToggleHeader={setIsHeaderVisible} />}
{activeTab === 'agents' && <AgentStudio apiKey={apiKey} isHeaderVisible={isHeaderVisible} onToggleHeader={setIsHeaderVisible} />}
{activeTab === 'apps' && <AppsStudio apiKey={apiKey} />}
</div>
{/* Settings Modal */}
{showSettings && (
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50">
<div className="bg-[#111] border border-white/10 rounded-2xl p-8 w-full max-w-md">
<h2 className="text-white font-bold text-xl mb-6">Settings</h2>
<p className="text-white/50 text-sm mb-4">
Current API key: <span className="text-white/80 font-mono">{apiKey.slice(0, 8)}</span>
<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 py-2 rounded-lg bg-red-500/20 text-red-400 hover:bg-red-500/30 text-sm transition-colors"
className="flex-1 h-10 rounded-md bg-red-500/10 text-red-400 hover:bg-red-500/20 text-xs font-semibold transition-all"
>
Change API Key
Change Key
</button>
<button
onClick={() => setShowSettings(false)}
className="flex-1 py-2 rounded-lg bg-white/5 text-white hover:bg-white/10 text-sm transition-colors"
className="flex-1 h-10 rounded-md bg-white/5 text-white/80 hover:bg-white/10 text-xs font-semibold transition-all border border-white/5"
>
Close
</button>

9
docker-compose.yml Normal file
View file

@ -0,0 +1,9 @@
services:
open-generative-ai:
build: .
container_name: open-generative-ai
ports:
- "3001:3000"
environment:
- NODE_ENV=production
restart: unless-stopped

View file

@ -0,0 +1,526 @@
const { ipcMain, app, BrowserWindow } = require('electron');
const path = require('path');
const fs = require('fs');
const https = require('https');
const http = require('http');
const { spawn, execFile } = require('child_process');
const os = require('os');
// ─── Paths ────────────────────────────────────────────────────────────────────
const DATA_DIR = path.join(app.getPath('userData'), 'local-ai');
const BIN_DIR = path.join(DATA_DIR, 'bin');
const MODELS_DIR = path.join(DATA_DIR, 'models');
const TMP_DIR = path.join(DATA_DIR, 'tmp');
for (const dir of [BIN_DIR, MODELS_DIR, TMP_DIR]) {
fs.mkdirSync(dir, { recursive: true });
}
const BINARY_NAME = process.platform === 'win32' ? 'sd-cli.exe' : 'sd-cli';
const BINARY_PATH = path.join(BIN_DIR, BINARY_NAME);
// ─── State ────────────────────────────────────────────────────────────────────
let activeProcess = null;
const activeDownloads = new Map(); // modelId → request object
// ─── GitHub release asset matcher per platform ───────────────────────────────
// Asset names look like: sd-master-44cca3d-bin-Darwin-macOS-15.7.4-arm64.zip
// We pick the best match in priority order so a single release that only
// ships e.g. avx512 still resolves cleanly.
function pickBinaryAsset(zipNames) {
const { platform, arch } = process;
// The "cudart" zip in recent leejet releases is just the CUDA runtime DLLs,
// not an sd-cli build, so it must never satisfy the Windows match.
const isSdCliZip = (n) => n.startsWith('sd-master-') || n.includes('-bin-');
const candidates = zipNames.filter(isSdCliZip);
if (platform === 'darwin') {
// leejet only publishes arm64 macOS builds. Mac Intel must use the
// hosted API instead — caller maps the empty result to a clear error.
if (arch !== 'arm64') return null;
return candidates.find(n => n.includes('Darwin') && n.includes('arm64')) || null;
}
if (platform === 'win32') {
// Priority: avx2 > avx > avx512 > noavx > cuda12. cuda needs the
// separate cudart runtime so we only fall back to it if nothing else.
const winCandidates = candidates.filter(n => /win-(avx2?|avx512|noavx|cuda12|cu12)-x64/.test(n));
const order = ['win-avx2-x64', 'win-avx-x64', 'win-avx512-x64', 'win-noavx-x64', 'win-cuda12-x64', 'win-cu12-x64'];
for (const tag of order) {
const hit = winCandidates.find(n => n.includes(tag));
if (hit) return hit;
}
return null;
}
// Linux: prefer plain x86_64, then vulkan, then rocm.
const linuxCandidates = candidates.filter(n => n.includes('Linux') && n.includes('x86_64'));
const plain = linuxCandidates.find(n => !n.includes('rocm') && !n.includes('vulkan'));
return plain
|| linuxCandidates.find(n => n.includes('vulkan'))
|| linuxCandidates.find(n => n.includes('rocm'))
|| null;
}
function fetchJson(url) {
return new Promise((resolve, reject) => {
https.get(url, { headers: { 'User-Agent': 'open-generative-ai' } }, (res) => {
if (res.statusCode !== 200) {
res.resume();
reject(new Error(`HTTP ${res.statusCode} from ${url}`));
return;
}
let body = '';
res.on('data', (d) => { body += d; });
res.on('end', () => {
try { resolve(JSON.parse(body)); } catch (e) { reject(e); }
});
res.on('error', reject);
}).on('error', reject);
});
}
// ─── Robust HTTPS download with redirect-following, range-resume, and retry ───
function downloadFile(url, destPath, onProgress) {
const tmp = destPath + '.part';
// Outer total so progress never goes backwards across retries/redirects
let knownTotal = 0;
const attempt = (requestUrl, redirectsLeft, retriesLeft) => new Promise((resolve, reject) => {
// Resume from however many bytes are already on disk
const alreadyDownloaded = fs.existsSync(tmp) ? fs.statSync(tmp).size : 0;
const parsed = new URL(requestUrl);
const mod = parsed.protocol === 'https:' ? https : http;
const reqHeaders = {
'User-Agent': 'Mozilla/5.0 (compatible; open-generative-ai/1.0)',
'Accept': '*/*',
'Connection': 'keep-alive',
};
if (alreadyDownloaded > 0) reqHeaders['Range'] = `bytes=${alreadyDownloaded}-`;
const req = mod.get({ hostname: parsed.hostname, path: parsed.pathname + parsed.search, headers: reqHeaders }, (res) => {
const { statusCode, headers } = res;
// Follow redirects
if ([301, 302, 303, 307, 308].includes(statusCode)) {
res.resume();
if (redirectsLeft <= 0) { reject(new Error('Too many redirects')); return; }
resolve(attempt(headers.location, redirectsLeft - 1, retriesLeft));
return;
}
// 206 Partial Content (range accepted) or 200 OK (server ignored Range)
if (statusCode !== 200 && statusCode !== 206) {
res.resume();
reject(new Error(`HTTP ${statusCode} from ${parsed.hostname}`));
return;
}
// content-length on a 206 is the remaining bytes; on 200 it's the full file
const chunkSize = parseInt(headers['content-length'] || '0', 10);
if (statusCode === 200) {
// Server ignored our Range header — restart the file
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
knownTotal = chunkSize;
} else {
// 206: total = already downloaded + remaining
knownTotal = alreadyDownloaded + chunkSize;
}
let received = alreadyDownloaded;
const out = fs.createWriteStream(tmp, { flags: statusCode === 206 ? 'a' : 'w' });
res.on('data', (chunk) => {
received += chunk.length;
if (knownTotal && onProgress) onProgress(received / knownTotal);
});
res.pipe(out);
out.on('finish', () => { fs.renameSync(tmp, destPath); resolve(); });
out.on('error', reject);
res.on('error', reject);
});
req.on('error', (err) => {
if (retriesLeft > 0) {
console.warn(`[download] ${err.message} — retrying in 3s (${retriesLeft} left)`);
setTimeout(() => resolve(attempt(requestUrl, redirectsLeft, retriesLeft - 1)), 3000);
} else {
reject(err);
}
});
req.setTimeout(60000, () => req.destroy(new Error('Request timed out')));
});
return attempt(url, 10, 5);
}
// ─── Extract zip on each platform ────────────────────────────────────────────
function extractZip(zipPath, destDir) {
return new Promise((resolve, reject) => {
let cmd, args;
if (process.platform === 'win32') {
cmd = 'powershell';
args = ['-NoProfile', '-Command', `Expand-Archive -Force -Path "${zipPath}" -DestinationPath "${destDir}"`];
} else {
cmd = 'unzip';
args = ['-o', zipPath, '-d', destDir];
}
execFile(cmd, args, (err) => {
if (err) reject(err);
else resolve();
});
});
}
// ─── Binary management ────────────────────────────────────────────────────────
// Recursively find a file by name under dir; returns full path or null.
function findFile(dir, name) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
const found = findFile(full, name);
if (found) return found;
} else if (entry.name === name) {
return full;
}
}
return null;
}
async function getBinaryStatus() {
const exists = fs.existsSync(BINARY_PATH);
return { exists, path: BINARY_PATH };
}
// Metal-enabled binaries hosted on our own release (macOS arm64 only).
// Other platforms fall back to the stock leejet release.
const CUSTOM_BINARIES = {
'darwin-arm64': 'https://github.com/Anil-matcha/Open-Generative-AI/releases/download/v1.0.3-binaries/sd-cli-metal-macos-arm64.zip',
};
async function downloadBinary(mainWindow) {
const send = (data) => mainWindow?.webContents.send('local-ai:download-progress', { id: '__binary__', ...data });
try {
send({ phase: 'fetching-release', progress: 0 });
const platformKey = `${process.platform}-${process.arch}`;
const customUrl = CUSTOM_BINARIES[platformKey];
let downloadUrl, zipName;
if (customUrl) {
downloadUrl = customUrl;
zipName = path.basename(customUrl);
} else {
// Walk recent releases until we find one that actually ships a
// build for this platform. leejet sometimes publishes a partial
// release (e.g. master-587 ships only Mac arm64 + Linux ROCm),
// so the very latest tag isn't always usable.
const releases = await fetchJson(
'https://api.github.com/repos/leejet/stable-diffusion.cpp/releases?per_page=15'
);
let chosen = null;
let lastSeen = [];
for (const release of releases) {
const zips = (release.assets || [])
.filter(a => a.name.endsWith('.zip'));
lastSeen = zips.map(a => a.name);
const pickedName = pickBinaryAsset(lastSeen);
if (pickedName) {
chosen = zips.find(a => a.name === pickedName);
break;
}
}
if (!chosen) {
if (process.platform === 'darwin' && process.arch !== 'arm64') {
throw new Error('Local inference on macOS only supports Apple Silicon (M1/M2/M3/M4). Mac Intel is not supported by stable-diffusion.cpp upstream.');
}
const available = lastSeen.join(', ') || '(none)';
throw new Error(`No binary found for ${process.platform}-${process.arch} in the last 15 releases. Latest release assets: ${available}`);
}
downloadUrl = chosen.browser_download_url;
zipName = chosen.name;
}
send({ phase: 'downloading', progress: 0 });
const zipPath = path.join(BIN_DIR, zipName);
await downloadFile(downloadUrl, zipPath, (p) => {
send({ phase: 'downloading', progress: p });
});
send({ phase: 'extracting', progress: 0.95 });
await extractZip(zipPath, BIN_DIR);
fs.unlinkSync(zipPath);
// The zip may extract into a subdirectory — find the binary wherever it landed
const foundBinary = findFile(BIN_DIR, BINARY_NAME);
if (!foundBinary) throw new Error(`Extracted archive but could not find "${BINARY_NAME}" inside ${BIN_DIR}`);
// Move it to the expected root location if it's nested
if (foundBinary !== BINARY_PATH) {
fs.renameSync(foundBinary, BINARY_PATH);
}
// Make binary executable on Unix
if (process.platform !== 'win32') {
fs.chmodSync(BINARY_PATH, 0o755);
// Also chmod the dylib so it can be loaded
const dylib = findFile(BIN_DIR, 'libstable-diffusion.dylib');
if (dylib) fs.chmodSync(dylib, 0o755);
}
// macOS: strip Gatekeeper quarantine so the downloaded binary can run
if (process.platform === 'darwin') {
await new Promise((res) => execFile('xattr', ['-cr', BIN_DIR], () => res()));
}
send({ phase: 'done', progress: 1 });
return { ok: true };
} catch (err) {
send({ phase: 'error', error: err.message });
throw err;
}
}
// ─── Model management ─────────────────────────────────────────────────────────
function getModelState(model) {
const filePath = path.join(MODELS_DIR, model.filename);
const partPath = filePath + '.part';
if (fs.existsSync(filePath)) return 'downloaded';
if (fs.existsSync(partPath)) return 'partial';
return 'not-downloaded';
}
function getAuxState(aux) {
const filePath = path.join(MODELS_DIR, aux.filename);
return fs.existsSync(filePath) ? 'downloaded' : 'not-downloaded';
}
async function listModels() {
const { LOCAL_MODEL_CATALOG, ZIMAGE_AUXILIARY } = require('./modelCatalog');
const auxStatus = {
llm: getAuxState(ZIMAGE_AUXILIARY.llm),
vae: getAuxState(ZIMAGE_AUXILIARY.vae),
};
return LOCAL_MODEL_CATALOG.map(m => ({
...m,
state: getModelState(m),
path: path.join(MODELS_DIR, m.filename),
...(m.requiresAuxiliary ? { auxiliaryStatus: auxStatus } : {}),
}));
}
async function downloadModel(modelId, mainWindow) {
const { LOCAL_MODEL_CATALOG } = require('./modelCatalog');
const model = LOCAL_MODEL_CATALOG.find(m => m.id === modelId);
if (!model) throw new Error(`Unknown model: ${modelId}`);
const destPath = path.join(MODELS_DIR, model.filename);
if (fs.existsSync(destPath)) return { ok: true, path: destPath };
const send = (data) => mainWindow?.webContents.send('local-ai:download-progress', { id: modelId, ...data });
send({ phase: 'downloading', progress: 0 });
await downloadFile(model.downloadUrl, destPath, (p) => {
send({ phase: 'downloading', progress: p });
});
send({ phase: 'done', progress: 1 });
return { ok: true, path: destPath };
}
async function downloadAuxiliary(auxKey, mainWindow) {
const { ZIMAGE_AUXILIARY } = require('./modelCatalog');
const aux = ZIMAGE_AUXILIARY[auxKey];
if (!aux) throw new Error(`Unknown auxiliary file: ${auxKey}`);
const destPath = path.join(MODELS_DIR, aux.filename);
if (fs.existsSync(destPath)) return { ok: true, path: destPath };
const id = aux.id;
const send = (data) => mainWindow?.webContents.send('local-ai:download-progress', { id, ...data });
send({ phase: 'downloading', progress: 0 });
await downloadFile(aux.downloadUrl, destPath, (p) => {
send({ phase: 'downloading', progress: p });
});
send({ phase: 'done', progress: 1 });
return { ok: true, path: destPath };
}
async function deleteModel(modelId) {
const { LOCAL_MODEL_CATALOG } = require('./modelCatalog');
const model = LOCAL_MODEL_CATALOG.find(m => m.id === modelId);
if (!model) throw new Error(`Unknown model: ${modelId}`);
const filePath = path.join(MODELS_DIR, model.filename);
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
const partPath = filePath + '.part';
if (fs.existsSync(partPath)) fs.unlinkSync(partPath);
return { ok: true };
}
// ─── Generation ───────────────────────────────────────────────────────────────
function arToDimensions(ar, modelType) {
const base = (modelType === 'sdxl' || modelType === 'z-image') ? 1024 : 512;
const map = {
'1:1': [base, base],
'16:9': [Math.round(base * 16 / 9 / 64) * 64, base],
'9:16': [base, Math.round(base * 16 / 9 / 64) * 64],
'4:3': [Math.round(base * 4 / 3 / 64) * 64, base],
'3:4': [base, Math.round(base * 4 / 3 / 64) * 64],
};
return map[ar] || [base, base];
}
async function generate(params, mainWindow) {
const { LOCAL_MODEL_CATALOG, ZIMAGE_AUXILIARY } = require('./modelCatalog');
const send = (data) => mainWindow?.webContents.send('local-ai:progress', data);
if (!fs.existsSync(BINARY_PATH)) throw new Error('sd.cpp binary not installed. Download it in Settings > Local Models.');
const model = LOCAL_MODEL_CATALOG.find(m => m.id === params.model);
if (!model) throw new Error(`Unknown local model: ${params.model}`);
const modelPath = path.join(MODELS_DIR, model.filename);
if (!fs.existsSync(modelPath)) throw new Error(`Model file not found. Download "${model.name}" in Settings > Local Models.`);
if (model.requiresAuxiliary) {
const llmPath = path.join(MODELS_DIR, ZIMAGE_AUXILIARY.llm.filename);
const vaePath = path.join(MODELS_DIR, ZIMAGE_AUXILIARY.vae.filename);
if (!fs.existsSync(llmPath)) throw new Error('Text encoder (Qwen3-4B) not downloaded. Go to Settings > Local Models and download all required files for Z-Image.');
if (!fs.existsSync(vaePath)) throw new Error('VAE (ae.safetensors) not downloaded. Go to Settings > Local Models and download all required files for Z-Image.');
}
const [width, height] = arToDimensions(params.aspect_ratio || '1:1', model.type);
const seed = params.seed && params.seed !== -1 ? params.seed : Math.floor(Math.random() * 2147483647);
const outPath = path.join(TMP_DIR, `gen-${Date.now()}.png`);
const steps = model.defaultSteps || params.steps || 20;
const cfgScale = model.defaultGuidance !== undefined ? model.defaultGuidance : (params.guidance_scale || 7.5);
const sampler = model.sampler || 'euler_a';
// z-image GGUFs are standalone diffusion transformers loaded via --diffusion-model.
// -m triggers full-model SD version detection which fails for these files (0 KV metadata).
const modelFlag = (model.type === 'z-image' || model.type === 'flux')
? '--diffusion-model'
: '-m';
const args = [
modelFlag, modelPath,
'-p', params.prompt || '',
'-o', outPath,
'--steps', String(steps),
'-H', String(height),
'-W', String(width),
'--cfg-scale', String(cfgScale),
'--seed', String(seed),
'--sampling-method', sampler,
'-v',
];
if (params.negative_prompt) {
args.push('-n', params.negative_prompt);
}
if (model.type === 'z-image') {
const llmPath = path.join(MODELS_DIR, ZIMAGE_AUXILIARY.llm.filename);
const vaePath = path.join(MODELS_DIR, ZIMAGE_AUXILIARY.vae.filename);
args.push('--llm', llmPath);
args.push('--vae', vaePath);
if (model.scheduler) args.push('--scheduler', model.scheduler);
} else if (model.type === 'sdxl') {
args.push('--sd-version', 'sdxl');
} else if (model.type === 'sd2') {
args.push('--sd-version', 'sd2');
} else if (model.type === 'flux') {
args.push('--flux');
}
return new Promise((resolve, reject) => {
send({ step: 0, totalSteps: params.steps || model.defaultSteps || 20, status: 'starting' });
console.log('[sd-cli] command:', BINARY_PATH, args.join(' '));
// DYLD_LIBRARY_PATH lets macOS find libstable-diffusion.dylib next to sd-cli
const spawnEnv = { ...process.env, DYLD_LIBRARY_PATH: BIN_DIR, LD_LIBRARY_PATH: BIN_DIR };
activeProcess = spawn(BINARY_PATH, args, { env: spawnEnv });
const stepRegex = /step\s+(\d+)\/(\d+)/i;
const outputLines = [];
const handleOutput = (data) => {
const line = data.toString();
outputLines.push(line.trimEnd());
const match = line.match(stepRegex);
if (match) {
const step = parseInt(match[1]);
const total = parseInt(match[2]);
send({ step, totalSteps: total, status: 'generating', progress: step / total });
}
};
activeProcess.stdout.on('data', handleOutput);
activeProcess.stderr.on('data', handleOutput);
activeProcess.on('close', (code) => {
activeProcess = null;
const allOutput = outputLines.filter(l => l.trim()).join('\n');
console.error('[sd-cli] full output:\n' + allOutput);
if (code !== 0) {
const tail = outputLines.filter(l => l.trim()).slice(-20).join('\n');
reject(new Error(`sd-cli exited (code ${code}):\n${tail}`));
return;
}
if (!fs.existsSync(outPath)) {
reject(new Error('sd.cpp finished but no output image found'));
return;
}
try {
const imgBuffer = fs.readFileSync(outPath);
const dataUrl = `data:image/png;base64,${imgBuffer.toString('base64')}`;
fs.unlinkSync(outPath);
send({ step: 1, totalSteps: 1, status: 'done', progress: 1 });
resolve({ url: dataUrl, seed });
} catch (err) {
reject(err);
}
});
activeProcess.on('error', (err) => {
activeProcess = null;
reject(err);
});
});
}
function cancelGeneration() {
if (activeProcess) {
activeProcess.kill('SIGTERM');
activeProcess = null;
}
return { ok: true };
}
// ─── IPC Registration ─────────────────────────────────────────────────────────
function getMainWindow() {
return BrowserWindow.getAllWindows()[0] || null;
}
function register() {
ipcMain.handle('local-ai:binary-status', () => getBinaryStatus());
ipcMain.handle('local-ai:download-binary', () => downloadBinary(getMainWindow()));
ipcMain.handle('local-ai:list-models', () => listModels());
ipcMain.handle('local-ai:download-model', (_, modelId) => downloadModel(modelId, getMainWindow()));
ipcMain.handle('local-ai:download-auxiliary', (_, auxKey) => downloadAuxiliary(auxKey, getMainWindow()));
ipcMain.handle('local-ai:delete-model', (_, modelId) => deleteModel(modelId));
ipcMain.handle('local-ai:generate', (_, params) => generate(params, getMainWindow()));
ipcMain.handle('local-ai:cancel-generation', () => cancelGeneration());
}
module.exports = { register };

View file

@ -0,0 +1,131 @@
// Curated local model catalog for sd.cpp
// All models must be publicly available (no auth required)
// Shared auxiliary files needed by Z-Image type models
const ZIMAGE_AUXILIARY = {
llm: {
id: '__llm__',
filename: 'Qwen3-4B-Instruct-2507-UD-Q4_K_XL.gguf',
displayName: 'Qwen3-4B Text Encoder',
sizeGB: 2.4,
downloadUrl: 'https://huggingface.co/unsloth/Qwen3-4B-Instruct-2507-GGUF/resolve/main/Qwen3-4B-Instruct-2507-UD-Q4_K_XL.gguf',
},
vae: {
id: '__vae__',
filename: 'ae.safetensors',
displayName: 'FLUX VAE',
sizeGB: 0.33,
downloadUrl: 'https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/vae/ae.safetensors',
},
};
const LOCAL_MODEL_CATALOG = [
// ── Z-Image (Tongyi-MAI) — native sd.cpp GGUF support ──────────────────
// Requires auxiliary files: Qwen3-4B LLM text encoder + FLUX VAE
{
id: 'z-image-turbo',
name: 'Z-Image Turbo',
description: 'WaveSpeed\'s featured local model — ultra-fast 8-step generation. No API key needed. Requires text encoder + VAE (~2.7 GB extra).',
type: 'z-image',
filename: 'z_image_turbo-Q4_K.gguf',
sizeGB: 2.5,
downloadUrl: 'https://huggingface.co/leejet/Z-Image-Turbo-GGUF/resolve/main/z_image_turbo-Q4_K.gguf',
aspectRatios: ['1:1', '4:3', '3:4', '16:9', '9:16'],
defaultWidth: 1024,
defaultHeight: 1024,
defaultSteps: 8,
defaultGuidance: 1.0,
sampler: 'euler',
scheduler: 'simple',
tags: ['turbo', 'fast', 'local', 'featured'],
featured: true,
requiresAuxiliary: true,
},
{
id: 'z-image-base',
name: 'Z-Image Base',
description: 'Full-quality model from Tongyi-MAI — higher detail, 50-step generation. Requires text encoder + VAE (~2.7 GB extra).',
type: 'z-image',
filename: 'Z-Image-Q4_K_M.gguf',
sizeGB: 3.5,
downloadUrl: 'https://huggingface.co/unsloth/Z-Image-GGUF/resolve/main/Z-Image-Q4_K_M.gguf',
aspectRatios: ['1:1', '4:3', '3:4', '16:9', '9:16'],
defaultWidth: 1024,
defaultHeight: 1024,
defaultSteps: 50,
defaultGuidance: 7.5,
sampler: 'euler',
scheduler: 'simple',
tags: ['high-quality', 'local', 'detailed'],
featured: true,
requiresAuxiliary: true,
},
// ── Classic SD 1.5 models ───────────────────────────────────────────────
{
id: 'dreamshaper-8',
name: 'Dreamshaper 8',
description: 'Versatile SD 1.5 model — great for portraits, landscapes, and artistic styles.',
type: 'sd1',
filename: 'DreamShaper_8_pruned.safetensors',
sizeGB: 2.1,
downloadUrl: 'https://huggingface.co/Lykon/DreamShaper/resolve/main/DreamShaper_8_pruned.safetensors',
aspectRatios: ['1:1', '4:3', '3:4', '16:9', '9:16'],
defaultWidth: 512,
defaultHeight: 512,
defaultSteps: 20,
defaultGuidance: 7.5,
sampler: 'euler_a',
tags: ['photorealistic', 'artistic', 'versatile'],
},
{
id: 'realistic-vision-v51',
name: 'Realistic Vision v5.1',
description: 'Highly photorealistic people and scenes, based on SD 1.5.',
type: 'sd1',
filename: 'realisticVisionV51_v51VAE.safetensors',
sizeGB: 2.1,
downloadUrl: 'https://huggingface.co/SG161222/Realistic_Vision_V5.1_noVAE/resolve/main/Realistic_Vision_V5.1_fp16-no-ema.safetensors',
aspectRatios: ['1:1', '4:3', '3:4', '16:9', '9:16'],
defaultWidth: 512,
defaultHeight: 768,
defaultSteps: 25,
defaultGuidance: 7,
sampler: 'euler_a',
tags: ['photorealistic', 'portraits', 'people'],
},
{
id: 'anything-v5',
name: 'Anything v5',
description: 'High quality anime and illustration style image generation.',
type: 'sd1',
filename: 'Anything-v5.0-PRT.safetensors',
sizeGB: 2.1,
downloadUrl: 'https://huggingface.co/Yntec/AnythingV5/resolve/main/Anything-v5.0-PRT.safetensors',
aspectRatios: ['1:1', '4:3', '3:4', '16:9', '9:16'],
defaultWidth: 512,
defaultHeight: 768,
defaultSteps: 20,
defaultGuidance: 7,
sampler: 'euler_a',
tags: ['anime', 'illustration', 'artistic'],
},
// ── SDXL ───────────────────────────────────────────────────────────────
{
id: 'stable-diffusion-xl-base',
name: 'SDXL Base 1.0',
description: 'Official Stable Diffusion XL base model — higher resolution, excellent quality.',
type: 'sdxl',
filename: 'sd_xl_base_1.0.safetensors',
sizeGB: 6.9,
downloadUrl: 'https://huggingface.co/stabilityai/stable-diffusion-xl-base-1.0/resolve/main/sd_xl_base_1.0.safetensors',
aspectRatios: ['1:1', '4:3', '3:4', '16:9', '9:16'],
defaultWidth: 1024,
defaultHeight: 1024,
defaultSteps: 30,
defaultGuidance: 7.5,
sampler: 'dpmpp2m',
tags: ['sdxl', 'high-quality', 'versatile'],
},
];
module.exports = { LOCAL_MODEL_CATALOG, ZIMAGE_AUXILIARY };

View file

@ -0,0 +1,466 @@
// Wan2GP HTTP provider — alternate local engine alongside sd.cpp.
// User runs Wan2GP themselves (https://github.com/deepbeepmeep/Wan2GP) and
// points this app at its Gradio server. We never bundle Python or weights.
// Useful when sd.cpp can't run a model (e.g. video) or the user has a
// dedicated CUDA box and only wants this Mac as the UI.
const { ipcMain, app, BrowserWindow } = require('electron');
const path = require('path');
const fs = require('fs');
const http = require('http');
const https = require('https');
const DATA_DIR = path.join(app.getPath('userData'), 'local-ai');
const CONFIG_FILE = path.join(DATA_DIR, 'wan2gp.json');
fs.mkdirSync(DATA_DIR, { recursive: true });
// ─── Catalog ──────────────────────────────────────────────────────────────────
// `fn` is the *preferred* Gradio api_name Wan2GP exposes via /gradio_api/call/<fn>.
// Wan2GP builds rename these between versions (and Pinokio packages drop them
// entirely on some endpoints), so at probe time we pull /info from the server
// and remap each catalog entry's `fn` to whatever is actually registered. The
// `fnAliases` list lets us match newer/older variants; `family` is the final
// fallback for fuzzy matching. See resolveFnNames() below.
const WAN2GP_CATALOG = [
{
id: 'wan2gp:flux-dev',
name: 'Flux.1 Dev (Wan2GP)',
description: 'Image — FLUX.1 dev served by Wan2GP. 1024px output.',
type: 'image',
family: 'flux',
provider: 'wan2gp',
fn: 'flux',
fnAliases: ['flux_dev', 'flux_1_dev', 'flux1_dev', 'flux_image'],
aspectRatios: ['1:1', '4:3', '3:4', '16:9', '9:16'],
defaultSteps: 28,
defaultGuidance: 3.5,
tags: ['image', 'flux', 'remote'],
},
{
id: 'wan2gp:qwen-image',
name: 'Qwen Image (Wan2GP)',
description: 'Image — Qwen-Image text-to-image served by Wan2GP.',
type: 'image',
family: 'qwen',
provider: 'wan2gp',
fn: 'qwen_image',
fnAliases: ['qwen', 'qwen_t2i', 'qwen_image_t2i'],
aspectRatios: ['1:1', '4:3', '3:4', '16:9', '9:16'],
defaultSteps: 30,
defaultGuidance: 4.0,
tags: ['image', 'qwen', 'remote'],
},
{
id: 'wan2gp:wan22-t2v',
name: 'Wan 2.2 (Text-to-Video)',
description: 'Video — Wan 2.2 text-to-video. Slow on consumer GPUs.',
type: 'video',
family: 'wan',
provider: 'wan2gp',
fn: 'wan22_t2v',
fnAliases: ['wan_2_2_t2v', 'wan22_text2video', 'wan_t2v', 'wan2_2_t2v', 't2v'],
aspectRatios: ['16:9', '1:1', '9:16'],
defaultSteps: 25,
defaultGuidance: 5.0,
tags: ['video', 'wan', 'text-to-video'],
},
{
id: 'wan2gp:wan22-i2v',
name: 'Wan 2.2 (Image-to-Video)',
description: 'Video — Wan 2.2 image-to-video. Provide a start frame.',
type: 'video',
family: 'wan',
provider: 'wan2gp',
fn: 'wan22_i2v',
fnAliases: ['wan_2_2_i2v', 'wan22_image2video', 'wan_i2v', 'wan2_2_i2v', 'i2v'],
needsImage: true,
aspectRatios: ['16:9', '1:1', '9:16'],
defaultSteps: 25,
defaultGuidance: 5.0,
tags: ['video', 'wan', 'image-to-video'],
},
{
id: 'wan2gp:hunyuan-video',
name: 'Hunyuan Video (Wan2GP)',
description: 'Video — Hunyuan text-to-video via Wan2GP.',
type: 'video',
family: 'hunyuan',
provider: 'wan2gp',
fn: 'hunyuan_video',
fnAliases: ['hunyuan', 'hunyuan_t2v', 'hyvideo', 'hy_video'],
aspectRatios: ['16:9', '1:1', '9:16'],
defaultSteps: 30,
defaultGuidance: 6.0,
tags: ['video', 'hunyuan'],
},
{
id: 'wan2gp:ltx-video',
name: 'LTX Video (Wan2GP)',
description: 'Video — LTX text-to-video. Fastest video option in Wan2GP.',
type: 'video',
family: 'ltx',
provider: 'wan2gp',
fn: 'ltx_video',
fnAliases: ['ltx', 'ltx_t2v', 'ltxv', 'ltx_v', 'ltx_2', 'ltx2'],
aspectRatios: ['16:9', '1:1', '9:16'],
defaultSteps: 20,
defaultGuidance: 3.0,
tags: ['video', 'ltx', 'fast'],
},
];
function getModelById(id) { return WAN2GP_CATALOG.find(m => m.id === id) || null; }
// ─── Config ───────────────────────────────────────────────────────────────────
function readConfig() {
if (!fs.existsSync(CONFIG_FILE)) return { url: '' };
try { return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8')); }
catch { return { url: '' }; }
}
function writeConfig(cfg) { fs.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2)); }
function normalizeUrl(url) { return (url || '').trim().replace(/\/+$/, ''); }
// ─── State ────────────────────────────────────────────────────────────────────
let activeAbort = null;
// Map of uploaded source URL → { path, url, orig_name } so generate() can
// rehydrate the Gradio file descriptor when the renderer passes the URL back.
const uploadedFiles = new Map();
// Per-base cache of resolved api_names. Populated by probe(); consumed by
// listModels() and generate(). Without this we'd hit FnIndexInferError on
// every call because Wan2GP's api_name strings drift between releases.
// Shape: Map<baseUrl, { apiNames: string[], resolved: Map<modelId, string|null> }>
const fnResolutionCache = new Map();
// ─── HTTP helpers ─────────────────────────────────────────────────────────────
function httpJson(urlStr, { method = 'GET', body = null, timeoutMs = 5000 } = {}) {
return new Promise((resolve, reject) => {
const u = new URL(urlStr);
const mod = u.protocol === 'https:' ? https : http;
const headers = body ? { 'Content-Type': 'application/json' } : {};
const req = mod.request({
hostname: u.hostname, port: u.port, path: u.pathname + u.search,
method, headers,
}, (res) => {
let buf = '';
res.on('data', (d) => { buf += d; });
res.on('end', () => resolve({ status: res.statusCode, body: buf }));
res.on('error', reject);
});
req.on('error', reject);
req.setTimeout(timeoutMs, () => req.destroy(new Error('Request timed out')));
if (body) req.write(body);
req.end();
});
}
// Pull the list of registered api_names from Gradio's /info endpoint.
// Gradio v4 exposes { named_endpoints: { "/api_name": {...}, ... } }; older
// builds use the legacy /api or a flat dependencies array. We try /info first,
// fall back to /api, and finally to /config['dependencies']. Returns an array
// of bare api_name strings (no leading slash). Empty array means we couldn't
// discover any — the server is reachable but doesn't expose a name index.
async function fetchApiNames(base) {
const candidates = [`${base}/info`, `${base}/api`, `${base}/gradio_api/info`];
for (const candidate of candidates) {
try {
const res = await httpJson(candidate, { timeoutMs: 8000 });
if (res.status !== 200) continue;
const parsed = JSON.parse(res.body);
const named = parsed.named_endpoints || parsed.unnamed_endpoints || parsed;
if (named && typeof named === 'object') {
const keys = Object.keys(named).filter(k => k.startsWith('/'));
if (keys.length) return keys.map(k => k.replace(/^\/+/, ''));
}
} catch { /* try next */ }
}
// Legacy fallback — /config exposes dependencies[] with api_name fields.
try {
const res = await httpJson(`${base}/config`, { timeoutMs: 5000 });
if (res.status === 200) {
const cfg = JSON.parse(res.body);
const deps = Array.isArray(cfg.dependencies) ? cfg.dependencies : [];
return deps.map(d => d.api_name).filter(n => typeof n === 'string' && n && n !== 'false');
}
} catch { /* ignore */ }
return [];
}
// Map each catalog model to a real api_name on this server. Strategy:
// 1. exact match on `fn`
// 2. exact match on any `fnAliases` entry
// 3. fuzzy: api_name contains the model's `family` substring
// (e.g. catalog wants `ltx_video`; server registered `ltx_2_t2v` → match)
// Returns { resolved: Map<modelId, string|null>, apiNames: string[] }.
function resolveFnNames(apiNames) {
const set = new Set(apiNames);
const resolved = new Map();
for (const m of WAN2GP_CATALOG) {
let hit = null;
if (set.has(m.fn)) hit = m.fn;
if (!hit && Array.isArray(m.fnAliases)) {
for (const a of m.fnAliases) { if (set.has(a)) { hit = a; break; } }
}
if (!hit && m.family) {
// Prefer names that also match the type (image vs video keyword).
const typeHint = m.type === 'video' ? /(video|t2v|i2v|v2v)/i : /(image|t2i|txt2img)/i;
const fuzzy = apiNames.find(n => n.toLowerCase().includes(m.family) && typeHint.test(n))
|| apiNames.find(n => n.toLowerCase().includes(m.family));
if (fuzzy) hit = fuzzy;
}
resolved.set(m.id, hit);
}
return { resolved, apiNames };
}
async function probe(url) {
const base = normalizeUrl(url);
if (!base) return { ok: false, error: 'URL is empty' };
try {
const res = await httpJson(`${base}/config`, { timeoutMs: 5000 });
if (res.status !== 200) return { ok: false, error: `HTTP ${res.status} from /config — is this a Gradio server?` };
const cfg = JSON.parse(res.body);
const apiNames = await fetchApiNames(base);
const { resolved } = resolveFnNames(apiNames);
fnResolutionCache.set(base, { apiNames, resolved });
const matched = [...resolved.values()].filter(Boolean).length;
return {
ok: true,
version: cfg.version || 'unknown',
apiNames,
matchedModels: matched,
totalModels: WAN2GP_CATALOG.length,
};
} catch (e) {
return { ok: false, error: e.message };
}
}
// ─── Upload (Gradio v4 /upload) ───────────────────────────────────────────────
// Renderer hands us { name, type, bytes:Uint8Array }. We POST as multipart to
// <base>/upload?upload_id=<id>; Gradio replies with an array of server paths.
// We expose those as a stable HTTP URL the renderer can preview AND stash the
// raw path for generate() to feed back into Gradio's file descriptor.
async function uploadFile({ name, type, bytes }) {
const { url } = readConfig();
if (!url) throw new Error('Wan2GP server URL not set. Open Settings → Local Models to configure.');
const base = normalizeUrl(url);
if (!bytes || !bytes.length) throw new Error('Empty file payload');
const safeName = name || 'upload.bin';
const mime = type || 'application/octet-stream';
const blob = new Blob([new Uint8Array(bytes)], { type: mime });
const form = new FormData();
form.append('files', blob, safeName);
const uploadId = Math.random().toString(36).slice(2, 12);
const res = await fetch(`${base}/upload?upload_id=${uploadId}`, { method: 'POST', body: form });
if (!res.ok) throw new Error(`Wan2GP upload failed: HTTP ${res.status}`);
const paths = await res.json();
const path = Array.isArray(paths) ? paths[0] : paths;
if (!path || typeof path !== 'string') throw new Error('Wan2GP upload returned no path');
const fileUrl = `${base}/file=${path.replace(/^\/+/, '')}`;
uploadedFiles.set(fileUrl, { path, url: fileUrl, orig_name: safeName, mime_type: mime });
return { url: fileUrl, path };
}
async function listModels() {
const { url } = readConfig();
if (!url) return WAN2GP_CATALOG.map(m => ({ ...m, ready: false, unavailableReason: 'Wan2GP URL not set' }));
const base = normalizeUrl(url);
const probeRes = await probe(url); // populates fnResolutionCache
const cached = fnResolutionCache.get(base);
return WAN2GP_CATALOG.map(m => {
if (!probeRes.ok) return { ...m, ready: false, unavailableReason: probeRes.error };
const realFn = cached?.resolved.get(m.id) || null;
if (!realFn) {
return {
...m,
ready: false,
unavailableReason: `Wan2GP server has no api_name matching "${m.fn}". Check Wan2GP version or load this model in its UI.`,
};
}
return { ...m, ready: true, fn: realFn };
});
}
// ─── Generate ─────────────────────────────────────────────────────────────────
function arToDimensions(ar) {
const base = 1024;
const map = {
'1:1': [base, base],
'16:9': [Math.round(base * 16 / 9 / 64) * 64, base],
'9:16': [base, Math.round(base * 16 / 9 / 64) * 64],
'4:3': [Math.round(base * 4 / 3 / 64) * 64, base],
'3:4': [base, Math.round(base * 4 / 3 / 64) * 64],
};
return map[ar] || [base, base];
}
// Gradio v4 protocol: POST /gradio_api/call/<fn> → { event_id }
// GET /gradio_api/call/<fn>/<event_id> → SSE stream
async function gradioCall(base, fn, payload, onProgress, signal) {
const post = await fetch(`${base}/gradio_api/call/${fn}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal,
});
if (!post.ok) throw new Error(`Wan2GP POST /call/${fn} → HTTP ${post.status}`);
const { event_id } = await post.json();
if (!event_id) throw new Error('Wan2GP did not return an event_id');
const stream = await fetch(`${base}/gradio_api/call/${fn}/${event_id}`, { signal });
if (!stream.ok) throw new Error(`Wan2GP stream → HTTP ${stream.status}`);
const reader = stream.body.getReader();
const dec = new TextDecoder();
let buf = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
const blocks = buf.split('\n\n');
buf = blocks.pop();
for (const block of blocks) {
const eMatch = block.match(/event:\s*(\S+)/);
const dMatch = block.match(/data:\s*(.*)$/s);
if (!eMatch) continue;
const evt = eMatch[1];
const dataStr = dMatch ? dMatch[1].trim() : '';
if (evt === 'generating' || evt === 'process_generating') {
const m = dataStr.match(/"progress":\s*([\d.]+)/);
if (m && onProgress) onProgress(parseFloat(m[1]));
} else if (evt === 'complete' || evt === 'process_completes') {
try {
const parsed = JSON.parse(dataStr);
return Array.isArray(parsed) ? parsed : (parsed.data || parsed);
} catch {
throw new Error(`Wan2GP returned malformed completion: ${dataStr.slice(0, 200)}`);
}
} else if (evt === 'error' || evt === 'process_error') {
throw new Error(`Wan2GP error: ${dataStr.slice(0, 200)}`);
}
}
}
throw new Error('Wan2GP stream ended without a completion event');
}
function resolveOutputUrl(base, output) {
const first = Array.isArray(output) ? output[0] : output;
if (!first) return null;
if (typeof first === 'string') {
return first.startsWith('http') ? first : `${base}/file=${first.replace(/^\/+/, '')}`;
}
if (first.url) return first.url.startsWith('http') ? first.url : `${base}${first.url}`;
if (first.path) return `${base}/file=${first.path.replace(/^\/+/, '')}`;
return null;
}
async function generate(params, mainWindow) {
const { url } = readConfig();
if (!url) throw new Error('Wan2GP server URL not set. Open Settings → Local Models to configure.');
const base = normalizeUrl(url);
const model = getModelById(params.model);
if (!model) throw new Error(`Unknown Wan2GP model: ${params.model}`);
const send = (data) => mainWindow?.webContents.send('local-ai:progress', data);
send({ status: 'starting', progress: 0 });
const [width, height] = arToDimensions(params.aspect_ratio || '1:1');
const seed = params.seed && params.seed !== -1 ? params.seed : Math.floor(Math.random() * 2147483647);
const steps = params.steps ?? model.defaultSteps;
const guidance = params.guidance_scale ?? model.defaultGuidance;
// Image input → resolve to a Gradio file descriptor if we uploaded it.
let imageDescriptor = null;
if (params.image) {
const cached = uploadedFiles.get(params.image);
if (cached) {
imageDescriptor = { path: cached.path, url: cached.url, orig_name: cached.orig_name, mime_type: cached.mime_type, meta: { _type: 'gradio.FileData' } };
} else if (typeof params.image === 'string') {
imageDescriptor = params.image; // raw URL — Gradio fetches it
} else {
imageDescriptor = params.image;
}
}
if (model.needsImage && !imageDescriptor) {
throw new Error(`${model.name} requires a start-frame image — upload one first.`);
}
// Generic positional input — adjust upstream `fn` if signature differs.
const payload = {
data: [
params.prompt || '',
params.negative_prompt || '',
width, height, steps, guidance, seed,
imageDescriptor,
],
};
// Resolve to whatever api_name the server actually exposes. Falls back to
// the catalog default so a user with a current Wan2GP build still works
// even if /info isn't reachable.
let cached = fnResolutionCache.get(base);
if (!cached) { await probe(url); cached = fnResolutionCache.get(base); }
const realFn = cached?.resolved.get(model.id) || model.fn;
if (cached && cached.apiNames.length && !cached.resolved.get(model.id)) {
const sample = cached.apiNames.slice(0, 8).join(', ');
throw new Error(
`${model.name}: Wan2GP server doesn't expose an api_name matching "${model.fn}". ` +
`Available endpoints: ${sample}${cached.apiNames.length > 8 ? '…' : ''}. ` +
`Make sure the model is loaded in Wan2GP, or update Wan2GP to a build that registers this api_name.`
);
}
const ac = new AbortController();
activeAbort = ac;
try {
const result = await gradioCall(base, realFn, payload, (p) => {
send({ status: 'generating', progress: p });
}, ac.signal);
activeAbort = null;
const mediaUrl = resolveOutputUrl(base, result);
if (!mediaUrl) throw new Error(`Wan2GP returned unrecognized output: ${JSON.stringify(result).slice(0, 200)}`);
send({ status: 'done', progress: 1 });
return { url: mediaUrl, mediaType: model.type, seed };
} catch (e) {
activeAbort = null;
if (e.name === 'AbortError') throw new Error('Generation cancelled');
throw e;
}
}
function cancelGeneration() {
if (activeAbort) {
activeAbort.abort();
activeAbort = null;
}
return { ok: true };
}
// ─── IPC ──────────────────────────────────────────────────────────────────────
function getMainWindow() { return BrowserWindow.getAllWindows()[0] || null; }
function register() {
ipcMain.handle('wan2gp:get-config', () => readConfig());
ipcMain.handle('wan2gp:set-url', (_, url) => { writeConfig({ url: normalizeUrl(url) }); return { ok: true }; });
ipcMain.handle('wan2gp:probe', (_, url) => probe(url));
ipcMain.handle('wan2gp:list-models', () => listModels());
ipcMain.handle('wan2gp:generate', (_, params) => generate(params, getMainWindow()));
ipcMain.handle('wan2gp:cancel-generation', () => cancelGeneration());
ipcMain.handle('wan2gp:upload-file', (_, payload) => uploadFile(payload));
}
module.exports = { register, WAN2GP_CATALOG };

View file

@ -1,33 +1,49 @@
import { app, BrowserWindow, shell } from 'electron';
import { fileURLToPath } from 'url';
import path from 'path';
const { app, BrowserWindow, shell } = require('electron');
const path = require('path');
const { register: registerLocalInference } = require('./lib/localInference');
const { register: registerWan2gp } = require('./lib/wan2gpProvider');
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// 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: false, // Allow file:// origin to call external APIs
webSecurity: false,
contextIsolation: true,
nodeIntegration: false,
preload: path.join(__dirname, 'preload.js'),
},
titleBarStyle: 'hiddenInset',
...(isMac ? { titleBarStyle: 'hiddenInset' } : {}),
backgroundColor: '#0d0d0d',
show: false,
title: 'Open Higgsfield AI',
title: 'Open Generative AI',
});
const indexPath = path.join(__dirname, '../dist/index.html');
mainWindow.loadFile(indexPath);
mainWindow.loadFile(indexPath).catch((err) => {
console.error('Failed to load index.html:', err);
mainWindow.show();
});
mainWindow.webContents.on('did-fail-load', (event, code, desc) => {
console.error('did-fail-load:', code, desc);
});
// Open external links in the system browser
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: 'deny' };
@ -44,6 +60,8 @@ function createWindow() {
app.whenReady().then(() => {
createWindow();
registerLocalInference();
registerWan2gp();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {

41
electron/preload.js Normal file
View file

@ -0,0 +1,41 @@
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('localAI', {
isElectron: true,
// ── sd.cpp engine ──────────────────────────────────────────────────────
getBinaryStatus: () => ipcRenderer.invoke('local-ai:binary-status'),
downloadBinary: () => ipcRenderer.invoke('local-ai:download-binary'),
listModels: () => ipcRenderer.invoke('local-ai:list-models'),
downloadModel: (modelId) => ipcRenderer.invoke('local-ai:download-model', modelId),
downloadAuxiliary: (auxKey) => ipcRenderer.invoke('local-ai:download-auxiliary', auxKey),
deleteModel: (modelId) => ipcRenderer.invoke('local-ai:delete-model', modelId),
cancelDownload: (modelId) => ipcRenderer.invoke('local-ai:cancel-download', modelId),
generate: (params) => ipcRenderer.invoke('local-ai:generate', params),
cancelGeneration: () => ipcRenderer.invoke('local-ai:cancel-generation'),
// ── Wan2GP engine (remote Gradio server) ───────────────────────────────
wan2gp: {
getConfig: () => ipcRenderer.invoke('wan2gp:get-config'),
setUrl: (url) => ipcRenderer.invoke('wan2gp:set-url', url),
probe: (url) => ipcRenderer.invoke('wan2gp:probe', url),
listModels: () => ipcRenderer.invoke('wan2gp:list-models'),
generate: (params) => ipcRenderer.invoke('wan2gp:generate', params),
cancelGeneration: () => ipcRenderer.invoke('wan2gp:cancel-generation'),
uploadFile: (payload) => ipcRenderer.invoke('wan2gp:upload-file', payload),
},
// Progress events — both engines emit on local-ai:progress
onProgress: (callback) => {
const listener = (_, data) => callback(data);
ipcRenderer.on('local-ai:progress', listener);
return () => ipcRenderer.removeListener('local-ai:progress', listener);
},
onDownloadProgress: (callback) => {
const listener = (_, data) => callback(data);
ipcRenderer.on('local-ai:download-progress', listener);
return () => ipcRenderer.removeListener('local-ai:download-progress', listener);
},
});

View file

@ -4,9 +4,9 @@
<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 Higgsfield AI — the free, open-source alternative to Higgsfield AI. Generate AI images and cinematic shots with 20+ models including Flux, SDXL, Ideogram, and Midjourney." />
<meta name="description" content="Open Generative AI — the free, open-source alternative to Higgsfield AI. Generate AI images and cinematic shots with 20+ models including Flux, SDXL, Ideogram, and Midjourney." />
<meta name="keywords" content="higgsfield ai, higgsfield alternative, open source higgsfield, ai image generator, ai cinema studio, flux ai, ai video generation, free higgsfield, open source ai image generation" />
<title>Open Higgsfield AI — Free Open-Source Alternative to Higgsfield AI</title>
<title>Open Generative AI — Free Open-Source Alternative to Higgsfield AI</title>
</head>
<body>
<div id="app"></div>

View file

@ -2,7 +2,9 @@
"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"]
}
}
}

31
middleware.js Normal file
View file

@ -0,0 +1,31 @@
import { NextResponse } from 'next/server';
export function middleware(request) {
const url = request.nextUrl;
// Catch requests to /api/workflow, /api/app, and /api/v1
const isMuApi = url.pathname.startsWith('/api/workflow') ||
url.pathname.startsWith('/api/app') ||
url.pathname.startsWith('/api/v1');
if (isMuApi) {
// Remap /api/v1 ONLY if it's not handled by a specific route.
// Actually, we'll let existing remapping for /api/v1 stay if needed,
// but we'll remove app/workflow as they need special handling.
if (url.pathname.startsWith('/api/v1')) {
const targetUrl = new URL(url.pathname + url.search, 'https://api.muapi.ai');
return NextResponse.rewrite(targetUrl);
}
}
return NextResponse.next();
}
// Match the paths we want to proxy
export const config = {
matcher: [
'/api/workflow/:path*',
'/api/app/:path*',
'/api/v1/:path*'
],
};

View file

@ -1819,6 +1819,50 @@
}
}
},
{
"id": "gpt-image-2",
"name": "Gpt Image 2",
"endpoint": "gpt-image-2-text-to-image",
"family": "gpt-2",
"inputs": {
"prompt": {
"examples": [
"A photorealistic product photo of a luxury watch resting on a slab of black marble, dramatic cinematic lighting with a soft rim glow, ultra-detailed metallic textures, shallow depth of field, studio quality."
],
"description": "Text prompt describing the image. Up to 20,000 characters supported.",
"type": "string",
"title": "Prompt",
"name": "prompt"
},
"aspect_ratio": {
"enum": [
"auto",
"1:1",
"16:9",
"9:16",
"4:3",
"3:4"
],
"title": "Aspect Ratio",
"name": "aspect_ratio",
"type": "string",
"description": "Aspect ratio of the output image.",
"default": "auto"
},
"resolution": {
"enum": [
"1K",
"2K",
"4K"
],
"title": "Resolution",
"name": "resolution",
"type": "string",
"description": "The target resolution of the generated image.",
"default": "2K"
}
}
},
{
"id": "wan2.6-text-to-image",
"name": "Wan2.6 Text To Image",
@ -2001,6 +2045,48 @@
"step": 0.01
}
}
},
{
"id": "minimax-image-01",
"name": "MiniMax Image 01",
"inputs": {
"prompt": {
"type": "string",
"title": "Prompt",
"name": "prompt",
"description": "Text prompt describing the image to generate (max 1500 characters).",
"examples": [
"A serene mountain lake at sunset with golden reflections on the water, surrounded by pine forests and snow-capped peaks, photorealistic, 8k."
]
},
"aspect_ratio": {
"type": "string",
"title": "Aspect Ratio",
"name": "aspect_ratio",
"description": "Aspect ratio of the output image.",
"enum": [
"16:9",
"9:16",
"1:1",
"4:3",
"3:4",
"3:2",
"2:3",
"21:9"
],
"default": "1:1"
},
"num_images": {
"type": "int",
"title": "Number of images",
"name": "num_images",
"description": "Number of images to generate in a single request.",
"default": 1,
"minValue": 1,
"maxValue": 4,
"step": 1
}
}
}
]
}

View file

@ -1,6 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
transpilePackages: ['studio'],
transpilePackages: ['studio', 'ai-agent', 'workflow-builder'],
};
export default nextConfig;

5093
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,52 +1,113 @@
{
"name": "open-higgsfield-ai",
"name": "open-generative-ai",
"description": "Open-source alternative to Higgsfield AI — AI image, video, cinema and lip sync studio",
"homepage": "https://github.com/Anil-matcha/Open-Generative-AI",
"private": true,
"version": "1.0.0",
"version": "1.0.10",
"workspaces": [
"packages/studio"
"packages/studio",
"packages/Vibe-Workflow/packages/workflow-builder",
"packages/Open-Poe-AI/packages/agents"
],
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"build:studio": "cd packages/studio && npm run build",
"setup": "npm install && npm run build:studio",
"build:studio": "npm run build -w studio",
"build:workflow": "npm run build -w workflow-builder",
"build:agent": "npm run build -w ai-agent",
"build:packages": "npm run build:workflow && npm run build:agent && npm run build:studio",
"setup": "git submodule update --init --recursive && npm install && npm run build:packages",
"vite:dev": "vite",
"vite: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:all": "vite build && electron-builder --mac --win"
"electron:build:linux": "vite build && electron-builder --linux",
"electron:build:all": "vite build && electron-builder --mac --win --linux"
},
"build": {
"appId": "ai.higgsfield.open",
"productName": "Open Higgsfield AI",
"appId": "ai.generative.open",
"productName": "Open Generative AI",
"copyright": "Copyright © 2025",
"directories": { "output": "release" },
"directories": {
"output": "release"
},
"afterPack": "./afterPack.js",
"files": ["dist/**/*", "electron/**/*"],
"files": [
"dist/**/*",
"electron/**/*"
],
"mac": {
"category": "public.app-category.graphics-design",
"icon": "public/banner.png",
"gatekeeperAssess": false,
"target": [{ "target": "dmg", "arch": ["x64", "arm64"] }]
"target": [
{
"target": "dmg",
"arch": [
"x64",
"arm64"
]
}
]
},
"win": {
"icon": "public/banner.png",
"target": [{ "target": "nsis", "arch": ["x64", "arm64"] }]
"target": [
{
"target": "nsis",
"arch": [
"x64"
]
}
]
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"include": "build/installer.nsh"
},
"linux": {
"icon": "public/banner.png",
"category": "Utility",
"maintainer": "Open Generative AI Team",
"extraFiles": [
{
"from": "build/linux/apparmor.profile",
"to": "resources/apparmor.profile"
}
],
"target": [
{
"target": "AppImage",
"arch": [
"x64"
]
},
{
"target": "deb",
"arch": [
"x64"
]
}
]
}
},
"dependencies": {
"axios": "^1.7.0",
"next": "^15.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hot-toast": "^2.4.1",
"studio": "*",
"axios": "^1.7.0",
"react-hot-toast": "^2.4.1"
"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",
@ -54,7 +115,7 @@
"eslint-config-next": "^15.0.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.0",
"vite": "^5.4.0",
"@tailwindcss/vite": "^4.1.18"
}
"vite": "^5.4.0"
},
"main": "electron/main.js"
}

1
packages/Open-Poe-AI Submodule

@ -0,0 +1 @@
Subproject commit cb12973823b15a50329ff34ed28491c73681a2ab

@ -0,0 +1 @@
Subproject commit 41a2da7d713d4bc92180ef248dad3cec8c3b0bea

View file

@ -1,7 +1,7 @@
{
"name": "studio",
"version": "1.0.0",
"description": "Open Higgsfield AI studio components for Muapi",
"description": "Open Generative AI studio components for Muapi",
"main": "src/index.js",
"module": "src/index.js",
"files": [
@ -14,9 +14,18 @@
},
"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-hot-toast": "^2.4.1"
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^16.1.1",
"react-toastify": "^11.1.0",
"reactflow": "^11.11.4",
"remark-gfm": "^4.0.1",
"workflow-builder": "file:../Vibe-Workflow/packages/workflow-builder",
"ai-agent": "file:../Open-Poe-AI/packages/agents"
},
"peerDependencies": {
"react": ">=18.0.0",

View file

@ -0,0 +1,295 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import {
getTemplateAgents,
getUserAgents,
getUserConversations,
} from "../muapi.js";
// Helpers
function timeAgo(dateStr) {
if (!dateStr) return "";
const utcStr =
dateStr.endsWith("Z") || dateStr.includes("+") ? dateStr : dateStr + "Z";
const diff = Math.floor((Date.now() - new Date(utcStr)) / 1000);
if (diff < 60) return "just now";
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
return new Date(utcStr).toLocaleDateString();
}
// Agent Card (grid)
function AgentCard({ agent, onClick, onEdit }) {
return (
<div className="group relative aspect-[4/5] rounded-xl cursor-pointer">
<div
onClick={() => onClick(agent)}
className="absolute inset-0 rounded-xl overflow-hidden border border-white/5 bg-[#0a0a0a] transition-all group-hover:border-[#d9ff00]/30 group-hover:scale-[1.02] shadow-2xl"
>
{agent.icon_url ? (
<img
src={agent.icon_url}
alt={agent.name}
className="absolute inset-0 w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
/>
) : (
<div className="absolute inset-0 bg-gradient-to-br from-violet-500/10 to-fuchsia-500/10 flex items-center justify-center">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="1" className="opacity-20">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
</svg>
</div>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/20 to-transparent" />
<div className="absolute inset-x-0 bottom-0 p-4">
<div className="text-[10px] font-bold text-[#d9ff00] uppercase tracking-wider mb-1 opacity-80">
{agent.category || "AI Assistant"}
</div>
<h3 className="text-sm font-bold text-white truncate group-hover:text-[#d9ff00] transition-colors">
{agent.name || "Unnamed Agent"}
</h3>
{agent.owner_username && (
<p className="text-[9px] text-white/40 mt-1 uppercase tracking-tighter font-black">
By {agent.owner_username}
</p>
)}
</div>
</div>
{onEdit && (
<button
onClick={(e) => {
e.stopPropagation();
onEdit(agent);
}}
className="absolute top-3 right-3 w-8 h-8 rounded-full bg-black/60 border border-white/10 flex items-center justify-center text-white opacity-0 group-hover:opacity-100 transition-all hover:bg-[#d9ff00] hover:text-black hover:scale-110 z-10"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
</button>
)}
</div>
);
}
// Conversation Card (My Chats)
function ConversationCard({ conv, onClick }) {
const displayTitle = conv.title || "New Chat";
const agentSlug = conv.agent_slug || conv.agent_id;
return (
<div
onClick={() => onClick(agentSlug, conv.id)}
className="group flex flex-col gap-3 bg-white/[0.03] border border-white/5 rounded-xl p-4 hover:border-[#d9ff00]/20 hover:bg-white/5 transition-all cursor-pointer"
>
<div className="flex items-center gap-3">
<div className="relative w-10 h-10 rounded-xl overflow-hidden bg-white/5 border border-white/5 shrink-0">
{conv.agent_icon_url ? (
<img src={conv.agent_icon_url} alt={conv.agent_name || "Agent"} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-white/20">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
</svg>
</div>
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-[10px] font-black text-[#d9ff00] uppercase tracking-wider truncate">
{conv.agent_name || "Unknown Agent"}
</p>
<p className="text-sm font-bold text-white truncate" title={displayTitle}>
{displayTitle}
</p>
</div>
</div>
<div className="flex items-center justify-between pt-2 border-t border-white/5 mt-auto text-[10px] text-white/30 font-medium">
<span>{timeAgo(conv.updated_at)}</span>
{conv.message_count != null && <span>{conv.message_count} msgs</span>}
</div>
</div>
);
}
// Main Component
const TABS = ["templates", "my-agents", "my-chats"];
export default function AgentStudio({ apiKey }) {
const router = useRouter();
const [activeMainTab, setActiveMainTab] = useState("templates");
const [agents, setAgents] = useState([]);
const [conversations, setConversations] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Navigate to the standalone /agents page AiAgent handles its own routing there
const handleSelectAgent = useCallback(
(agent) => {
const id = agent.agent_id || agent.id;
router.push(`/agents/${id}`);
},
[router]
);
const handleEditAgent = useCallback(
(agent) => {
const id = agent.agent_id || agent.id;
router.push(`/agents/edit/${id}`);
},
[router]
);
const handleCreateAgent = useCallback(() => {
router.push("/agents/create");
}, [router]);
const handleOpenConversation = useCallback(
(agentSlug, convId) => {
router.push(`/agents/${agentSlug}/${convId}`);
},
[router]
);
useEffect(() => {
if (!apiKey) return;
let cancelled = false;
async function load() {
setLoading(true);
setError(null);
setAgents([]);
setConversations([]);
try {
if (activeMainTab === "templates") {
const data = await getTemplateAgents(apiKey);
if (!cancelled) setAgents(data);
} else if (activeMainTab === "my-agents") {
const data = await getUserAgents(apiKey);
if (!cancelled) setAgents(data);
} else if (activeMainTab === "my-chats") {
const data = await getUserConversations(apiKey);
if (!cancelled) setConversations(data);
}
} catch (err) {
console.error("AgentStudio load error:", err);
if (!cancelled) setError(err.message || "Failed to load.");
} finally {
if (!cancelled) setLoading(false);
}
}
load();
return () => { cancelled = true; };
}, [apiKey, activeMainTab]);
// Render
return (
<div className="h-full flex flex-col bg-[#030303] text-white">
{/* Header */}
<div className="flex-shrink-0 h-16 border-b border-white/5 flex items-center justify-between px-8 bg-black/40">
<div className="flex items-center gap-8 h-full">
<h2 className="text-sm font-black uppercase tracking-[0.2em] text-[#d9ff00]">
Agents
</h2>
<div className="flex gap-1 bg-white/5 p-1 rounded-xl">
{TABS.map((tab) => (
<button
key={tab}
onClick={() => setActiveMainTab(tab)}
className={`px-4 py-1.5 text-[10px] font-black uppercase tracking-widest rounded-lg transition-all ${
activeMainTab === tab
? "bg-white text-black shadow-xl"
: "text-white/40 hover:text-white hover:bg-white/5"
}`}
>
{tab.replace(/-/g, " ")}
</button>
))}
</div>
</div>
<button
onClick={handleCreateAgent}
className="px-6 py-2 bg-[#d9ff00] text-black text-[10px] font-black uppercase tracking-widest rounded-lg hover:bg-[#ebff66] transition-all active:scale-95 flex items-center gap-2"
>
<span className="text-sm">+</span>
Create
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-8">
{loading ? (
<div className="h-full flex items-center justify-center">
<div className="w-10 h-10 border-2 border-white/5 border-t-[#d9ff00] rounded-full animate-spin" />
</div>
) : error ? (
<div className="h-full flex flex-col items-center justify-center text-white/20 gap-4">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<p className="text-xs font-bold uppercase tracking-widest">{error}</p>
<button
onClick={() => setActiveMainTab(activeMainTab)} // retrigger effect
className="text-[10px] text-white/40 hover:text-white border border-white/10 px-4 py-2 rounded-lg transition-colors"
>
Retry
</button>
</div>
) : activeMainTab === "my-chats" ? (
// My Chats view
conversations.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-white/10 gap-4">
<svg width="60" height="60" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="0.5">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
<p className="text-[10px] font-black uppercase tracking-[0.3em]">No chats yet</p>
<button
onClick={() => setActiveMainTab("templates")}
className="text-[10px] text-[#d9ff00] hover:text-white border border-[#d9ff00]/20 hover:border-white/20 px-4 py-2 rounded-lg transition-colors"
>
Browse Templates
</button>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 max-w-[1600px] mx-auto">
{conversations.map((conv) => (
<ConversationCard
key={conv.id}
conv={conv}
onClick={handleOpenConversation}
/>
))}
</div>
)
) : (
// Agents grid (templates / my-agents)
agents.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-white/10 gap-4">
<svg width="60" height="60" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="0.5">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
</svg>
<p className="text-[10px] font-black uppercase tracking-[0.3em]">No agents found</p>
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-6 max-w-[1600px] mx-auto">
{agents.map((agent) => (
<AgentCard
key={agent.agent_id || agent.id}
agent={agent}
onClick={handleSelectAgent}
/>
))}
</div>
)
)}
</div>
</div>
);
}

View file

@ -0,0 +1,377 @@
"use client";
import React, { useState, useEffect } from 'react';
import {
FaUserTie, FaImage, FaMagic, FaVideo, FaFileAlt,
FaBriefcase, FaHome, FaMicrophone, FaHandSparkles, FaBuilding,
FaUserInjured, FaStethoscope, FaCar, FaPaw, FaBalanceScale, FaTruck, FaMapMarkerAlt,
FaGithub, FaExternalLinkAlt, FaDollarSign, FaRocket, FaCreditCard
} from "react-icons/fa";
import { registerAppInterest, getAppInterests } from '../muapi.js';
import toast, { Toaster } from 'react-hot-toast';
const templateApps = [
{
name: "AI Headshot Studio",
description: "Launch a headshot SaaS in minutes. Charge $5$20 per set, keep all profits. Stripe payments & user accounts included.",
icon: FaUserTie,
color: "blue",
repo: "https://github.com/SamurAIGPT/ai-headshot-generator",
hosted: "https://ai-headshot-generator-xi.vercel.app/",
thumbnail: "https://cdn.muapi.ai/apps/d9c39378f60e48098f6b6ce657dc18b5.png",
isTemplate: true
},
{
name: "Nano Banana Studio",
description: "Your own AI image generation platform, ready to monetize. Add credit packs or subscriptions and start earning from day one.",
icon: FaHandSparkles,
color: "amber",
repo: "https://github.com/SamurAIGPT/nano-banana-generator",
hosted: "https://nano-banana-generator-psi.vercel.app",
thumbnail: "https://cdn.muapi.ai/data/2/874086171651/Screenshot_2026-04-15_103743.png",
isTemplate: true
},
{
name: "Seedance V2 Studio",
description: "Deploy a premium AI art studio and sell access to users. Full Stripe integration lets you collect revenue immediately after launch.",
icon: FaMagic,
color: "purple",
repo: "https://github.com/SamurAIGPT/seedance-2-generator",
hosted: "https://seedance-2-generator.vercel.app/",
thumbnail: "https://cdn.muapi.ai/apps/4cd1f49d48934d448e7f493f9d5e476e.png",
isTemplate: true
},
{
name: "AI Clipping Studio",
description: "Launch your own AI-powered video clipping SaaS. Download YouTube videos and extract viral highlights with ease.",
icon: FaVideo,
color: "emerald",
repo: "https://github.com/SamurAIGPT/ai-clipping-generator",
hosted: "https://ai-clipping-generator.vercel.app/",
thumbnail: "https://cdn.muapi.ai/data/2/883345778103/cca8b5bb-25f1-40fe-928e-53dce2c8c928.png",
isTemplate: true
},
{
name: "EasyVeo Studio",
description: "The complete Veo 3.1 video generation suite. Monetize text-to-video, image-to-video, and reference-to-video workflows with ease.",
icon: FaVideo,
color: "indigo",
repo: "https://github.com/SamurAIGPT/veo4-video-generator",
hosted: "https://veo4-video-generator.vercel.app/",
thumbnail: "https://cdn.muapi.ai/data/2/901343404247/94ac6d86-be4e-4b70-b1e6-96d7e3692604.png",
isTemplate: true
}
];
const dummyAppsData = [
{ thumbnail: "https://cdn.muapi.ai/apps/Pet_Product_Studio.jpg", name: "Pet Product Studio", description: "High-end product photography specifically for pet toys and food.", icon: FaPaw, category: "Lifestyle" },
{ thumbnail: "https://cdn.muapi.ai/apps/Resale_Photo_Enhancer.png", name: "Resale Photo Enhancer", description: "Boost sales by elevating low-quality product photos to studio level.", icon: FaImage, category: "Business" },
{ thumbnail: "https://cdn.muapi.ai/apps/AI_Recruiter.png", name: "AI Recruiter", description: "Smart candidate screening and interview assistant.", icon: FaBriefcase, category: "Business" },
{ thumbnail: "https://cdn.muapi.ai/apps/Talk_to_PDF.png", name: "Talk to PDF", description: "Interactive document chat for deep research and summarization.", icon: FaFileAlt, category: "Productivity" },
{ thumbnail: "https://cdn.muapi.ai/apps/Blogger_CMS.png", name: "Blogger CMS", description: "AI-powered content management for high-velocity SEO blogs.", icon: FaBriefcase, category: "Business" },
{ thumbnail: "https://cdn.muapi.ai/apps/Amazon_Product_Studio.webp", name: "Amazon Product Studio", description: "Perfect Amazon-ready product shots with AI backdrops.", icon: FaImage, category: "Business" },
{ thumbnail: "https://cdn.muapi.ai/apps/AI_Business_Card.webp", name: "AI Business Card", description: "Digital-first business card generator with AI networking.", icon: FaBriefcase, category: "Business" },
{ thumbnail: "https://cdn.muapi.ai/apps/MailWise.png", name: "MailWise", description: "Intelligent email drafting and scheduling assistant.", icon: FaBriefcase, category: "Business" },
{ thumbnail: "https://cdn.muapi.ai/apps/My_Podcast.webp", name: "My Podcast", description: "Automated podcast editing and show-note generation.", icon: FaMicrophone, category: "Creative" },
{ thumbnail: "https://cdn.muapi.ai/apps/EZScribe.png", name: "EZScribe", description: "Instant transcription and meeting minute automation.", icon: FaFileAlt, category: "Productivity" },
{ thumbnail: "https://cdn.muapi.ai/apps/AI_Knowledge_Base.png", name: "AI Knowledge Base", description: "Train an AI on your company data for instant support.", icon: FaBriefcase, category: "Business" },
{ thumbnail: "https://cdn.muapi.ai/apps/AI_Outbound.webp", name: "AI Outbound", description: "Personalized cold outreach at scale for sales teams.", icon: FaBriefcase, category: "Business" },
{ thumbnail: "https://cdn.muapi.ai/apps/AI_Royal_Portrait.png", name: "AI Royal Portrait", description: "Transform your photos into 18th-century royal oil paintings.", icon: FaHandSparkles, category: "Creative" },
{ thumbnail: "https://cdn.muapi.ai/apps/AI_MEME.png", name: "AI MEME", description: "Viral-ready meme generation based on trending topics.", icon: FaMagic, category: "Creative" },
{ thumbnail: "https://cdn.muapi.ai/apps/AI_Real_Estate_Stager.webp", name: "AI Real Estate Stager", description: "Virtually furnish and stage empty homes for sale.", icon: FaHome, category: "Real Estate" },
{ thumbnail: "https://cdn.muapi.ai/apps/AI_Logo.png", name: "AI Logo", description: "Dynamic brand identity and logo generator.", icon: FaHandSparkles, category: "Business" },
{ thumbnail: "https://cdn.muapi.ai/apps/OldPhoto.png", name: "OldPhoto", description: "Restore, colorize, and sharpen vintage family photos.", icon: FaImage, category: "Creative" },
{ thumbnail: "https://cdn.muapi.ai/apps/AITryOn.png", name: "AITryOn", description: "Virtual fitting room for fashion brands and enthusiasts.", icon: FaHandSparkles, category: "Lifestyle" },
{ thumbnail: "https://cdn.muapi.ai/apps/AI_Age_Transformation.webp", name: "AI Age Transformation", description: "Visualize yourself at different stages of life with high fidelity.", icon: FaImage, category: "Lifestyle" },
{ thumbnail: "https://cdn.muapi.ai/apps/AI_Professional_Makeup_Generator.webp", name: "AI Professional Makeup Generator", description: "Try on hundreds of makeup looks virtually.", icon: FaHandSparkles, category: "Lifestyle" },
{ thumbnail: "https://cdn.muapi.ai/apps/AI_Flash_Cards.webp", name: "AI Flash Cards", description: "Turn any text or PDF into pedagogical flashcards.", icon: FaFileAlt, category: "Education" },
{ thumbnail: "https://cdn.muapi.ai/apps/AI_Group_Photo.webp", name: "AI Group Photo", description: "Seamlessly combine individual portraits into a group photo.", icon: FaImage, category: "Creative" },
{ thumbnail: "https://cdn.muapi.ai/apps/AI_Tattoo_Try_On.webp", name: "AI Tattoo Try-On", description: "Visualize tattoos on your body before getting inked.", icon: FaHandSparkles, category: "Lifestyle" },
{ thumbnail: "https://cdn.muapi.ai/apps/AI_Hair_Style_Simulator.webp", name: "AI Hair Style Simulator", description: "Try on new haircuts and colors with zero commitment.", icon: FaHandSparkles, category: "Lifestyle" },
{ thumbnail: "https://cdn.muapi.ai/apps/AI_Kids_to_Adult_Prediction.webp", name: "AI Kids-to-Adult Prediction", description: "Ever wonder what your kid will look like as an adult?", icon: FaImage, category: "Lifestyle" },
{ thumbnail: "https://cdn.muapi.ai/apps/AI_Room_Declutter.webp", name: "AI Room Declutter", description: "Instantly clean up messy room photos for listings.", icon: FaHome, category: "Real Estate" },
{ thumbnail: "https://cdn.muapi.ai/apps/AI_Fitness_Body_Simulator.webp", name: "AI Fitness Body Simulator", description: "Visualize your fitness goals on your own body.", icon: FaImage, category: "Lifestyle" },
{ thumbnail: "https://cdn.muapi.ai/apps/AI_Pet_Portrait.webp", name: "AI Pet Portrait", description: "Elegant, artistic portraits for your beloved pets.", icon: FaPaw, category: "Lifestyle" },
{ thumbnail: "https://cdn.muapi.ai/apps/AI_Kissing_Video_Generator.webp", name: "AI Kissing Video Generator", description: "Expressive AI video generation for romantic moments.", icon: FaVideo, category: "Creative" },
{ thumbnail: "https://cdn.muapi.ai/apps/Chat_with_PDF.webp", name: "Chat with PDF", description: "Ask questions and extract data from massive PDF files.", icon: FaFileAlt, category: "Productivity" },
{ thumbnail: "https://cdn.muapi.ai/apps/AI_Travel_Studio.png", name: "AI Travel Studio", description: "Create stunning travel posters and visuals from prompts.", icon: FaMapMarkerAlt, category: "Lifestyle" },
{ thumbnail: "https://cdn.muapi.ai/apps/Prompt_Architect.webp", name: "Prompt Architect", description: "Refine and optimize complex prompts for high-tier AI models.", icon: FaMagic, category: "Creative" },
{ thumbnail: "https://cdn.muapi.ai/apps/ClearMark_AI.webp", name: "ClearMark AI", description: "Automated watermark removal and brand cleanup for assets.", icon: FaImage, category: "Business" },
{ thumbnail: "https://cdn.muapi.ai/apps/PlantVision_AI.webp", name: "PlantVision AI", description: "Identify plants and generate gardening care guides.", icon: FaHandSparkles, category: "Lifestyle" },
{ thumbnail: "https://cdn.muapi.ai/apps/AI_Wedding_Photo.png", name: "AI Wedding Photo", description: "Cinematic wedding photography enhancements and filters.", icon: FaImage, category: "Lifestyle" },
{ thumbnail: "https://cdn.muapi.ai/apps/User_Account_Registration_Form.webp", name: "User Account Registration Form", description: "Beautiful, conversion-optimized signup flows.", icon: FaBriefcase, category: "Development" },
{ thumbnail: "https://cdn.muapi.ai/apps/Social_Post.webp", name: "Social Post", description: "AI-generated social media scheduling and copy creator.", icon: FaBriefcase, category: "Marketing" },
{ thumbnail: "https://cdn.muapi.ai/apps/MagicSelf_AI.webp", name: "MagicSelf AI", description: "The ultimate AI selfie and avatar generation engine.", icon: FaMagic, category: "Creative" },
{ thumbnail: "https://cdn.muapi.ai/apps/AI_Resume_Builder.webp", name: "AI Resume Builder", description: "Craft the perfect, ATS-friendly resume in seconds.", icon: FaFileAlt, category: "Productivity" },
{ thumbnail: "https://cdn.muapi.ai/apps/GEO_Checker.webp", name: "GEO Checker", description: "AI-powered location tagging and geodata validation.", icon: FaMapMarkerAlt, category: "Business" },
{ thumbnail: "https://cdn.muapi.ai/apps/AI_Character_Studio.webp", name: "AI Character Studio", description: "Consistent character design for animators and writers.", icon: FaUserTie, category: "Creative" },
{ thumbnail: "https://cdn.muapi.ai/apps/Luxury_Hair_Studio.webp", name: "Luxury Hair Studio", description: "High-end hair visualization for top-tier salons.", icon: FaHandSparkles, category: "Lifestyle" },
{ thumbnail: "https://cdn.muapi.ai/apps/ProFlow_Plumbing.webp", name: "ProFlow Plumbing", description: "AI scheduling and diagnostics for plumbing services.", icon: FaHome, category: "Services" },
{ thumbnail: "https://cdn.muapi.ai/apps/Solace_AI.webp", name: "Solace AI", description: "Empathetic AI assistant for mental well-being support.", icon: FaHandSparkles, category: "Health" },
{ thumbnail: "https://cdn.muapi.ai/apps/ReLive_AI.webp", name: "ReLive AI", description: "Immersive memory and historical visualization engine.", icon: FaHandSparkles, category: "Creative" },
{ thumbnail: "https://cdn.muapi.ai/apps/AI_Chiropractic_Service.webp", name: "AI Chiropractic Service", description: "Postural analysis and exercise recommendation AI.", icon: FaUserInjured, category: "Health" },
{ thumbnail: "https://cdn.muapi.ai/apps/Tabla___ReserveAI.webp", name: "Tabla - ReserveAI", description: "Intelligent table reservation engine for restaurants.", icon: FaBuilding, category: "Services" },
{ thumbnail: "https://cdn.muapi.ai/apps/Dental_ReserveAI.webp", name: "Dental ReserveAI", description: "Smart dental appointment and follow-up management.", icon: FaStethoscope, category: "Health" },
{ thumbnail: "https://cdn.muapi.ai/apps/CounselMate.webp", name: "CounselMate", description: "Legal research and document drafting aid for lawyers.", icon: FaBalanceScale, category: "Legal" },
{ thumbnail: "https://cdn.muapi.ai/apps/Intelligent_Real_Estate_Agent.webp", name: "Intelligent Real Estate Agent", description: "Automate leads and property matches with AI agents.", icon: FaHome, category: "Real Estate" },
{ thumbnail: "https://cdn.muapi.ai/apps/Fixera.webp", name: "Fixera", description: "Home repair diagnosis and pro-finding ecosystem.", icon: FaHome, category: "Services" },
{ thumbnail: "https://cdn.muapi.ai/apps/Velora___Yoga_AI.webp", name: "Velora - Yoga AI", description: "Personalized AI yoga and posture guidance engine.", icon: FaHandSparkles, category: "Health" },
{ thumbnail: "https://cdn.muapi.ai/apps/Nova_AssuranceAI.webp", name: "Nova AssuranceAI", description: "Smart insurance quote and claim processing assistant.", icon: FaBalanceScale, category: "Legal" },
{ thumbnail: "https://cdn.muapi.ai/apps/TurboGlow_Auto_Spa.webp", name: "TurboGlow Auto Spa", description: "AI booking and customization for luxury auto detailing.", icon: FaCar, category: "Services" },
{ thumbnail: "https://cdn.muapi.ai/apps/Paws___Pals.webp", name: "Paws & Pals", description: "AI-powered pet care and walking coordination hub.", icon: FaPaw, category: "Lifestyle" },
{ thumbnail: "https://cdn.muapi.ai/apps/Vertex_Tax_Strategy.webp", name: "Vertex Tax Strategy", description: "Intelligent tax planning and deduction spotting AI.", icon: FaBalanceScale, category: "Business" },
{ thumbnail: "https://cdn.muapi.ai/apps/LedgerSync.webp", name: "LedgerSync", description: "Automated bookkeeping and financial reconciliations.", icon: FaBriefcase, category: "Business" },
{ thumbnail: "https://cdn.muapi.ai/apps/Nova_Care_Clinic.webp", name: "Nova Care Clinic", description: "Patient scheduling and medical intake automation.", icon: FaStethoscope, category: "Health" },
{ thumbnail: "https://cdn.muapi.ai/apps/Opulent_Drive.webp", name: "Opulent Drive", description: "Luxury car rental and fleet management AI.", icon: FaCar, category: "Services" },
{ thumbnail: "https://cdn.muapi.ai/apps/ProFix_Auto.webp", name: "ProFix Auto", description: "Engine diagnostics and preventive maintenance alerts.", icon: FaCar, category: "Services" },
{ thumbnail: "https://cdn.muapi.ai/apps/TowMate.webp", name: "TowMate", description: "Smart roadside assistance and dispatch coordination.", icon: FaTruck, category: "Services" },
{ thumbnail: "https://cdn.muapi.ai/apps/SwiftLink_Logistics.webp", name: "SwiftLink Logistics", description: "AI route optimization and fleet tracking system.", icon: FaTruck, category: "Services" },
{ thumbnail: "https://cdn.muapi.ai/apps/Lumea_Residence.webp", name: "Lumea Residence", description: "Smart home property management and tenant portal.", icon: FaHome, category: "Real Estate" }
];
export default function AppsStudio({ apiKey }) {
const [selectedApp, setSelectedApp] = useState(null);
const [isRequesting, setIsRequesting] = useState(false);
const [requestedApps, setRequestedApps] = useState([]);
useEffect(() => {
if (apiKey) {
getAppInterests(apiKey)
.then(setRequestedApps)
.catch(err => console.error("Error fetching interests:", err));
}
}, [apiKey]);
const handleRequestAccess = async () => {
if (!selectedApp || !apiKey) return;
setIsRequesting(true);
try {
await registerAppInterest(apiKey, selectedApp.name);
setRequestedApps(prev => [...prev, selectedApp.name]);
toast.success("Got it! We'll send you the template details shortly.");
setTimeout(() => setSelectedApp(null), 1500);
} catch (error) {
console.error(error);
toast.error("Failed to register interest. Please try again later.");
} finally {
setIsRequesting(false);
}
};
const renderAppCard = (app, isDummy = false, index = 0) => {
// Premium Vibrant Gradients for placeholders
const gradients = [
"from-blue-600/20 to-indigo-600/20",
"from-purple-600/20 to-pink-600/20",
"from-amber-500/20 to-orange-600/20",
"from-emerald-500/20 to-teal-600/20",
"from-rose-500/20 to-red-600/20",
"from-cyan-500/20 to-blue-600/20",
];
const cardGradient = gradients[index % gradients.length];
return (
<div
key={app.name}
className="group bg-[#0a0a0a] border border-white/5 rounded-lg flex flex-col overflow-hidden transition-all duration-300 hover:border-white/10 hover:bg-[#0f0f0f] hover:shadow-2xl hover:shadow-blue-500/5 hover:-translate-y-1"
>
{/* Thumbnail Section */}
<div className="relative h-44 w-full overflow-hidden bg-white/5">
{app.thumbnail ? (
<img
src={app.thumbnail}
alt={app.name}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
/>
) : (
<div className={`w-full h-full flex items-center justify-center bg-gradient-to-br ${cardGradient} transition-colors group-hover:scale-110 duration-700`}>
<app.icon className={`text-4xl opacity-20 group-hover:opacity-40 transition-opacity text-white`} />
</div>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent opacity-60 group-hover:opacity-80 transition-opacity" />
</div>
{/* Content Section */}
<div className="p-5 flex flex-col flex-1 space-y-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-white/5 flex items-center justify-center text-lg text-[#d9ff00] border border-white/5 group-hover:border-white/10 transition-colors">
<app.icon />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-bold text-white uppercase tracking-tight truncate">{app.name}</h3>
<p className="text-[10px] text-white/40 font-bold uppercase tracking-widest">{app.category || 'Template'}</p>
</div>
</div>
<p className="text-xs text-white/50 leading-relaxed font-medium line-clamp-2 min-h-[2.5rem]">{app.description}</p>
{/* Action Buttons */}
<div className="flex items-center gap-2 pt-2">
{isDummy ? (
<>
<button
onClick={() => setSelectedApp(app)}
className="flex-1 py-2 bg-white/5 text-white rounded-md text-[11px] font-bold uppercase tracking-wider flex items-center justify-center gap-2 hover:bg-white/10 transition-all border border-white/5 active:scale-95"
>
<FaGithub className="text-xs" />
Github
</button>
<button
onClick={() => setSelectedApp(app)}
className="flex-1 py-2 bg-[#d9ff00]/10 text-[#d9ff00] rounded-md text-[11px] font-bold uppercase tracking-wider flex items-center justify-center gap-2 hover:bg-[#d9ff00]/20 transition-all border border-[#d9ff00]/20 active:scale-95"
>
<FaExternalLinkAlt className="text-[9px]" />
Demo
</button>
</>
) : (
<>
<a
href={app.repo || '#'}
target="_blank"
rel="noopener noreferrer"
className="flex-1 py-2 bg-white/5 text-white rounded-md text-[11px] font-bold uppercase tracking-wider flex items-center justify-center gap-2 hover:bg-white/10 transition-all border border-white/5 active:scale-95"
>
<FaGithub className="text-xs" />
Github
</a>
<a
href={app.hosted || '#'}
target="_blank"
rel="noopener noreferrer"
className="flex-1 py-2 bg-[#d9ff00]/10 text-[#d9ff00] rounded-md text-[11px] font-bold uppercase tracking-wider flex items-center justify-center gap-2 hover:bg-[#d9ff00]/20 transition-all border border-[#d9ff00]/20 active:scale-95"
>
<FaExternalLinkAlt className="text-[9px]" />
Demo
</a>
</>
)}
</div>
</div>
</div>
);
};
return (
<div className="h-full w-full flex flex-col items-center bg-[#030303] overflow-y-auto custom-scrollbar relative">
<Toaster position="bottom-right" reverseOrder={false} />
<div className="flex flex-col gap-10 items-center w-full max-w-7xl pt-12 pb-24 px-6">
{/* Header Section */}
<div className="text-center space-y-6 max-w-3xl">
<div className="inline-flex items-center gap-2 px-3 py-1.5 bg-[#d9ff00]/10 border border-[#d9ff00]/20 rounded-full">
<FaDollarSign className="text-[#d9ff00] text-xs" />
<span className="text-[10px] font-black text-[#d9ff00] uppercase tracking-widest">Revenue-Ready Templates</span>
</div>
<h1 className="text-5xl font-black text-white tracking-tighter leading-[0.9]">
LAUNCH AN AI APP.<br />START EARNING TODAY.
</h1>
<p className="text-white/40 text-sm font-medium leading-relaxed max-w-xl mx-auto">
Each template is a fully-functional, Stripe-integrated AI SaaS you can deploy in minutes.
Charge your users, keep the revenue muapi handles the AI infrastructure.
</p>
</div>
{/* Monetization Steps */}
<div className="w-full grid grid-cols-1 sm:grid-cols-3 gap-4">
{[
{
icon: FaRocket,
step: "01",
title: "Deploy in Minutes",
body: "Fork the open-source template, add your muapi key, and push to Vercel. No backend setup needed."
},
{
icon: FaCreditCard,
step: "02",
title: "Collect Payments",
body: "Stripe is pre-wired. Set your own pricing — one-time credits, subscriptions, or pay-per-use."
},
{
icon: FaDollarSign,
step: "03",
title: "Keep the Revenue",
body: "Payments go straight to your Stripe account. You own the product, the brand, and the profits."
}
].map(({ icon: Icon, step, title, body }) => (
<div key={step} className="flex items-start gap-4 bg-[#0a0a0a] border border-white/5 rounded-2xl p-6 hover:border-white/10 transition-colors">
<div className="w-12 h-12 shrink-0 rounded-2xl bg-white/5 flex items-center justify-center text-[#d9ff00] border border-white/5">
<Icon className="text-lg" />
</div>
<div>
<p className="text-[10px] font-black text-white/30 uppercase tracking-widest mb-1">Step {step}</p>
<h3 className="text-sm font-bold text-white mb-1.5">{title}</h3>
<p className="text-xs text-white/40 leading-relaxed font-medium">{body}</p>
</div>
</div>
))}
</div>
{/* Apps Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 w-full pt-8">
{templateApps.map((app, index) => renderAppCard(app, false, index))}
{dummyAppsData.map((app, index) => renderAppCard(app, true, index + templateApps.length))}
</div>
{/* Footer Accent */}
<div className="pt-24 pb-12 flex flex-col items-center gap-4">
<div className="inline-flex items-center gap-3 px-4 py-2 bg-white/5 rounded-full border border-white/5">
<span className="block w-1.5 h-1.5 rounded-full bg-[#d9ff00] animate-pulse" />
<span className="text-[9px] font-black text-white/40 uppercase tracking-widest">Muapi Ecosystem More templates coming soon</span>
</div>
</div>
</div>
{/* Get Template Modal */}
{selectedApp && (
<div className="fixed inset-0 z-[100] flex items-center justify-center px-6">
<div className="absolute inset-0 bg-black/80 backdrop-blur-sm animate-fade-in" onClick={() => setSelectedApp(null)} />
<div className="relative bg-[#0a0a0a] border border-white/10 w-full max-w-md rounded-2xl p-8 space-y-8 animate-scale-up shadow-2xl">
<div className="flex flex-col items-center text-center space-y-4">
<div className="w-20 h-20 rounded-[28px] bg-[#d9ff00]/10 border border-[#d9ff00]/20 flex items-center justify-center text-4xl text-[#d9ff00] mb-2">
<selectedApp.icon />
</div>
<h2 className="text-2xl font-black text-white uppercase tracking-tight">
Deploy {selectedApp.name}
</h2>
<p className="text-sm font-medium text-white/40 leading-relaxed px-4">
Enter your details and we&apos;ll send you the <b>{selectedApp.name}</b> template along with setup instructions so you can deploy and start earning immediately.
</p>
</div>
<div className="space-y-3">
<button
onClick={handleRequestAccess}
disabled={isRequesting}
className="w-full py-4 bg-[#d9ff00] text-black rounded-md text-sm font-black uppercase tracking-widest flex items-center justify-center gap-2 hover:bg-[#d9ff00]/90 transition-all shadow-lg active:scale-95 disabled:opacity-50"
>
{isRequesting ? 'Sending Details...' : 'Get Template'}
</button>
<button
onClick={() => setSelectedApp(null)}
className="w-full py-4 bg-white/5 border border-white/10 text-white/60 rounded-md text-sm font-bold uppercase tracking-widest flex items-center justify-center gap-2 hover:bg-white/10 transition-all"
>
Maybe Later
</button>
</div>
</div>
</div>
)}
<style jsx global>{`
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes scaleUp {
from { opacity: 0; transform: scale(0.95) translateY(10px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
.animate-fade-in { animation: fadeIn 0.3s ease-out; }
.animate-scale-up { animation: scaleUp 0.3s cubic-bezier(0.16, 1, 0.3, 1); }
`}</style>
</div>
);
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,607 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { uploadFile, generateMarketingStudioAd } from "../muapi.js";
const SCROLLBAR_STYLE = `
.custom-scrollbar-thin::-webkit-scrollbar {
height: 4px;
}
.custom-scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar-thin::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 10px;
}
.custom-scrollbar-thin::-webkit-scrollbar-thumb:hover {
background: rgba(217, 255, 0, 0.3);
}
`;
// Icons
const CheckSvg = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#d9ff00" strokeWidth="4">
<polyline points="20 6 9 17 4 12" />
</svg>
);
const PlusSvg = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
);
const CloseSvg = () => (
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
);
const ProductIcon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 8l-2-2H5L3 8v10a2 2 0 002 2h14a2 2 0 002-2V8z" />
<path d="M3 10h18" />
<path d="M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2" />
</svg>
);
const AvatarIcon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
);
const RefIcon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
);
// Assets
const ASSETS = {
avatar: [
{ id: "aa252283-8591-4d14-91a8-41ce54187992", name: "Priya", url: "https://d3adwkbyhxyrtq.cloudfront.net/web-app/Priya.webp" },
{ id: "ba6c9b18-f79c-4dab-9649-88a181d0a038", name: "Elena", url: "https://d3adwkbyhxyrtq.cloudfront.net/web-app/Elena.webp" },
{ id: "30e2cadd-987c-4a7a-81c3-094d4fb3a65e", name: "Kai", url: "https://d3adwkbyhxyrtq.cloudfront.net/web-app/Kai.webp" },
{ id: "fbed59e1-4b8d-4625-9140-ef2044e0be72", name: "Sora", url: "https://d3adwkbyhxyrtq.cloudfront.net/web-app/Sora.webp" },
{ id: "bcd9e6ee-c000-48e6-9f4b-a20fc2a674f7", name: "Minji", url: "https://d3adwkbyhxyrtq.cloudfront.net/web-app/Minji.webp" },
{ id: "1da384ed-3856-45e4-bf4c-a496c7aa95ff", name: "Margot", url: "https://d3adwkbyhxyrtq.cloudfront.net/web-app/Margot.webp" },
{ id: "b799c8f5-fb6e-4905-b33b-cdefac153ec3", name: "Niko", url: "https://d3adwkbyhxyrtq.cloudfront.net/web-app/Niko.webp" },
{ id: "b6971dd4-55fa-4e64-b318-392b16504284", name: "Jin", url: "https://d3adwkbyhxyrtq.cloudfront.net/web-app/Jin.webp" }
],
ugc: [
{ id: 1, name: "UGC", url: "https://d3adwkbyhxyrtq.cloudfront.net/web-app/ugc.mp4" },
{ id: 2, name: "Tutorial", url: "https://d3adwkbyhxyrtq.cloudfront.net/web-app/ugc_how_to.mp4" },
{ id: 3, name: "Unboxing", url: "https://d3adwkbyhxyrtq.cloudfront.net/web-app/ugc_unboxing.mp4" },
{ id: 4, name: "Hyper Motion", url: "https://d3adwkbyhxyrtq.cloudfront.net/web-app/hyper-motion-mini.mp4" },
{ id: 5, name: "Product Review", url: "https://d3adwkbyhxyrtq.cloudfront.net/web-app/product_review.mp4" },
{ id: 6, name: "TV Spot", url: "https://d3adwkbyhxyrtq.cloudfront.net/web-app/tv-spot-mini.mp4" }
]
};
const OPTIONS = {
ratio: ["9:16", "3:4", "4:3", "16:9", "1:1"],
res: ["720p", "1080p"],
duration: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
};
// Components
function UploadSlot({ icon, url, progress, label, onUpload, onClear, multiple = false, images = [] }) {
const inputRef = useRef(null);
return (
<div className="relative group/slot flex items-center">
<div
onClick={() => inputRef.current?.click()}
title={`Upload ${label}`}
className={`relative w-10 h-10 rounded-full border transition-all flex items-center justify-center cursor-pointer ${
url ? 'border-primary/40 bg-primary/5' : 'border-white/5 bg-white/5 hover:bg-white/10 hover:border-white/20'
}`}
>
<input
ref={inputRef}
type="file"
accept="image/*"
className="hidden"
multiple={multiple}
onChange={(e) => onUpload(e)}
/>
{progress > 0 && progress < 100 ? (
<div className="absolute inset-0 bg-black/60 rounded-full flex items-center justify-center z-10">
<span className="text-[8px] font-black text-primary">{progress}%</span>
</div>
) : url ? (
<div className="w-full h-full rounded-full overflow-hidden border border-black/20">
<img src={url} className="w-full h-full object-cover" alt={label} />
</div>
) : (
<div className="text-white/40 group-hover:text-primary transition-colors">
{icon}
</div>
)}
{/* Clear Button (Single) */}
{url && !multiple && (
<button
onClick={(e) => { e.stopPropagation(); onClear(); }}
className="absolute -top-1 -right-1 w-4 h-4 bg-red-500 text-white rounded-full flex items-center justify-center opacity-0 group-hover/slot:opacity-100 transition-opacity shadow-lg"
>
<CloseSvg />
</button>
)}
</div>
</div>
);
}
function Dropdown({ isOpen, title, items, selectedId, onSelect, onClose, isVideo = false }) {
const ref = useRef(null);
useEffect(() => {
if (!isOpen) return;
const handler = (e) => {
if (ref.current && !ref.current.contains(e.target)) onClose();
};
window.addEventListener("click", handler);
return () => window.removeEventListener("click", handler);
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div
ref={ref}
className="absolute bottom-[calc(100%+12px)] left-0 z-50 bg-[#0a0a0a] rounded p-4 shadow-4xl border border-white/10 w-[420px] animate-fade-in-up"
>
<div className="text-[10px] font-black text-white/40 uppercase tracking-widest mb-4 px-1">{title}</div>
<div className="grid grid-cols-3 gap-3 max-h-[300px] overflow-y-auto custom-scrollbar pr-1">
{items.map(item => (
<div
key={item.id}
onClick={() => onSelect(item)}
className={`relative rounded overflow-hidden border-2 transition-all group cursor-pointer ${
selectedId === item.id || selectedId === item.url ? 'border-primary shadow-glow' : 'border-white/5 hover:border-white/20'
}`}
>
{isVideo ? (
<video src={item.url} autoPlay loop muted className="w-full aspect-[3/4] object-cover group-hover:scale-105 transition-all duration-500" />
) : (
<img src={item.url} className="w-full aspect-square object-cover group-hover:scale-105 transition-all duration-500" alt={item.name} />
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent flex items-end p-2 opacity-0 group-hover:opacity-100 transition-opacity">
<span className="text-[9px] font-black text-white uppercase tracking-tight">{item.name}</span>
</div>
{(selectedId === item.id || selectedId === item.url) && (
<div className="absolute top-1.5 right-1.5 w-4 h-4 bg-primary rounded-full flex items-center justify-center shadow-lg">
<CheckSvg />
</div>
)}
</div>
))}
</div>
</div>
);
}
function SimpleDropdown({ isOpen, title, options, selected, onSelect, onClose }) {
const ref = useRef(null);
useEffect(() => {
if (!isOpen) return;
const handler = (e) => {
if (ref.current && !ref.current.contains(e.target)) onClose();
};
window.addEventListener("click", handler);
return () => window.removeEventListener("click", handler);
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div
ref={ref}
className="absolute bottom-[calc(100%+12px)] left-0 z-50 bg-[#0a0a0a] rounded p-1 max-h-[200px] overflow-y-auto custom-scrollbar shadow-3xl border border-white/10 min-w-[140px] animate-fade-in-up"
>
<div className="text-[10px] font-black text-white/40 uppercase tracking-widest mb-2 px-3 pt-2">{title}</div>
{options.map(opt => (
<button
key={opt}
onClick={() => { onSelect(opt); onClose(); }}
className={`w-full text-left px-4 py-2 rounded text-xs font-bold transition-all flex items-center justify-between ${
selected === opt ? 'bg-primary text-black' : 'text-white/60 hover:bg-white/5 hover:text-white'
}`}
>
<span>{opt}</span>
{selected === opt && <CheckSvg />}
</button>
))}
</div>
);
}
// Main Component
export default function MarketingStudio({ apiKey, droppedFiles, onFilesHandled }) {
const PERSIST_KEY = "hg_marketing_studio_persistent";
const [prompt, setPrompt] = useState("");
const [productImage, setProductImage] = useState(null);
const [avatarImage, setAvatarImage] = useState(null);
const [additionalImages, setAdditionalImages] = useState([]);
const [params, setParams] = useState({
ratio: "9:16",
format: ASSETS.ugc[0].name,
videoUrl: ASSETS.ugc[0].url,
res: "1080p",
duration: 5
});
const [history, setHistory] = useState([]);
const [isGenerating, setIsGenerating] = useState(false);
const [dropdown, setDropdown] = useState(null); // 'format' | 'avatar' | 'ratio' | 'res' | 'duration'
const [uploadProgress, setUploadProgress] = useState({ product: 0, avatar: 0, additional: 0 });
const [fullscreenUrl, setFullscreenUrl] = useState(null);
const textareaRef = useRef(null);
// Persistence
useEffect(() => {
try {
const stored = localStorage.getItem(PERSIST_KEY);
if (stored) {
const data = JSON.parse(stored);
if (data.prompt) setPrompt(data.prompt);
if (data.params) setParams(data.params);
if (data.productImage) setProductImage(data.productImage);
if (data.avatarImage) setAvatarImage(data.avatarImage);
if (data.additionalImages) setAdditionalImages(data.additionalImages);
if (data.history) setHistory(data.history);
}
} catch (err) { console.warn("Load failed", err); }
}, []);
useEffect(() => {
const timer = setTimeout(() => {
const state = { prompt, params, productImage, avatarImage, additionalImages, history };
localStorage.setItem(PERSIST_KEY, JSON.stringify(state));
}, 500);
return () => clearTimeout(timer);
}, [prompt, params, productImage, avatarImage, additionalImages, history]);
// Handlers
const downloadFile = async (url, filename) => {
try {
const response = await fetch(url);
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = blobUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(blobUrl);
} catch {
window.open(url, "_blank");
}
};
const handleUpload = async (e, target) => {
const files = Array.from(e.target.files);
if (!files.length) return;
if (target === 'additional') {
const remaining = 6 - additionalImages.length;
const toUpload = files.slice(0, remaining);
for (const file of toUpload) {
try {
const url = await uploadFile(apiKey, file, (pct) => setUploadProgress(p => ({ ...p, additional: pct })));
setAdditionalImages(prev => [...prev, url].slice(0, 6));
} catch (err) { alert(err.message); }
}
} else {
const file = files[0];
try {
const url = await uploadFile(apiKey, file, (pct) => setUploadProgress(p => ({ ...p, [target]: pct })));
if (target === 'product') setProductImage(url);
else setAvatarImage(url);
} catch (err) { alert(err.message); }
}
setUploadProgress(p => ({ ...p, [target]: 0 }));
};
const handleGenerate = async () => {
if (!prompt.trim()) return alert("Please enter an ad script.");
if (!productImage) return alert("Please upload a product image.");
setIsGenerating(true);
try {
const result = await generateMarketingStudioAd(apiKey, {
prompt,
aspect_ratio: params.ratio,
duration: params.duration,
resolution: params.res,
images_list: [productImage, avatarImage, ...additionalImages].filter(Boolean),
video_files: params.videoUrl ? [params.videoUrl] : []
});
if (result?.url) {
const entry = {
id: Date.now(),
url: result.url,
prompt,
format: params.format,
timestamp: new Date().toISOString()
};
setHistory(prev => [entry, ...prev]);
setFullscreenUrl(result.url);
}
} catch (err) {
alert("Generation failed: " + err.message);
} finally {
setIsGenerating(false);
}
};
const handleTextareaInput = (e) => {
const el = e.target;
el.style.height = "auto";
el.style.height = Math.min(el.scrollHeight, 250) + "px";
};
// Render
return (
<div className="w-full h-full flex flex-col items-center justify-center bg-app-bg relative p-4 md:p-6 overflow-hidden">
<style>{SCROLLBAR_STYLE}</style>
{/* ── MAIN CONTENT AREA ── */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-6 pb-40">
{history.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 animate-fade-in-up">
{history.map(entry => (
<div key={entry.id} className="relative group rounded-lg overflow-hidden border border-white/10 bg-[#0a0a0a] shadow-xl hover:border-primary/50 transition-all duration-300 flex flex-col">
<video
src={entry.url}
className="w-full aspect-video object-cover cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => setFullscreenUrl(entry.url)}
muted loop onMouseOver={e => e.target.play()} onMouseOut={e => { e.target.pause(); e.target.currentTime = 0; }}
/>
{/* Actions Overlay */}
<div className="absolute top-2 right-2 flex flex-col gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => { e.stopPropagation(); downloadFile(entry.url, `marketing-ad-${entry.id}.mp4`); }}
className="p-2 bg-black/60 backdrop-blur-md rounded-full text-white hover:bg-primary hover:text-black transition-all border border-white/10"
title="Download"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
</svg>
</button>
</div>
<div className="p-3 bg-black/80 backdrop-blur-sm border-t border-white/5 flex flex-col gap-1.5 flex-1">
<p className="text-white/60 text-[10px] line-clamp-2 leading-relaxed font-medium">{entry.prompt}</p>
<div className="flex items-center justify-between mt-auto">
<span className="text-[9px] font-black text-primary px-2 py-0.5 bg-primary/10 rounded border border-primary/20 uppercase tracking-tighter">
{entry.format}
</span>
<span className="text-[9px] text-white/30 font-bold">{new Date(entry.timestamp).toLocaleDateString()}</span>
</div>
</div>
</div>
))}
</div>
) : (
<div className="h-full flex flex-col items-center justify-center animate-fade-in-up transition-all duration-700">
<div className="mb-12 relative group">
<div className="absolute inset-0 bg-primary/10 blur-[120px] rounded-full opacity-30 group-hover:opacity-60 transition-opacity duration-1000" />
<div className="relative w-24 h-24 md:w-32 md:h-32 bg-white/[0.02] rounded-[2rem] flex items-center justify-center border border-white/[0.05] overflow-hidden backdrop-blur-sm">
<div className="w-16 h-16 bg-primary/5 rounded-2xl flex items-center justify-center border border-primary/10 relative z-10 transition-transform duration-500 group-hover:scale-110 shadow-inner">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#d9ff00" strokeWidth="1.5">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
<line x1="8" y1="21" x2="16" y2="21" />
<line x1="12" y1="17" x2="12" y2="21" />
</svg>
</div>
</div>
</div>
<h1 className="text-3xl sm:text-5xl md:text-6xl font-extrabold text-white tracking-tight mb-4 text-center px-4">
<span className="text-white/40 font-medium uppercase tracking-widest">START CREATING WITH</span>
<br />
<span className="text-white uppercase tracking-tight">MARKETING STUDIO</span>
</h1>
<p className="text-white/40 text-sm md:text-base font-medium tracking-wide text-center max-w-lg leading-relaxed px-6">
Describe your scene, upload your product, and watch high-converting AI video ads come to life.
</p>
</div>
)}
</div>
{/* ── BOTTOM PROMPT BAR ── */}
<div style={{ animationDelay: "0.2s" }} className="absolute bottom-4 w-full max-w-[95%] lg:max-w-4xl z-40 animate-fade-in-up">
<div className="bg-[#0a0a0a]/80 backdrop-blur-3xl rounded-lg border border-white/10 p-4 flex flex-col gap-2 shadow-4xl">
{additionalImages.length > 0 && (
<div className="flex items-center gap-1.5">
{additionalImages.map((img, idx) => (
<div key={idx} className="relative group/img flex-shrink-0">
<img src={img} className="w-9 h-9 rounded-full object-cover border border-white/10" />
<button
onClick={() => setAdditionalImages(prev => prev.filter((_, i) => i !== idx))}
className="absolute -top-1 -right-1 w-3.5 h-3.5 bg-black/80 text-white rounded-full flex items-center justify-center opacity-0 group-hover/img:opacity-100 transition-opacity border border-white/10"
>
<CloseSvg />
</button>
</div>
))}
</div>
)}
{/* Top Row: Full-width Textarea */}
<div className="w-full relative">
<textarea
ref={textareaRef}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onInput={handleTextareaInput}
placeholder="Describe your ad script... Use @image1 for product, @image2 for avatar."
rows={1}
className="w-full bg-transparent border-none text-white text-sm placeholder:text-white/20 focus:outline-none resize-none pt-1 leading-relaxed min-h-[44px] max-h-[300px] custom-scrollbar font-medium"
/>
</div>
{/* Bottom Row: Uploads + Controls + Generate */}
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 pt-3 border-t border-white/[0.05]">
<div className="flex items-center gap-3 flex-wrap">
{/* Asset Uploads Group */}
<div className="flex items-center gap-1.5 pr-3 border-r border-white/10">
<UploadSlot
label="Product"
icon={<ProductIcon />}
url={productImage}
progress={uploadProgress.product}
onUpload={(e) => handleUpload(e, 'product')}
onClear={() => setProductImage(null)}
/>
<UploadSlot
label="Avatar"
icon={<AvatarIcon />}
url={avatarImage}
progress={uploadProgress.avatar}
onUpload={(e) => handleUpload(e, 'avatar')}
onClear={() => setAvatarImage(null)}
/>
<UploadSlot
label="References"
icon={<RefIcon />}
url={additionalImages[0]}
progress={uploadProgress.additional}
multiple
images={additionalImages}
onUpload={(e) => handleUpload(e, 'additional')}
onClear={(idx) => {
if (idx !== undefined) {
setAdditionalImages(prev => prev.filter((_, i) => i !== idx));
} else {
setAdditionalImages([]);
}
}}
/>
</div>
{/* Format Button */}
<div className="relative">
<button
onClick={(e) => { e.stopPropagation(); setDropdown(dropdown === 'format' ? null : 'format'); }}
className={`flex items-center gap-2 px-3 py-2 bg-white/[0.03] hover:bg-white/[0.08] rounded border transition-all group whitespace-nowrap ${dropdown === 'format' ? 'border-primary/50' : 'border-white/5'}`}
>
<div className="w-4 h-4 bg-primary/10 rounded flex items-center justify-center border border-primary/20">
<span className="text-[8px] font-black text-primary uppercase">U</span>
</div>
<span className="text-sm font-bold text-white/70 group-hover:text-primary transition-colors">{params.format}</span>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="4" className="opacity-20 group-hover:opacity-100 transition-opacity"><path d="M6 9l6 6 6-6" /></svg>
</button>
<Dropdown
isOpen={dropdown === 'format'}
title="Video Format Presets"
items={ASSETS.ugc}
selectedId={params.format}
onSelect={(item) => setParams({ ...params, format: item.name, videoUrl: item.url })}
onClose={() => setDropdown(null)}
isVideo
/>
</div>
{/* Avatar Preset Button */}
<div className="relative">
<button
onClick={(e) => { e.stopPropagation(); setDropdown(dropdown === 'avatar' ? null : 'avatar'); }}
className={`flex items-center gap-2 px-3 py-2 bg-white/[0.03] hover:bg-white/[0.08] rounded border transition-all group whitespace-nowrap ${dropdown === 'avatar' ? 'border-primary/50' : 'border-white/5'}`}
>
<div className="w-4 h-4 rounded-full overflow-hidden border border-white/20 shadow-inner">
<img src={avatarImage || ASSETS.avatar[0].url} className="w-full h-full object-cover" />
</div>
<span className="text-sm font-bold text-white/70 group-hover:text-primary transition-colors">
{ASSETS.avatar.find(a => a.url === avatarImage)?.name || "Select Avatar"}
</span>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="4" className="opacity-20 group-hover:opacity-100 transition-opacity"><path d="M6 9l6 6 6-6" /></svg>
</button>
<Dropdown
isOpen={dropdown === 'avatar'}
title="Avatar Presets"
items={ASSETS.avatar}
selectedId={avatarImage}
onSelect={(item) => setAvatarImage(item.url)}
onClose={() => setDropdown(null)}
/>
</div>
{/* Simple Controls */}
{['ratio', 'res', 'duration'].map(key => (
<div key={key} className="relative">
<button
onClick={(e) => { e.stopPropagation(); setDropdown(dropdown === key ? null : key); }}
className={`px-3 py-2 bg-white/[0.03] hover:bg-white/[0.08] rounded border transition-all text-sm font-bold ${dropdown === key ? 'border-primary/50 text-primary' : 'border-white/5 text-white/70'}`}
>
{key === 'duration' ? `${params[key]}s` : params[key]}
</button>
<SimpleDropdown
isOpen={dropdown === key}
title={key === 'res' ? 'Resolution' : key.toUpperCase()}
options={OPTIONS[key]}
selected={params[key]}
onSelect={(val) => setParams({ ...params, [key]: val })}
onClose={() => setDropdown(null)}
/>
</div>
))}
</div>
<button
onClick={handleGenerate}
disabled={isGenerating}
className="bg-primary text-black px-8 py-2.5 rounded font-bold text-base hover:bg-[#e5ff33] hover:scale-[1.02] active:scale-[0.98] transition-all flex items-center justify-center gap-3 shadow-glow disabled:opacity-50 disabled:grayscale z-10"
>
{isGenerating ? (
<>
<div className="w-3 h-3 border-2 border-black/20 border-t-black rounded-full animate-spin" />
Generating...
</>
) : (
<>
<span>Launch</span>
<div className="flex items-center gap-1 border-l border-black/10 pl-3">
<span className="text-[10px] opacity-70">{params.res === '1080p' ? params.duration * 0.675 : params.duration * 0.3}</span>
<span className="text-[8px] font-black opacity-40">$</span>
</div>
</>
)}
</button>
</div>
</div>
</div>
{/* Fullscreen Preview */}
{fullscreenUrl && (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/95 backdrop-blur-sm animate-fade-in" onClick={() => setFullscreenUrl(null)}>
<button className="absolute top-6 right-6 p-3 bg-white/10 hover:bg-white/20 rounded-full text-white border border-white/10 transition-colors shadow-2xl"><CloseSvg /></button>
<video src={fullscreenUrl} controls autoPlay className="max-w-[95vw] max-h-[95vh] rounded-lg shadow-4xl animate-scale-up" onClick={e => e.stopPropagation()} />
</div>
)}
</div>
);
}

View file

@ -0,0 +1,162 @@
"use client";
import React from 'react';
import { FaGithub, FaTerminal, FaPlug, FaStar, FaExternalLinkAlt } from 'react-icons/fa';
const FEATURES = [
{
tag: 'CLI',
title: 'muapi-cli',
icon: FaTerminal,
description:
'Generate images, videos, and audio from the terminal across 14+ AI models. Dual interface — colored human output plus JSON for agents (--output-json, --jq filtering). Async workflows, file uploads, credit tracking.',
code: `npm install -g muapi-cli
muapi auth login
muapi image generate "a cyberpunk city" \\
--model flux-dev`,
href: 'https://github.com/SamurAIGPT/muapi-cli',
},
{
tag: 'MCP',
title: 'muapi-mcp-server',
icon: FaPlug,
description:
'Connect Claude, Cursor, Windsurf, and any MCP-compatible assistant to 100+ generative models. Hosted endpoint — no install. 19 structured tools with input/output schemas, async polling, and account management.',
code: `claude mcp add --transport http muapi \\
https://api.muapi.ai/mcp \\
--header "Authorization: Bearer YOUR_KEY"`,
href: 'https://github.com/SamurAIGPT/muapi-mcp-server',
},
{
tag: 'Skills',
title: 'Generative Media Skills',
icon: FaStar,
description:
'Multimodal toolkit for Claude Code, Cursor, and Gemini CLI. Cinema Director, Nano-Banana, UI Designer, Logo Creator, Seedance 2, AI Clipping, and YouTube Shorts presets. Agent-native with JSON outputs and semantic exit codes.',
code: `npx skills add SamurAIGPT/Generative-Media-Skills --all`,
href: 'https://github.com/SamurAIGPT/Generative-Media-Skills',
},
];
const QUICK_STEPS = [
{ num: '1', title: 'Install the CLI', code: 'npm install -g muapi-cli' },
{ num: '2', title: 'Sign in', code: 'muapi auth login' },
{ num: '3', title: 'Add the skills', code: 'npx skills add SamurAIGPT/Generative-Media-Skills' },
];
const EXAMPLES = [
{ title: 'Image generation', code: 'muapi image generate "a serene mountain lake at sunrise" \\\n --model flux-dev --download ./outputs' },
{ title: 'Text-to-video', code: 'muapi video generate "a dog running on a beach" \\\n --model kling-master' },
{ title: 'Audio creation', code: 'muapi audio create "upbeat lo-fi hip hop for studying"' },
{ title: 'Run a skill', code: 'bash library/visual/nano-banana/scripts/\\\n generate-nano-art.sh --file image.jpg --view' },
];
function CodeBlock({ children, className = '' }) {
return (
<pre
className={`text-[11.5px] font-mono text-[#d9ff00] bg-black/50 border border-white/5 rounded-md px-3 py-2 overflow-x-auto whitespace-pre ${className}`}
>
{children}
</pre>
);
}
export default function McpCliStudio() {
return (
<div className="w-full h-full overflow-y-auto bg-[#050505] text-white">
<div className="max-w-5xl mx-auto px-6 py-12 flex flex-col gap-12">
{/* Hero */}
<section className="flex flex-col items-center text-center gap-4">
<div className="px-3 py-1 rounded-full border border-white/10 bg-white/5 text-[11px] font-bold uppercase tracking-widest text-white/60">
For developers &amp; AI agents
</div>
<h1 className="text-4xl md:text-5xl font-bold tracking-tight">MCP &amp; CLI</h1>
<p className="text-white/60 text-base md:text-lg max-w-2xl">
Use Open Higgsfield AI from your terminal, your IDE, or any MCP-compatible
assistant. Generate cinematic images, videos, and audio across 100+ models
without leaving your workflow.
</p>
</section>
{/* Quick start */}
<section className="rounded-2xl border border-white/5 bg-white/[0.02] p-6 md:p-8 flex flex-col gap-4">
<div className="flex items-center gap-2">
<span className="text-[11px] font-bold uppercase tracking-widest text-white/50">Quick start</span>
<div className="flex-1 h-px bg-white/5" />
</div>
<div className="grid md:grid-cols-3 gap-4">
{QUICK_STEPS.map((step) => (
<div
key={step.num}
className="rounded-xl border border-white/5 bg-white/[0.02] p-4 flex flex-col gap-2"
>
<div className="flex items-center gap-2">
<span className="w-6 h-6 rounded-full bg-white text-black text-xs font-bold flex items-center justify-center">
{step.num}
</span>
<span className="text-sm font-bold">{step.title}</span>
</div>
<CodeBlock className="text-[11.5px]">{step.code}</CodeBlock>
</div>
))}
</div>
</section>
{/* Feature cards */}
<section className="grid md:grid-cols-3 gap-4">
{FEATURES.map((f) => {
const Icon = f.icon;
return (
<a
key={f.title}
href={f.href}
target="_blank"
rel="noopener noreferrer"
className="rounded-2xl border border-white/5 bg-white/[0.02] p-6 flex flex-col gap-3 hover:bg-white/[0.04] hover:border-white/10 transition-colors group"
>
<div className="flex items-center justify-between">
<div className="w-10 h-10 rounded-xl bg-white/5 flex items-center justify-center text-white">
<Icon className="text-lg" />
</div>
<span className="text-[10px] font-bold uppercase tracking-widest text-white/50">{f.tag}</span>
</div>
<h3 className="text-lg font-bold">{f.title}</h3>
<p className="text-[13px] text-white/60 leading-relaxed">{f.description}</p>
<CodeBlock>{f.code}</CodeBlock>
<div className="mt-auto flex items-center gap-1.5 text-[12px] font-bold text-white/50 group-hover:text-white transition-colors">
<FaGithub className="text-sm" />
<span>View on GitHub</span>
<FaExternalLinkAlt className="text-[10px]" />
</div>
</a>
);
})}
</section>
{/* Examples */}
<section className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<span className="text-[11px] font-bold uppercase tracking-widest text-white/50">Examples</span>
<div className="flex-1 h-px bg-white/5" />
</div>
<div className="grid md:grid-cols-2 gap-4">
{EXAMPLES.map((ex) => (
<div
key={ex.title}
className="rounded-xl border border-white/5 bg-white/[0.02] p-4 flex flex-col gap-2"
>
<span className="text-[12px] font-bold text-white/80">{ex.title}</span>
<CodeBlock>{ex.code}</CodeBlock>
</div>
))}
</div>
</section>
<p className="text-center text-xs text-white/40 pb-4">
Open-source · MIT licensed · Works with Claude, Cursor, Windsurf, and Gemini CLI
</p>
</div>
</div>
);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,980 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useParams, useRouter } from "next/navigation";
import {
getTemplateWorkflows,
getUserWorkflows,
getPublishedWorkflows,
createWorkflow,
updateWorkflowName,
deleteWorkflow,
getWorkflowInputs,
executeWorkflow,
getAllNodeSchemas,
getWorkflowData,
} from "../muapi.js";
import dynamic from "next/dynamic";
const WorkflowUI = dynamic(() => import("./WorkflowUI"), {
ssr: false,
loading: () => (
<div className="absolute inset-0 flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<div className="w-12 h-12 border-4 border-white/5 border-t-[#d9ff00] rounded-full animate-spin" />
<div className="text-[10px] font-black text-white/20 uppercase tracking-widest">
Loading Builder...
</div>
</div>
</div>
),
});
function WorkflowCard({ workflow, onClick, activeTab, onRename, onDelete }) {
const [showOptions, setShowOptions] = useState(false);
return (
<div
onClick={() => onClick(workflow)}
className="group relative aspect-[3/4] rounded-lg overflow-hidden cursor-pointer border border-white/5 bg-[#0a0a0a] transition-all hover:border-[#d9ff00]/30 hover:scale-[1.02] shadow-2xl"
>
{workflow.thumbnail ? (
<img
src={workflow.thumbnail}
alt={workflow.name}
className="absolute inset-0 w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
/>
) : (
<div className="absolute inset-0 bg-gradient-to-br from-blue-500/10 to-purple-500/10 flex items-center justify-center">
<svg
width="40"
height="40"
viewBox="0 0 24 24"
fill="none"
stroke="white"
strokeWidth="1"
strokeLinecap="round"
strokeLinejoin="round"
className="opacity-20"
>
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
</svg>
</div>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/20 to-transparent" />
{/* Options Dropdown for My Workflows */}
{activeTab === 'my-workflows' && (
<div
className="absolute top-2 right-2 z-30"
onClick={(e) => { e.stopPropagation(); }}
>
<button
onClick={() => setShowOptions(!showOptions)}
onBlur={() => setTimeout(() => setShowOptions(false), 200)}
className="w-8 h-8 rounded-full bg-black/40 backdrop-blur-md border border-white/10 flex items-center justify-center text-white/60 hover:text-white transition-colors"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<circle cx="12" cy="5" r="1" /><circle cx="12" cy="12" r="1" /><circle cx="12" cy="19" r="1" />
</svg>
</button>
{showOptions && (
<div className="absolute top-10 right-0 w-32 bg-[#111] border border-white/10 rounded-lg shadow-2xl py-1 animate-in fade-in zoom-in duration-200">
<button
onClick={() => onRename(workflow)}
className="w-full px-4 py-2 text-left text-[11px] font-bold text-white/70 hover:text-[#d9ff00] hover:bg-white/5 transition-colors flex items-center gap-2"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
Rename
</button>
<button
onClick={() => onDelete(workflow.id)}
className="w-full px-4 py-2 text-left text-[11px] font-bold text-red-500 hover:bg-red-500/10 transition-colors flex items-center gap-2"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" />
</svg>
Delete
</button>
</div>
)}
</div>
)}
{/* Community Profile Info */}
{activeTab === 'published' && workflow.user_name && (
<div className="absolute top-2 left-2 z-20 flex items-center gap-2 bg-black/40 backdrop-blur-md px-2 py-1 rounded-full border border-white/10">
<img src={workflow.user_profile || "/user_profile.png"} alt="profile" className="w-4 h-4 rounded-full" />
<span className="text-[9px] font-black text-white/80 uppercase tracking-widest">{workflow.user_name}</span>
</div>
)}
<div className="absolute inset-x-0 bottom-0 p-4">
<div className="text-[10px] font-bold text-[#d9ff00] uppercase tracking-wider mb-1 opacity-80">
{workflow.category || "General"}
</div>
<h3 className="text-sm font-bold text-white truncate group-hover:text-[#d9ff00] transition-colors">
{workflow.name || "Untitled Flow"}
</h3>
</div>
</div>
);
}
export default function WorkflowStudio({ apiKey, isHeaderVisible = true, onToggleHeader }) {
const params = useParams();
const router = useRouter();
const slug = params?.slug || [];
const idFromParams = params?.id; // exists on /workflow/[id]/[tab] route
const tabFromParams = params?.tab; // exists on /workflow/[id]/[tab] route
// Robustly extract ID and Tab from either route structure
const getWorkflowInfo = useCallback(() => {
// Priority 1: Dedicated /workflow/[id]/[tab] route
if (idFromParams) {
return { id: idFromParams, tab: tabFromParams || null };
}
// Priority 2: Catch-all /studio/[[...slug]] route
const wfIndex = slug.findIndex(s => s === 'workflows' || s === 'workflow');
if (wfIndex === -1) return { id: null, tab: null };
return {
id: slug[wfIndex + 1] || null,
tab: slug[wfIndex + 2] || null
};
}, [slug, idFromParams, tabFromParams]);
const { id: urlWorkflowId, tab: urlTab } = getWorkflowInfo();
const [workflows, setWorkflows] = useState([]);
const [loading, setLoading] = useState(true);
const [selectedWorkflow, setSelectedWorkflow] = useState(null);
const [activeSubTab, setActiveSubTab] = useState("playground"); // 'playground' | 'builder'
const [activeMainTab, setActiveMainTab] = useState("templates"); // 'templates' | 'my-workflows' | 'published'
const [renamingWorkflow, setRenamingWorkflow] = useState(null);
const [newWorkflowName, setNewWorkflowName] = useState("");
const [isDeletingId, setIsDeletingId] = useState(null);
const [inputSchema, setInputSchema] = useState(null);
const [nodeSchemas, setNodeSchemas] = useState(null);
const [workflowDef, setWorkflowDef] = useState(null);
const [formData, setFormData] = useState({});
const [isExecuting, setIsExecuting] = useState(false);
const [result, setResult] = useState(null);
const [error, setError] = useState(null);
// Handlers defined early so they can be used in effects
const handleSelectWorkflow = useCallback(
async (wf, fromUrl = false) => {
setSelectedWorkflow(wf);
setResult(null);
setError(null);
const targetTab = urlTab || "playground";
setActiveSubTab(targetTab);
if (!fromUrl) {
// Always route to /workflow/[id] so the builder library's useParams().id resolves correctly
router.push(`/workflow/${wf.id}/${targetTab}`);
}
},
[router, urlTab],
);
// Dedicated data fetching effect for the active workflow
useEffect(() => {
if (!selectedWorkflow?.id || !apiKey) return;
async function loadWorkflowDetails() {
try {
setLoading(true);
const wfId = selectedWorkflow.id;
// Fetch everything in parallel with allSettled so one failure doesn't block the others
const results = await Promise.allSettled([
getWorkflowInputs(apiKey, wfId),
getAllNodeSchemas(apiKey, wfId),
getWorkflowData(apiKey, wfId)
]);
// Process Input Schema
if (results[0].status === 'fulfilled') {
const response = results[0].value;
const schema = response.input_data || response;
setInputSchema(schema);
const initial = {};
Object.entries(schema.properties || {}).forEach(([key, prop]) => {
initial[key] =
prop.default ||
(Array.isArray(prop.examples) ? prop.examples[0] : prop.examples) ||
"";
});
setFormData(initial);
} else {
console.warn("Input schema not available for this workflow:", results[0].reason);
setInputSchema(null);
setFormData({});
}
// Process Builder State
const nodes = results[1].status === 'fulfilled' ? results[1].value : [];
const def = results[2].status === 'fulfilled' ? results[2].value : { nodes: [], edges: [] };
setNodeSchemas(nodes);
setWorkflowDef(def);
if (results[1].status === 'rejected' || results[2].status === 'rejected') {
console.error("Builder components failed to load:", results[1].reason, results[2].reason);
if (!nodes.length && !def.nodes?.length) {
setError("Failed to load full builder data. Some features may be disabled.");
}
}
} catch (err) {
console.error("Critical error loading pulse details:", err);
setError("Critical error loading builder: " + err.message);
setNodeSchemas([]);
setWorkflowDef({ nodes: [], edges: [] });
} finally {
setLoading(false);
}
}
loadWorkflowDetails();
}, [selectedWorkflow?.id, apiKey]);
const handleCreateWorkflow = useCallback(
async (fromUrl = false) => {
try {
setLoading(true);
if (!fromUrl) {
const payload = {
workflow_id: null,
name: "Untitled Workflow",
edges: [],
data: { nodes: [] },
};
const response = await createWorkflow(apiKey, payload);
// Route to /workflow/[id] so useParams().id works in the builder library
router.push(`/workflow/${response.workflow_id}/builder`);
return;
}
// Initialize state for the new flow
setSelectedWorkflow({ id: null, name: "Untitled Workflow" });
setNodeSchemas([]);
setWorkflowDef({ nodes: [], edges: [] });
setActiveSubTab("builder");
} catch (err) {
setError("Failed to initialize workflow: " + err.message);
} finally {
setLoading(false);
}
},
[apiKey, router],
);
const handleDeleteWorkflow = async (wfId) => {
if (!confirm("Are you sure you want to delete this workflow?")) return;
setIsDeletingId(wfId);
try {
await deleteWorkflow(apiKey, wfId);
setWorkflows((prev) => prev.filter((w) => w.id !== wfId));
} catch (err) {
console.error("Delete failed:", err);
alert("Failed to delete workflow");
} finally {
setIsDeletingId(null);
}
};
const handleRenameWorkflow = async (e) => {
e?.preventDefault();
if (!renamingWorkflow || !newWorkflowName.trim()) return;
const wfId = renamingWorkflow.id;
try {
await updateWorkflowName(apiKey, wfId, newWorkflowName);
setWorkflows((prev) =>
prev.map((w) => (w.id === wfId ? { ...w, name: newWorkflowName } : w)),
);
if (selectedWorkflow?.id === wfId) {
setSelectedWorkflow({ ...selectedWorkflow, name: newWorkflowName });
}
setRenamingWorkflow(null);
} catch (err) {
console.error("Rename failed:", err);
alert("Failed to rename workflow");
}
};
// KEY FIX: If the user is on /studio/workflows/[id], redirect to /workflow/[id]
// so the builder library's useParams().id resolves correctly, preventing duplicate creation.
useEffect(() => {
if (typeof window !== 'undefined' && urlWorkflowId && urlWorkflowId !== 'new') {
const path = window.location.pathname;
if (path.startsWith('/studio/workflows/')) {
const tab = urlTab || 'builder';
router.replace(`/workflow/${urlWorkflowId}/${tab}`);
}
}
}, [urlWorkflowId, urlTab, router]);
// 1. Sync state with URL on mount or URL change
useEffect(() => {
if (loading) return;
if (urlWorkflowId) {
if (urlWorkflowId === "new") {
if (!selectedWorkflow || selectedWorkflow.id !== null) {
handleCreateWorkflow(true);
}
} else {
const found = workflows.find((wf) => wf.id === urlWorkflowId);
if (found) {
if (!selectedWorkflow || selectedWorkflow.id !== urlWorkflowId) {
handleSelectWorkflow(found, true);
}
} else if (
!selectedWorkflow ||
selectedWorkflow.id !== urlWorkflowId
) {
// Fallback for deep-linking: attempt to open even if not in the current tab's list
// handleSelectWorkflow fetches official name/data anyway
handleSelectWorkflow(
{ id: urlWorkflowId, name: "Loading..." },
true,
);
}
}
} else if (selectedWorkflow) {
setSelectedWorkflow(null);
}
}, [
urlWorkflowId,
workflows,
loading,
selectedWorkflow,
handleCreateWorkflow,
handleSelectWorkflow,
]);
// Handle reload on exit to clear builder CSS
useEffect(() => {
const fromBuilder = sessionStorage.getItem("fromWorkflowBuilder");
if (fromBuilder && (!urlWorkflowId || activeSubTab !== "builder")) {
sessionStorage.removeItem("fromWorkflowBuilder");
window.location.reload();
}
}, [urlWorkflowId, activeSubTab]);
useEffect(() => {
async function loadWorkflows() {
try {
setLoading(true);
let data = [];
if (activeMainTab === "templates") {
data = await getTemplateWorkflows(apiKey);
} else if (activeMainTab === "my-workflows") {
data = await getUserWorkflows(apiKey);
} else if (activeMainTab === "published") {
data = await getPublishedWorkflows(apiKey);
}
setWorkflows(data);
} catch (err) {
console.error("Failed to load workflows:", err);
setError("Failed to load workflows list.");
} finally {
setLoading(false);
}
}
loadWorkflows();
}, [apiKey, activeMainTab]);
const handleRun = async (e) => {
e.preventDefault();
if (isExecuting) return;
setIsExecuting(true);
setError(null);
setResult(null);
try {
const inputs = {};
Object.entries(formData).forEach(([key, value]) => {
if (!value) return;
if (key.startsWith("text")) inputs[key] = { prompt: value };
else if (key.startsWith("image")) inputs[key] = { image_url: value };
else if (key.startsWith("video")) inputs[key] = { video_url: value };
else inputs[key] = value;
});
const data = await executeWorkflow(apiKey, selectedWorkflow.id, inputs);
setResult(data);
} catch (err) {
console.error("Execution failed:", err);
setError(err.message || "Execution failed");
} finally {
setIsExecuting(false);
}
};
if (loading && !selectedWorkflow) {
return (
<div className="h-full flex items-center justify-center">
<div className="animate-spin text-[#d9ff00] text-3xl"></div>
</div>
);
}
if (selectedWorkflow) {
return (
<div className="h-full flex flex-col bg-[#030303] text-white">
{/* Immersive Sub-header / Floating Toggle */}
{isHeaderVisible ? (
<div className="flex-shrink-0 h-14 border-b border-white/5 flex items-center justify-between px-6 bg-black/40 z-30">
<div className="flex items-center gap-8 h-full">
<button
onClick={() => router.push("/studio/workflows")}
className="flex items-center gap-2 text-xs font-bold text-white/50 hover:text-white transition-colors"
type="button"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
All Workflows
</button>
<div className="h-4 w-[1px] bg-white/10" />
<div className="flex h-full">
<div className="flex bg-white/5 p-1 rounded-lg my-auto">
<button
onClick={() => {
setActiveSubTab("playground");
if (selectedWorkflow?.id) router.push(`/workflow/${selectedWorkflow.id}/playground`);
}}
type="button"
className={`px-4 py-1.5 text-[10px] font-black uppercase tracking-widest rounded-md transition-all ${
activeSubTab === "playground"
? "bg-[#d9ff00] text-black shadow-[0_0_15px_rgba(217,255,0,0.2)]"
: "text-white/40 hover:text-white"
}`}
>
Playground
</button>
<button
onClick={() => {
setActiveSubTab("builder");
if (selectedWorkflow?.id) router.push(`/workflow/${selectedWorkflow.id}/builder`);
}}
type="button"
className={`px-4 py-1.5 text-[10px] font-black uppercase tracking-widest rounded-md transition-all ${
activeSubTab === "builder"
? "bg-[#d9ff00] text-black shadow-[0_0_15px_rgba(217,255,0,0.2)]"
: "text-white/40 hover:text-white"
}`}
>
Full Workflow
</button>
</div>
</div>
</div>
<div className="flex items-center gap-3">
<span className="text-[11px] font-black text-[#d9ff00] uppercase tracking-widest">
{selectedWorkflow.name}
</span>
<button
onClick={() => onToggleHeader?.(false)}
className="p-1.5 bg-white/5 hover:bg-white/10 rounded-md transition-colors text-white/40 hover:text-white"
title="Enter Zen Mode"
type="button"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7" />
</svg>
</button>
</div>
</div>
) : (
/* Floating Immersive Mode Controller */
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-[100] flex items-center gap-4 px-4 py-2 bg-black/60 backdrop-blur-xl border border-white/10 rounded-full shadow-2xl animate-fade-in-down">
<button
onClick={() => router.push("/studio/workflows")}
className="p-1.5 text-white/40 hover:text-white transition-colors"
title="Back to All Workflows"
type="button"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
</button>
<div className="h-4 w-[1px] bg-white/10" />
<div className="flex bg-white/5 p-1 rounded-lg">
<button
onClick={() => setActiveSubTab("playground")}
type="button"
className={`px-3 py-1 text-[9px] font-black uppercase tracking-widest rounded-md transition-all ${
activeSubTab === "playground" ? "bg-[#d9ff00] text-black" : "text-white/40"
}`}
>
Play
</button>
<button
onClick={() => setActiveSubTab("builder")}
type="button"
className={`px-3 py-1 text-[9px] font-black uppercase tracking-widest rounded-md transition-all ${
activeSubTab === "builder" ? "bg-[#d9ff00] text-black" : "text-white/40"
}`}
>
Builder
</button>
</div>
<div className="h-4 w-[1px] bg-white/10" />
<button
onClick={() => onToggleHeader?.(true)}
className="px-3 py-1 bg-white/10 hover:bg-white/20 text-[9px] font-black text-white uppercase tracking-widest rounded-lg transition-colors flex items-center gap-2"
type="button"
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3"><path d="M4 14h6v6M20 10h-6V4M10 20l-7-7M14 4l7 7"/></svg>
Exit Zen
</button>
</div>
)}
<div className="flex-1 overflow-hidden flex flex-col lg:flex-row">
{activeSubTab === "playground" ? (
<>
{/* Controls Panel */}
<div className="w-full lg:w-[400px] border-r border-white/5 flex flex-col bg-black/20">
<div className="p-6 overflow-y-auto flex-1 custom-scrollbar">
<form onSubmit={handleRun} className="space-y-6">
<div>
<h3 className="text-xs font-black text-white/30 uppercase tracking-widest mb-4">
Configuration
</h3>
<div className="space-y-4">
{inputSchema &&
Object.entries(inputSchema.properties || {}).map(
([key, prop]) => (
<div key={key} className="space-y-2">
<label className="block text-[11px] font-bold text-white/80 uppercase tracking-wider">
{prop.title || key}
</label>
{prop.type === "string" && !prop.enum ? (
<textarea
value={formData[key] || ""}
onChange={(e) =>
setFormData({
...formData,
[key]: e.target.value,
})
}
className="w-full bg-white/5 border border-white/10 rounded-lg p-3 text-sm text-white focus:outline-none focus:border-[#d9ff00]/50 transition-colors min-h-[80px] resize-none"
placeholder={
prop.description || `Enter ${key}...`
}
/>
) : prop.enum ? (
<select
value={formData[key] || ""}
onChange={(e) =>
setFormData({
...formData,
[key]: e.target.value,
})
}
className="w-full bg-white/5 border border-white/10 rounded-lg p-3 text-sm text-white focus:outline-none focus:border-[#d9ff00]/50 transition-colors"
>
{prop.enum.map((opt) => (
<option
key={opt}
value={opt}
className="bg-black"
>
{opt}
</option>
))}
</select>
) : (
<input
type="text"
value={formData[key] || ""}
onChange={(e) =>
setFormData({
...formData,
[key]: e.target.value,
})
}
className="w-full bg-white/5 border border-white/10 rounded-lg p-3 text-sm text-white focus:outline-none focus:border-[#d9ff00]/50 transition-colors"
placeholder={
prop.description || `Enter ${key}...`
}
/>
)}
</div>
),
)}
</div>
</div>
<button
type="submit"
disabled={isExecuting || !selectedWorkflow.id}
className="w-full py-4 bg-[#d9ff00] text-black text-xs font-black uppercase tracking-[0.2em] rounded-xl hover:bg-white transition-all transform hover:scale-[1.02] active:scale-95 disabled:opacity-50 disabled:grayscale shadow-[0_0_30px_rgba(217,255,0,0.15)] flex items-center justify-center gap-3 mt-8"
>
{isExecuting ? (
<>
<div className="w-4 h-4 border-2 border-black/20 border-t-black rounded-full animate-spin" />
<span>Generating...</span>
</>
) : (
<>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
>
<path d="M5 3l14 9-14 9V3z" />
</svg>
<span>Run Workflow</span>
</>
)}
</button>
{!selectedWorkflow.id && (
<p className="text-[10px] text-white/30 text-center mt-4">
Save your workflow first to enable execution.
</p>
)}
</form>
</div>
</div>
{/* Preview Panel */}
<div className="flex-1 overflow-y-auto p-8 lg:p-12 bg-[#050505] flex items-center justify-center min-h-[500px]">
{error && (
<div className="w-full max-w-md p-6 bg-red-500/10 border border-red-500/20 rounded-2xl flex flex-col items-center gap-4 animate-shake">
<div className="w-12 h-12 bg-red-500/20 rounded-full flex items-center justify-center text-red-500">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
</div>
<div className="text-center">
<span className="text-[10px] font-black text-red-500 uppercase tracking-widest block mb-1">
Execution Error
</span>
<p className="text-white/60 text-sm leading-relaxed">
{error}
</p>
</div>
</div>
)}
{!isExecuting && !result && !error && (
<div className="flex flex-col items-center gap-6 opacity-40">
<div className="w-20 h-20 bg-white/5 rounded-3xl flex items-center justify-center text-white/20">
<svg
width="40"
height="40"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
</svg>
</div>
<p className="text-xs text-white/40 max-w-[200px] mx-auto text-center font-medium">
Configure parameters and run the workflow to see results.
</p>
</div>
)}
{isExecuting && (
<div className="flex flex-col items-center gap-6 animate-fade-in">
<div className="relative">
<div className="w-24 h-24 border-[3px] border-white/5 border-t-[#d9ff00] rounded-full animate-spin shadow-[0_0_40px_rgba(217,255,0,0.1)]" />
<div className="absolute inset-0 flex items-center justify-center text-[#d9ff00]">
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
className="animate-pulse"
>
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
</svg>
</div>
</div>
<div className="text-center space-y-2">
<div className="text-[10px] font-black text-[#d9ff00] uppercase tracking-[0.3em] animate-pulse">
Running Pipeline
</div>
<div className="text-[13px] text-white/40 font-medium">
Processing nodes and generating assets...
</div>
</div>
</div>
)}
{result && (
<div className="w-full max-w-4xl space-y-8 animate-fade-in-up">
<div className="flex items-center justify-between mb-2">
<h3 className="text-xs font-black text-white/30 uppercase tracking-widest">
Workflow Results
</h3>
<div className="flex items-center gap-2 px-3 py-1 bg-green-500/10 text-green-500 rounded-full text-[10px] font-bold border border-green-500/20">
<div className="w-1 h-1 bg-green-500 rounded-full animate-pulse" />{" "}
COMPLETED
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{result.outputs?.map((out, idx) => (
<div
key={idx}
className="group relative bg-white/5 border border-white/10 rounded-2xl overflow-hidden hover:border-[#d9ff00]/30 transition-all shadow-2xl"
>
{out.type === "image_url" ? (
<img
src={out.value}
className="w-full aspect-square object-cover"
alt="Output"
/>
) : out.type === "video_url" ? (
<video
src={out.value}
controls
className="w-full aspect-square object-cover"
/>
) : (
<div className="p-6 min-h-[200px] flex items-center justify-center italic text-white/60">
{out.value}
</div>
)}
<div className="absolute inset-x-0 bottom-0 p-4 bg-gradient-to-t from-black/80 to-transparent translate-y-full group-hover:translate-y-0 transition-transform">
<div className="flex items-center justify-between">
<span className="text-[10px] font-black text-[#d9ff00] uppercase tracking-widest">
{out.id}
</span>
<a
href={out.value}
target="_blank"
rel="noreferrer"
className="w-8 h-8 rounded-lg bg-white/10 flex items-center justify-center hover:bg-[#d9ff00] hover:text-black transition-colors"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
>
<path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6M15 3h6v6M10 14L21 3" />
</svg>
</a>
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
</>
) : (
<div className="flex-1 relative bg-[#050505]">
{nodeSchemas && workflowDef ? (
<WorkflowUI
workflowId={selectedWorkflow?.id}
initialNodeSchemas={nodeSchemas}
initialWorkflowData={{
...workflowDef,
// Inject ID to prevent builder from assuming this is a new unsaved flow
workflow_id: selectedWorkflow?.id
}}
/>
) : (
<div className="absolute inset-0 flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<div className="w-12 h-12 border-4 border-white/5 border-t-[#d9ff00] rounded-full animate-spin" />
<div className="text-[10px] font-black text-white/20 uppercase tracking-widest">
Loading Builder...
</div>
</div>
</div>
)}
</div>
)}
</div>
</div>
);
}
// Render main workflow list
return (
<div className="h-full w-full flex flex-col p-8 overflow-y-auto custom-scrollbar">
<div className="max-w-7xl mx-auto w-full">
<div className="flex flex-col gap-6 mb-12">
<div className="flex items-end justify-between">
<div>
<h1 className="text-3xl font-bold text-white mb-2 tracking-tight">
Workflows
</h1>
<p className="text-white/40 text-sm font-medium">
Create and manage your asynchronous AI processing pipelines
</p>
</div>
<button
onClick={() => handleCreateWorkflow()}
className="px-6 py-3 bg-[#d9ff00] text-black text-xs font-black uppercase tracking-widest rounded-lg hover:bg-white transition-all transform hover:scale-105 active:scale-95 shadow-[0_0_20px_rgba(217,255,0,0.3)] flex items-center gap-2"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
Create Workflow
</button>
</div>
<div className="flex items-center gap-2 border-b border-white/5">
<button
onClick={() => setActiveMainTab("templates")}
className={`px-6 py-4 text-xs font-black uppercase tracking-[0.2em] transition-all border-b-2 ${
activeMainTab === "templates"
? "text-[#d9ff00] border-[#d9ff00]"
: "text-white/30 border-transparent hover:text-white"
}`}
>
Templates
</button>
<button
onClick={() => setActiveMainTab("my-workflows")}
className={`px-6 py-4 text-xs font-black uppercase tracking-[0.2em] transition-all border-b-2 ${
activeMainTab === "my-workflows"
? "text-[#d9ff00] border-[#d9ff00]"
: "text-white/30 border-transparent hover:text-white"
}`}
>
My Workflows
</button>
<button
onClick={() => setActiveMainTab("published")}
className={`px-6 py-4 text-xs font-black uppercase tracking-[0.2em] transition-all border-b-2 ${
activeMainTab === "published"
? "text-[#d9ff00] border-[#d9ff00]"
: "text-white/30 border-transparent hover:text-white"
}`}
>
Community
</button>
</div>
</div>
{loading ? (
<div className="py-20 flex items-center justify-center">
<div className="w-10 h-10 border-4 border-white/5 border-t-[#d9ff00] rounded-full animate-spin" />
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-6">
{workflows.map((wf) => (
<WorkflowCard
key={wf.id}
workflow={wf}
onClick={handleSelectWorkflow}
activeTab={activeMainTab}
onRename={(wf) => {
setRenamingWorkflow(wf);
setNewWorkflowName(wf.name);
}}
onDelete={handleDeleteWorkflow}
/>
))}
{!loading && workflows.length === 0 && (
<div className="col-span-full py-24 text-center border-2 border-dashed border-white/5 rounded-2xl bg-white/[0.02]">
<div className="text-white/20 text-sm font-medium italic">
No workflows found in this section.
</div>
</div>
)}
</div>
)}
</div>
{/* Rename Modal */}
{renamingWorkflow && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-6">
<div className="absolute inset-0 bg-black/80 backdrop-blur-md" onClick={() => setRenamingWorkflow(null)} />
<form
onSubmit={handleRenameWorkflow}
className="relative w-full max-w-sm bg-[#0a0a0a] border border-white/10 rounded-2xl p-8 shadow-2xl animate-in fade-in zoom-in duration-300"
>
<h3 className="text-xl font-bold text-white mb-2">Rename Workflow</h3>
<p className="text-white/40 text-sm mb-6">Enter a new descriptive name for your pipeline.</p>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-[10px] font-black text-[#d9ff00] uppercase tracking-widest">Workflow Name</label>
<input
autoFocus
type="text"
value={newWorkflowName}
onChange={(e) => setNewWorkflowName(e.target.value)}
placeholder="e.g. Cinematic Video Flow"
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-sm text-white focus:outline-none focus:border-[#d9ff00]/50 transition-colors"
/>
</div>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={() => setRenamingWorkflow(null)}
className="flex-1 px-4 py-3 text-xs font-black text-white/40 uppercase tracking-widest hover:text-white transition-colors"
>
Cancel
</button>
<button
type="submit"
className="flex-1 bg-[#d9ff00] text-black px-4 py-3 rounded-xl text-xs font-black uppercase tracking-widest hover:bg-white transition-all transform hover:scale-105 active:scale-95"
>
Save Name
</button>
</div>
</div>
</form>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,26 @@
"use client";
import React, { useEffect } from "react";
import { WorkflowBuilder } from "workflow-builder";
import "reactflow/dist/style.css";
import "react-toastify/dist/ReactToastify.css";
import "workflow-builder/dist/tailwind.css";
const WorkflowUI = ({ workflowId, initialNodeSchemas, initialWorkflowData }) => {
useEffect(() => {
sessionStorage.setItem("fromWorkflowBuilder", "true");
}, []);
return (
<div className="w-full h-full bg-black">
<WorkflowBuilder
workflowId={workflowId}
initialNodeSchemas={initialNodeSchemas}
initialWorkflowData={initialWorkflowData}
costType="dollars"
/>
</div>
);
};
export default WorkflowUI;

View file

@ -4,3 +4,9 @@ export { default as ImageStudio } from './components/ImageStudio';
export { default as VideoStudio } from './components/VideoStudio';
export { default as LipSyncStudio } from './components/LipSyncStudio';
export { default as CinemaStudio } from './components/CinemaStudio';
export { default as MarketingStudio } from './components/MarketingStudio';
export { default as WorkflowStudio } from './components/WorkflowStudio';
export { default as AgentStudio } from './components/AgentStudio';
export { default as AppsStudio } from './components/AppsStudio';
export { default as McpCliStudio } from './components/McpCliStudio';
export * from './muapi';

View file

@ -1823,6 +1823,50 @@ export const t2iModels = [
}
}
},
{
"id": "gpt-image-2",
"name": "Gpt Image 2",
"endpoint": "gpt-image-2-text-to-image",
"family": "gpt-2",
"inputs": {
"prompt": {
"examples": [
"A photorealistic product photo of a luxury watch resting on a slab of black marble, dramatic cinematic lighting with a soft rim glow, ultra-detailed metallic textures, shallow depth of field, studio quality."
],
"description": "Text prompt describing the image. Up to 20,000 characters supported.",
"type": "string",
"title": "Prompt",
"name": "prompt"
},
"aspect_ratio": {
"enum": [
"auto",
"1:1",
"16:9",
"9:16",
"4:3",
"3:4"
],
"title": "Aspect Ratio",
"name": "aspect_ratio",
"type": "string",
"description": "Aspect ratio of the output image.",
"default": "auto"
},
"resolution": {
"enum": [
"1K",
"2K",
"4K"
],
"title": "Resolution",
"name": "resolution",
"type": "string",
"description": "The target resolution of the generated image.",
"default": "2K"
}
}
},
{
"id": "wan2.6-text-to-image",
"name": "Wan2.6 Text To Image",
@ -2089,6 +2133,50 @@ export const t2iModels = [
"default": "basic"
}
}
},
{
"id": "minimax-image-01",
"name": "MiniMax Image 01",
"endpoint": "minimax-image-01",
"family": "minimax",
"inputs": {
"prompt": {
"type": "string",
"title": "Prompt",
"name": "prompt",
"description": "Text prompt describing the image to generate (max 1500 characters).",
"examples": [
"A serene mountain lake at sunset with golden reflections on the water, surrounded by pine forests and snow-capped peaks, photorealistic, 8k."
]
},
"aspect_ratio": {
"type": "string",
"title": "Aspect Ratio",
"name": "aspect_ratio",
"description": "Aspect ratio of the output image.",
"enum": [
"16:9",
"9:16",
"1:1",
"4:3",
"3:4",
"3:2",
"2:3",
"21:9"
],
"default": "1:1"
},
"num_images": {
"type": "int",
"title": "Number of images",
"name": "num_images",
"description": "Number of images to generate in a single request.",
"default": 1,
"minValue": 1,
"maxValue": 4,
"step": 1
}
}
}
];
@ -2271,6 +2359,16 @@ export const t2vModels = [
"resolution": { "enum": ["1080p"], "title": "Resolution", "name": "resolution", "type": "string", "description": "The resolution of the generated video.", "default": "1080p" }
}
},
{
"id": "veo3.1-lite-text-to-video",
"name": "Veo 3.1 Lite",
"inputs": {
"prompt": { "type": "string", "title": "Prompt", "name": "prompt", "description": "Text prompt describing the video." },
"aspect_ratio": { "enum": ["16:9", "9:16"], "title": "Aspect Ratio", "name": "aspect_ratio", "type": "string", "description": "Aspect ratio of the output video.", "default": "16:9" },
"duration": { "enum": [8], "title": "Duration", "name": "duration", "type": "int", "description": "The duration of the generated video in seconds", "default": 8 },
"resolution": { "enum": ["1080p"], "title": "Resolution", "name": "resolution", "type": "string", "description": "The resolution of the generated video.", "default": "1080p" }
}
},
{
"id": "runway-text-to-video",
"name": "Runway Gen-3",
@ -3748,7 +3846,7 @@ export const i2iModels = [
{
"id": "higgsfield-soul-image-to-image",
"name": "Higgsfield Soul Image To Image",
"endpoint": "higgsfield-soul-image-to-image",
"endpoint": "hf-soul-image-to-image",
"family": "higgsfield",
"imageField": "image_url",
"hasPrompt": true,
@ -4499,6 +4597,53 @@ export const i2iModels = [
}
}
},
{
"id": "gpt-image-2-edit",
"name": "Gpt Image 2 Edit",
"endpoint": "gpt-image-2-image-to-image",
"family": "gpt-2",
"imageField": "images_list",
"hasPrompt": true,
"maxImages": 16,
"inputs": {
"prompt": {
"type": "string",
"title": "Prompt",
"name": "prompt",
"description": "Text prompt describing the transformation. Up to 20,000 characters supported.",
"examples": [
"Transform these product photos into a professional lifestyle scene with warm cinematic lighting, soft natural shadows, and a clean modern background; keep brand details and proportions unchanged."
]
},
"aspect_ratio": {
"type": "string",
"title": "Aspect Ratio",
"name": "aspect_ratio",
"description": "Aspect ratio of the output image.",
"enum": [
"auto",
"1:1",
"16:9",
"9:16",
"4:3",
"3:4"
],
"default": "auto"
},
"resolution": {
"type": "string",
"title": "Resolution",
"name": "resolution",
"description": "The target resolution of the generated image.",
"enum": [
"1K",
"2K",
"4K"
],
"default": "2K"
}
}
},
{
"id": "gpt-image-1.5-edit",
"name": "Gpt Image 1.5 Edit",
@ -5438,6 +5583,7 @@ export const i2vModels = [
"endpoint": "kling-v2.1-master-i2v",
"family": "kling-v2.1",
"imageField": "image_url",
"lastImageField": "last_image",
"hasPrompt": true,
"inputs": {
"prompt": {
@ -5479,6 +5625,7 @@ export const i2vModels = [
"endpoint": "kling-v2.1-standard-i2v",
"family": "kling-v2.1",
"imageField": "image_url",
"lastImageField": "last_image",
"hasPrompt": true,
"inputs": {
"prompt": {
@ -5520,6 +5667,7 @@ export const i2vModels = [
"endpoint": "kling-v2.1-pro-i2v",
"family": "kling-v2.1",
"imageField": "image_url",
"lastImageField": "last_image",
"hasPrompt": true,
"inputs": {
"prompt": {
@ -5561,6 +5709,7 @@ export const i2vModels = [
"endpoint": "wan2.2-image-to-video",
"family": "wan2.2",
"imageField": "image_url",
"lastImageField": "last_image",
"hasPrompt": true,
"inputs": {
"prompt": {
@ -5787,6 +5936,7 @@ export const i2vModels = [
"endpoint": "minimax-hailuo-02-standard-i2v",
"family": "minimax-2",
"imageField": "image_url",
"lastImageField": "end_image_url",
"hasPrompt": true,
"inputs": {
"prompt": {
@ -5828,6 +5978,7 @@ export const i2vModels = [
"endpoint": "minimax-hailuo-02-pro-i2v",
"family": "minimax-2",
"imageField": "image_url",
"lastImageField": "end_image_url",
"hasPrompt": true,
"inputs": {
"prompt": {
@ -5927,6 +6078,7 @@ export const i2vModels = [
"endpoint": "seedance-lite-i2v",
"family": "bytedance",
"imageField": "image_url",
"lastImageField": "last_image",
"hasPrompt": true,
"inputs": {
"prompt": {
@ -6434,7 +6586,7 @@ export const i2vModels = [
{
"id": "higgsfield-dop-image-to-video",
"name": "Higgsfield Dop Image To Video",
"endpoint": "higgsfield-dop-image-to-video",
"endpoint": "hf-dop-image-to-video",
"family": "higgsfield",
"imageField": "image_url",
"hasPrompt": true,
@ -6608,6 +6760,7 @@ export const i2vModels = [
"endpoint": "veo3.1-image-to-video",
"family": "veo3.1",
"imageField": "image_url",
"lastImageField": "last_image",
"hasPrompt": true,
"inputs": {
"prompt": {
@ -6658,6 +6811,7 @@ export const i2vModels = [
"endpoint": "veo3.1-fast-image-to-video",
"family": "veo3.1",
"imageField": "image_url",
"lastImageField": "last_image",
"hasPrompt": true,
"inputs": {
"prompt": {
@ -6702,6 +6856,54 @@ export const i2vModels = [
}
}
},
{
"id": "veo3.1-lite-image-to-video",
"name": "Veo3.1 Lite Image To Video",
"endpoint": "veo3.1-lite-image-to-video",
"family": "veo3.1",
"imageField": "image_url",
"lastImageField": "last_image",
"hasPrompt": true,
"inputs": {
"prompt": {
"type": "string",
"title": "Prompt",
"name": "prompt",
"description": "Text prompt describing the video."
},
"aspect_ratio": {
"type": "string",
"title": "Aspect Ratio",
"name": "aspect_ratio",
"description": "Aspect ratio of the output video.",
"enum": [
"16:9",
"9:16"
],
"default": "16:9"
},
"duration": {
"type": "int",
"title": "Duration",
"name": "duration",
"description": "The duration of the generated video in seconds",
"enum": [
8
],
"default": 8
},
"resolution": {
"type": "string",
"title": "Resolution",
"name": "resolution",
"description": "The resolution of the generated video.",
"enum": [
"1080p"
],
"default": "1080p"
}
}
},
{
"id": "veo3.1-reference-to-video",
"name": "Veo3.1 Reference To Video",
@ -7241,6 +7443,7 @@ export const i2vModels = [
"endpoint": "kling-o1-image-to-video",
"family": "kling-o1",
"imageField": "image_url",
"lastImageField": "last_image",
"hasPrompt": true,
"inputs": {
"prompt": {
@ -7561,6 +7764,7 @@ export const i2vModels = [
"endpoint": "kling-o1-standard-image-to-video",
"family": "kling-o1",
"imageField": "image_url",
"lastImageField": "last_image",
"hasPrompt": true,
"inputs": {
"prompt": {
@ -7633,6 +7837,7 @@ export const i2vModels = [
"endpoint": "seedance-v1.5-pro-i2v",
"family": "seedance-v1.5-pro",
"imageField": "image_url",
"lastImageField": "last_image",
"hasPrompt": true,
"inputs": {
"prompt": {
@ -7703,6 +7908,7 @@ export const i2vModels = [
"endpoint": "seedance-v1.5-pro-i2v-fast",
"family": "seedance-v1.5-pro",
"imageField": "image_url",
"lastImageField": "last_image",
"hasPrompt": true,
"inputs": {
"prompt": {
@ -7813,6 +8019,7 @@ export const i2vModels = [
"endpoint": "kling-v3.0-pro-image-to-video",
"family": "kling-v3.0",
"imageField": "image_url",
"lastImageField": "last_image",
"hasPrompt": true,
"inputs": {
"prompt": {
@ -7849,6 +8056,7 @@ export const i2vModels = [
"endpoint": "kling-v3.0-standard-image-to-video",
"family": "kling-v3.0",
"imageField": "image_url",
"lastImageField": "last_image",
"hasPrompt": true,
"inputs": {
"prompt": {
@ -8020,6 +8228,37 @@ export const v2vModels = [
"videoField": "video_url",
"hasPrompt": false,
"description": "Remove watermarks, logos, captions, and unwanted text from videos."
},
{
"id": "kling-v2.6-std-motion-control",
"name": "Kling 2.6 Std Motion Control",
"endpoint": "kling-v2.6-std-motion-control",
"family": "kling",
"videoField": "video_url",
"imageField": "image_url",
"hasPrompt": true,
"promptRequired": true,
"description": "Kling v2.6 Pro Motion Control allows precise control over camera movement, subject motion, and scene dynamics during video generation."
},
{
"id": "kling-v3.0-std-motion-control",
"name": "Kling 3.0 Std Motion Control",
"endpoint": "kling-v3.0-std-motion-control",
"family": "kling",
"videoField": "video_url",
"imageField": "image_url",
"hasPrompt": true,
"description": "Kling V3.0 Standard Motion Control allows for precise control over the camera and subject movement in generated videos."
},
{
"id": "kling-v3.0-pro-motion-control",
"name": "Kling 3.0 Pro Motion Control",
"endpoint": "kling-v3.0-pro-motion-control",
"family": "kling",
"videoField": "video_url",
"imageField": "image_url",
"hasPrompt": true,
"description": "Kling V3.0 Pro Motion Control provides the highest level of detail and control for video generation."
}
];

View file

@ -1,6 +1,7 @@
import { getModelById, getVideoModelById, getI2IModelById, getI2VModelById, getV2VModelById, getLipSyncModelById } from './models.js';
const BASE_URL = 'https://api.muapi.ai';
const PROXY_WF_BASE = '/api/workflow';
async function pollForResult(requestId, key, maxAttempts = 900, interval = 2000) {
const pollUrl = `${BASE_URL}/api/v1/predictions/${requestId}/result`;
@ -53,8 +54,14 @@ export async function generateImage(apiKey, params) {
if (params.aspect_ratio) payload.aspect_ratio = params.aspect_ratio;
if (params.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 payload.image_url = null;
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);
}
@ -100,6 +107,10 @@ export async function generateI2V(apiKey, params) {
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) {
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;
@ -108,6 +119,32 @@ export async function generateI2V(apiKey, params) {
return submitAndPoll(endpoint, payload, apiKey, params.onRequestId, 900);
}
export async function generateMarketingStudioAd(apiKey, params) {
const endpoint = params.resolution === '1080p' ? 'sd-2-vip-omni-reference-1080p' : 'seedance-2-vip-omni-reference';
const payload = {
prompt: params.prompt,
aspect_ratio: params.aspect_ratio || '16:9',
duration: params.duration || 5,
images_list: params.images_list || [],
video_files: params.video_files || []
};
return submitAndPoll(endpoint, payload, apiKey, params.onRequestId, 900);
}
export async function processV2V(apiKey, params) {
const modelInfo = getV2VModelById(params.model);
const endpoint = modelInfo?.endpoint || params.model;
const videoField = modelInfo?.videoField || 'video_url';
const payload = { [videoField]: params.video_url };
if (modelInfo?.imageField && params.image_url) {
payload[modelInfo.imageField] = params.image_url;
}
if (modelInfo?.hasPrompt && params.prompt) {
payload.prompt = params.prompt;
}
return submitAndPoll(endpoint, payload, apiKey, params.onRequestId, 900);
}
export async function processLipSync(apiKey, params) {
const modelInfo = getLipSyncModelById(params.model);
const endpoint = modelInfo?.endpoint || params.model;
@ -115,27 +152,484 @@ export async function processLipSync(apiKey, params) {
if (params.audio_url) payload.audio_url = params.audio_url;
if (params.image_url) payload.image_url = params.image_url;
if (params.video_url) payload.video_url = params.video_url;
if (params.prompt) payload.prompt = params.prompt;
if (modelInfo?.hasPrompt) payload.prompt = params.prompt || '';
if (params.resolution) payload.resolution = params.resolution;
if (params.seed !== undefined && params.seed !== -1) payload.seed = params.seed;
return submitAndPoll(endpoint, payload, apiKey, params.onRequestId, 900);
}
export async function uploadFile(apiKey, file) {
const url = `${BASE_URL}/api/v1/upload_file`;
const formData = new FormData();
formData.append('file', file);
const response = await fetch(url, {
method: 'POST',
headers: { 'x-api-key': apiKey },
body: formData
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
}
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();
throw new Error(`File upload failed: ${response.status} - ${errText.slice(0, 100)}`);
throw new Error(`Failed to fetch balance: ${response.status} - ${errText.slice(0, 100)}`);
}
return await response.json();
}
export async function getTemplateWorkflows(apiKey) {
const response = await fetch(`${BASE_URL}/workflow/get-template-workflows`, {
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey
}
});
if (!response.ok) {
const errText = await response.text();
throw new Error(`Failed to fetch template workflows: ${response.status} - ${errText.slice(0, 100)}`);
}
return await response.json();
};
export async function getUserWorkflows(apiKey) {
const response = await fetch(`${BASE_URL}/workflow/get-workflow-defs`, {
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey
}
});
if (!response.ok) {
const errText = await response.text();
throw new Error(`Failed to fetch user workflows: ${response.status} - ${errText.slice(0, 100)}`);
}
return await response.json();
};
export async function getPublishedWorkflows(apiKey) {
const response = await fetch(`${BASE_URL}/workflow/get-published-workflows`, {
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey
}
});
if (!response.ok) {
const errText = await response.text();
throw new Error(`Failed to fetch published workflows: ${response.status} - ${errText.slice(0, 100)}`);
}
return await response.json();
};
// Agents — uses direct URL → https://api.muapi.ai/agents/...
export async function getTemplateAgents(apiKey) {
const response = await fetch(`${BASE_URL}/agents/templates/agents`, {
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey
}
});
if (!response.ok) {
const errText = await response.text();
throw new Error(`Failed to fetch template agents: ${response.status} - ${errText.slice(0, 100)}`);
}
const data = await response.json();
const fileUrl = data.url || data.file_url || data.data?.url;
if (!fileUrl) throw new Error('No URL returned from file upload');
return fileUrl;
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();
}

View file

@ -8,6 +8,8 @@ module.exports = {
'panel-bg': '#0a0a0a',
'card-bg': '#111111',
primary: '#d9ff00',
secondary: '#a1a1aa',
muted: '#52525b',
},
},
},

View file

@ -1,17 +1,17 @@
# Open Higgsfield AI: Technical Documentation & Context
# Open Generative AI: Technical Documentation & Context
This document serves as a comprehensive knowledge base for the Open Higgsfield AI project. It details the architecture, key components, API integration patterns, and state management strategies used in the application.
This document serves as a comprehensive knowledge base for the Open Generative AI project. It details the architecture, key components, API integration patterns, and state management strategies used in the application.
## 1. Project Vision & Overview
**Open Higgsfield AI** is an ambitious open-source project dedicated to **replicating the full functionality of the Higgsfield platform**.
**Open Generative AI** is an ambitious open-source project dedicated to **replicating the full functionality of the Higgsfield platform**.
- **Core Goal:** To build a feature-complete, self-hosted alternative to Higgsfield, starting with **Image Generation** (Nano) and expanding into **Video Generation** (Cinema) and other creative tools.
- **Current State:** The Image Studio ("Nano Banana Pro" interface) is fully operational, featuring a premium dark-mode UI, history management, and multi-model support via the [Muapi.ai](https://muapi.ai) engine.
- **Future Direction:** The architecture is designed to scale for video generation, model training interfaces, and advanced editing tools, mirroring the evolving capabilities of Higgsfield.
- **Stack:** Vite, Vanilla JavaScript, Tailwind CSS v4.
- **Repository:** `https://github.com/Anil-matcha/Open-Higgsfield-AI`
- **Repository:** `https://github.com/Anil-matcha/Open-Generative-AI`
- **Primary Branch:** `main`
## 2. Architecture & File Structure

View file

@ -0,0 +1,179 @@
/**
* Test script for MiniMax provider integration.
*
* Verifies that the MiniMax Image 01 model is correctly registered in models.js
* and that the model definition has the expected structure.
*
* Usage:
* node scripts/test_minimax_provider.js
*
* Set MUAPI_KEY env var to run the live API smoke test:
* MUAPI_KEY=your_key node scripts/test_minimax_provider.js
*/
import { readFileSync } from "fs";
import { fileURLToPath } from "url";
import { dirname, join } from "path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, "..");
// ── 1. Model registration check ──────────────────────────────────────────────
const modelsContent = readFileSync(
join(ROOT, "src", "lib", "models.js"),
"utf-8"
);
// Extract the t2iModels JSON array via a simple regex
const t2iMatch = modelsContent.match(/export const t2iModels = (\[[\s\S]*?\]);/);
if (!t2iMatch) {
console.error("FAIL: Could not parse t2iModels from src/lib/models.js");
process.exit(1);
}
let t2iModels;
try {
t2iModels = JSON.parse(t2iMatch[1]);
} catch (err) {
console.error("FAIL: t2iModels is not valid JSON:", err.message);
process.exit(1);
}
const minimaxModel = t2iModels.find((m) => m.id === "minimax-image-01");
if (!minimaxModel) {
console.error(
'FAIL: "minimax-image-01" not found in t2iModels.\n' +
"Expected it to be registered in src/lib/models.js."
);
process.exit(1);
}
// Validate required fields
const required = ["id", "name", "endpoint", "family", "inputs"];
for (const field of required) {
if (!minimaxModel[field]) {
console.error(`FAIL: minimax-image-01 is missing required field: ${field}`);
process.exit(1);
}
}
if (minimaxModel.family !== "minimax") {
console.error(
`FAIL: expected family "minimax", got "${minimaxModel.family}"`
);
process.exit(1);
}
if (!minimaxModel.inputs.prompt) {
console.error("FAIL: minimax-image-01 inputs missing 'prompt' field");
process.exit(1);
}
if (!minimaxModel.inputs.aspect_ratio?.enum?.includes("1:1")) {
console.error(
"FAIL: minimax-image-01 aspect_ratio enum does not include '1:1'"
);
process.exit(1);
}
console.log("PASS: minimax-image-01 is correctly registered in t2iModels");
console.log(
` endpoint=${minimaxModel.endpoint} family=${minimaxModel.family}`
);
console.log(
` aspect ratios: ${minimaxModel.inputs.aspect_ratio.enum.join(", ")}`
);
// ── 2. models_dump.json check ─────────────────────────────────────────────────
const dump = JSON.parse(
readFileSync(join(ROOT, "models_dump.json"), "utf-8")
);
const dumpEntry = dump.t2i?.find((m) => m.id === "minimax-image-01");
if (!dumpEntry) {
console.error(
'FAIL: "minimax-image-01" not found in models_dump.json t2i section'
);
process.exit(1);
}
console.log("PASS: minimax-image-01 found in models_dump.json");
// ── 3. Live API smoke test (optional) ────────────────────────────────────────
const apiKey = process.env.MUAPI_KEY;
if (!apiKey) {
console.log(
"\nINFO: Skipping live API test (set MUAPI_KEY env var to enable)."
);
console.log("\nAll checks passed.");
process.exit(0);
}
console.log("\nRunning live API smoke test against muapi.ai …");
const MUAPI_BASE = "https://api.muapi.ai";
async function testMiniMaxImageGeneration() {
const endpoint = minimaxModel.endpoint;
const url = `${MUAPI_BASE}/api/v1/${endpoint}`;
const payload = {
prompt: "A simple test: a red apple on a white background.",
aspect_ratio: "1:1",
num_images: 1,
};
console.log(`POST ${url}`);
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json", "x-api-key": apiKey },
body: JSON.stringify(payload),
});
if (!res.ok) {
const text = await res.text();
console.error(`FAIL: API returned ${res.status}: ${text.slice(0, 200)}`);
process.exit(1);
}
const data = await res.json();
const requestId = data.request_id || data.id;
if (!requestId) {
console.error("FAIL: No request_id in response:", JSON.stringify(data));
process.exit(1);
}
console.log(`PASS: Generation queued — request_id=${requestId}`);
// Poll for result (max 60 s)
const pollUrl = `${MUAPI_BASE}/api/v1/predictions/${requestId}/result`;
for (let i = 0; i < 30; i++) {
await new Promise((r) => setTimeout(r, 2000));
const poll = await fetch(pollUrl, {
headers: { "Content-Type": "application/json", "x-api-key": apiKey },
});
if (!poll.ok) continue;
const result = await poll.json();
const status = result.status?.toLowerCase();
if (status === "completed" || status === "succeeded" || status === "success") {
const imageUrl =
result.outputs?.[0] || result.url || result.output?.url;
console.log(`PASS: Generation complete — image URL: ${imageUrl}`);
console.log("\nAll checks passed.");
return;
}
if (status === "failed" || status === "error") {
console.error("FAIL: Generation failed:", result.error);
process.exit(1);
}
console.log(` Polling … status=${status}`);
}
console.error("FAIL: Timed out waiting for generation result.");
process.exit(1);
}
testMiniMaxImageGeneration().catch((err) => {
console.error("FAIL: Unexpected error:", err.message);
process.exit(1);
});

View file

@ -0,0 +1,24 @@
export function AgentStudio() {
const container = document.createElement('div');
container.className = 'w-full h-full flex flex-col items-center justify-center bg-app-bg text-white gap-4';
const icon = document.createElement('div');
icon.innerHTML = `<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" opacity="0.4">
<path d="M12 2a4 4 0 0 1 4 4 4 4 0 0 1-4 4 4 4 0 0 1-4-4 4 4 0 0 1 4-4"/>
<path d="M8 14a6 6 0 0 0-6 6h20a6 6 0 0 0-6-6H8z"/>
<path d="M9 9h.01M15 9h.01"/>
</svg>`;
const title = document.createElement('p');
title.textContent = 'Agent Studio';
title.className = 'text-lg font-bold opacity-60';
const sub = document.createElement('p');
sub.textContent = 'Available in the web app at open-generative-ai.com';
sub.className = 'text-sm opacity-40';
container.appendChild(icon);
container.appendChild(title);
container.appendChild(sub);
return container;
}

View file

@ -3,30 +3,30 @@ import { CAMERA_MAP, LENS_MAP, FOCAL_PERSPECTIVE, APERTURE_EFFECT } from '../lib
const ASSET_URLS = {
// CAMERA
"Modular 8K Digital": "/assets/cinema/modular_8k_digital.webp",
"Full-Frame Cine Digital": "/assets/cinema/full_frame_cine_digital.webp",
"Grand Format 70mm Film": "/assets/cinema/grand_format_70mm_film.webp",
"Studio Digital S35": "/assets/cinema/studio_digital_s35.webp",
"Classic 16mm Film": "/assets/cinema/classic_16mm_film.webp",
"Premium Large Format Digital": "/assets/cinema/premium_large_format_digital.webp",
"Modular 8K Digital": "./assets/cinema/modular_8k_digital.webp",
"Full-Frame Cine Digital": "./assets/cinema/full_frame_cine_digital.webp",
"Grand Format 70mm Film": "./assets/cinema/grand_format_70mm_film.webp",
"Studio Digital S35": "./assets/cinema/studio_digital_s35.webp",
"Classic 16mm Film": "./assets/cinema/classic_16mm_film.webp",
"Premium Large Format Digital": "./assets/cinema/premium_large_format_digital.webp",
// LENS
"Creative Tilt Lens": "/assets/cinema/creative_tilt_lens.webp",
"Compact Anamorphic": "/assets/cinema/compact_anamorphic.webp",
"Extreme Macro": "/assets/cinema/extreme_macro.webp",
"70s Cinema Prime": "/assets/cinema/70s_cinema_prime.webp",
"Classic Anamorphic": "/assets/cinema/classic_anamorphic.webp",
"Premium Modern Prime": "/assets/cinema/premium_modern_prime.webp",
"Warm Cinema Prime": "/assets/cinema/warm_cinema_prime.webp",
"Swirl Bokeh Portrait": "/assets/cinema/swirl_bokeh_portrait.webp",
"Vintage Prime": "/assets/cinema/vintage_prime.webp",
"Halation Diffusion": "/assets/cinema/halation_diffusion.webp",
"Clinical Sharp Prime": "/assets/cinema/clinical_sharp_prime.webp",
"Creative Tilt Lens": "./assets/cinema/creative_tilt_lens.webp",
"Compact Anamorphic": "./assets/cinema/compact_anamorphic.webp",
"Extreme Macro": "./assets/cinema/extreme_macro.webp",
"70s Cinema Prime": "./assets/cinema/70s_cinema_prime.webp",
"Classic Anamorphic": "./assets/cinema/classic_anamorphic.webp",
"Premium Modern Prime": "./assets/cinema/premium_modern_prime.webp",
"Warm Cinema Prime": "./assets/cinema/warm_cinema_prime.webp",
"Swirl Bokeh Portrait": "./assets/cinema/swirl_bokeh_portrait.webp",
"Vintage Prime": "./assets/cinema/vintage_prime.webp",
"Halation Diffusion": "./assets/cinema/halation_diffusion.webp",
"Clinical Sharp Prime": "./assets/cinema/clinical_sharp_prime.webp",
// APERTURE
"f/1.4": "/assets/cinema/f_1_4.webp",
"f/4": "/assets/cinema/f_4.webp",
"f/11": "/assets/cinema/f_11.webp"
"f/1.4": "./assets/cinema/f_1_4.webp",
"f/4": "./assets/cinema/f_4.webp",
"f/11": "./assets/cinema/f_11.webp"
};
export function CameraControls(onChange) {

View file

@ -1,7 +1,7 @@
import { muapi } from '../lib/muapi.js';
import { CameraControls } from './CameraControls.js';
import { buildNanoBananaPrompt, CAMERA_MAP, LENS_MAP } from '../lib/promptUtils.js';
import { buildNanoBananaPrompt, CAMERA_MAP, LENS_MAP, FOCAL_PERSPECTIVE, APERTURE_EFFECT } from '../lib/promptUtils.js';
import { AuthModal } from './AuthModal.js';
export function CinemaStudio() {
@ -17,6 +17,9 @@ export function CinemaStudio() {
focal: 35,
aperture: "f/1.4"
};
// Camera builder panel state
let showCameraBuilder = false;
// ==========================================
// 1. HERO SECTION (Empty State)
@ -180,6 +183,13 @@ export function CinemaStudio() {
createDropdown(['1K', '2K', '4K'], resBtn.dataset.value, (val) => { updateResBtn(val); }, resBtn);
};
settingsToolbar.appendChild(resBtn);
// Camera Builder Toggle Button
const cameraBuilderBtn = document.createElement('button');
cameraBuilderBtn.className = 'flex items-center gap-1.5 px-3 py-1.5 text-[10px] font-bold text-white/50 hover:text-white transition-colors bg-white/5 hover:bg-white/10 rounded-lg border border-white/5';
cameraBuilderBtn.setAttribute('data-tooltip', 'Quick camera builder');
cameraBuilderBtn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="6" width="20" height="12" rx="2"/><circle cx="12" cy="12" r="3"/></svg> Builder`;
settingsToolbar.appendChild(cameraBuilderBtn);
leftColumn.appendChild(settingsToolbar);
promptBar.appendChild(leftColumn);
@ -193,6 +203,7 @@ export function CinemaStudio() {
const summaryCard = document.createElement('button');
// Removed 'hidden' class, added 'flex' and refined width constraints for mobile
summaryCard.className = 'flex flex-col items-start justify-center px-4 py-2 bg-[#2a2a2a] rounded-xl border border-white/5 hover:border-white/20 transition-colors text-left flex-1 min-w-[100px] md:min-w-[140px] max-w-[240px] h-[56px] relative group overflow-hidden';
summaryCard.setAttribute('data-tooltip', 'Open camera settings');
// Dot indicator
const dot = document.createElement('div');
@ -224,6 +235,7 @@ export function CinemaStudio() {
// Generate Button
const generateBtn = document.createElement('button');
generateBtn.className = 'h-[56px] px-8 bg-[#d9ff00] text-black rounded-xl font-black text-xs uppercase hover:bg-white transition-colors shadow-lg disabled:opacity-50 disabled:cursor-not-allowed';
generateBtn.setAttribute('data-tooltip', 'Generate cinema shot');
generateBtn.innerHTML = `GENERATE ✨`;
rightGroup.appendChild(summaryCard);
@ -233,6 +245,114 @@ export function CinemaStudio() {
promptBarWrapper.appendChild(promptBar);
container.appendChild(promptBarWrapper);
// ==========================================
// 3B. CAMERA BUILDER PANEL (Collapsible)
// ==========================================
const cameraBuilderPanel = document.createElement('div');
cameraBuilderPanel.className = 'absolute bottom-8 left-4 right-4 md:left-1/2 md:-translate-x-1/2 md:w-full md:max-w-4xl z-20';
cameraBuilderPanel.style.display = 'none'; // Hidden by default
const builderCard = document.createElement('div');
builderCard.className = 'bg-[#1a1a1a] border border-white/10 rounded-2xl p-4 shadow-3xl';
builderCard.innerHTML = `
<div class="flex items-center justify-between mb-4">
<h4 class="text-xs font-bold text-white">Camera Builder</h4>
<button id="close-builder-btn" class="text-white/40 hover:text-white transition-colors">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
</button>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
<div class="flex flex-col gap-1.5">
<label class="text-[10px] font-bold text-muted uppercase">Camera</label>
<select id="builder-camera" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-xs text-white focus:outline-none focus:border-primary/50">
${Object.keys(CAMERA_MAP).map(c => `<option value="${c}" ${c === currentSettings.camera ? 'selected' : ''}>${c}</option>`).join('')}
</select>
</div>
<div class="flex flex-col gap-1.5">
<label class="text-[10px] font-bold text-muted uppercase">Lens</label>
<select id="builder-lens" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-xs text-white focus:outline-none focus:border-primary/50">
${Object.keys(LENS_MAP).map(l => `<option value="${l}" ${l === currentSettings.lens ? 'selected' : ''}>${l}</option>`).join('')}
</select>
</div>
<div class="flex flex-col gap-1.5">
<label class="text-[10px] font-bold text-muted uppercase">Focal</label>
<select id="builder-focal" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-xs text-white focus:outline-none focus:border-primary/50">
${Object.keys(FOCAL_PERSPECTIVE).map(f => `<option value="${f}" ${f === currentSettings.focal ? 'selected' : ''}>${f}mm</option>`).join('')}
</select>
</div>
<div class="flex flex-col gap-1.5">
<label class="text-[10px] font-bold text-muted uppercase">Aperture</label>
<select id="builder-aperture" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-xs text-white focus:outline-none focus:border-primary/50">
${Object.keys(APERTURE_EFFECT).map(a => `<option value="${a}" ${a === currentSettings.aperture ? 'selected' : ''}>${a}</option>`).join('')}
</select>
</div>
</div>
<div class="flex flex-col gap-2">
<label class="text-[10px] font-bold text-muted uppercase">Preview</label>
<div id="builder-preview" class="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2.5 text-white text-xs min-h-[40px]"></div>
<button id="apply-builder-btn" class="px-4 py-2 bg-primary text-black rounded-lg text-xs font-bold hover:shadow-glow transition-all">
Use This Setup
</button>
</div>
`;
cameraBuilderPanel.appendChild(builderCard);
container.appendChild(cameraBuilderPanel);
// Camera Builder toggle logic
cameraBuilderBtn.onclick = () => {
showCameraBuilder = !showCameraBuilder;
cameraBuilderPanel.style.display = showCameraBuilder ? 'block' : 'none';
if (showCameraBuilder) updateBuilderPreview();
};
const closeBuilderBtn = cameraBuilderPanel.querySelector('#close-builder-btn');
if (closeBuilderBtn) closeBuilderBtn.onclick = () => {
showCameraBuilder = false;
cameraBuilderPanel.style.display = 'none';
};
// Update builder preview
const updateBuilderPreview = () => {
const camera = builderCard.querySelector('#builder-camera')?.value || currentSettings.camera;
const lens = builderCard.querySelector('#builder-lens')?.value || currentSettings.lens;
const focal = parseInt(builderCard.querySelector('#builder-focal')?.value || currentSettings.focal);
const aperture = builderCard.querySelector('#builder-aperture')?.value || currentSettings.aperture;
const preview = buildNanoBananaPrompt('', camera, lens, focal, aperture);
const previewEl = builderCard.querySelector('#builder-preview');
if (previewEl) {
previewEl.textContent = preview || 'Select camera settings to see preview...';
}
};
// Builder event listeners
const builderCamera = builderCard.querySelector('#builder-camera');
const builderLens = builderCard.querySelector('#builder-lens');
const builderFocal = builderCard.querySelector('#builder-focal');
const builderAperture = builderCard.querySelector('#builder-aperture');
if (builderCamera) builderCamera.onchange = updateBuilderPreview;
if (builderLens) builderLens.onchange = updateBuilderPreview;
if (builderFocal) builderFocal.onchange = updateBuilderPreview;
if (builderAperture) builderAperture.onchange = updateBuilderPreview;
const applyBuilderBtn = builderCard.querySelector('#apply-builder-btn');
if (applyBuilderBtn) {
applyBuilderBtn.onclick = () => {
currentSettings.camera = builderCamera?.value || currentSettings.camera;
currentSettings.lens = builderLens?.value || currentSettings.lens;
currentSettings.focal = parseInt(builderFocal?.value || currentSettings.focal);
currentSettings.aperture = builderAperture?.value || currentSettings.aperture;
updateSummaryCard();
showCameraBuilder = false;
cameraBuilderPanel.style.display = 'none';
};
}
// ==========================================
// 3. HISTORY SIDEBAR

View file

@ -27,7 +27,7 @@ export function Header(navigate) {
const menu = document.createElement('nav');
menu.className = 'hidden lg:flex items-center gap-6 text-[13px] font-bold text-secondary';
const items = ['Explore', 'Image', 'Video', 'Lip Sync', 'Edit', 'Character', 'Contests', 'Vibe Motion', 'Cinema Studio', 'AI Influencer', 'Apps', 'Assist', 'Community'];
const items = ['Image', 'Video', 'Lip Sync', 'Cinema Studio', 'Workflows', 'Agents', 'MCP & CLI'];
items.forEach(item => {
const link = document.createElement('a');
@ -41,10 +41,6 @@ export function Header(navigate) {
link.appendChild(dot);
}
if (item === 'Contests') {
link.innerHTML += ' <span class="bg-primary/10 text-primary text-[8px] px-1.5 py-0.5 rounded-full ml-1 border border-primary/20">New</span>';
}
link.onclick = () => {
// Remove active state from all
Array.from(menu.children).forEach(child => child.classList.remove('text-white'));
@ -55,6 +51,9 @@ export function Header(navigate) {
else if (item === 'Video') navigate('video');
else if (item === 'Lip Sync') navigate('lipsync');
else if (item === 'Cinema Studio') navigate('cinema');
else if (item === 'Workflows') navigate('workflows');
else if (item === 'Agents') navigate('agents');
else if (item === 'MCP & CLI') navigate('mcp-cli');
};
menu.appendChild(link);
@ -66,19 +65,21 @@ export function Header(navigate) {
const rightPart = document.createElement('div');
rightPart.className = 'flex items-center gap-4';
const keyBtn = document.createElement('button');
keyBtn.className = 'p-2 text-secondary hover:text-white transition-colors';
keyBtn.title = 'Update API Key';
keyBtn.innerHTML = `
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3m-3-3l-2.25-2.25"/>
const settingsBtn = document.createElement('button');
settingsBtn.className = 'flex items-center gap-2 px-3 py-1.5 rounded-md border border-white/10 bg-white/5 text-[13px] font-bold text-white/80 hover:text-white hover:bg-white/10 hover:border-white/20 transition-colors';
settingsBtn.title = 'Settings — API key, local models, preferences';
settingsBtn.innerHTML = `
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="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>
`;
keyBtn.onclick = () => {
settingsBtn.onclick = () => {
document.body.appendChild(SettingsModal());
};
rightPart.appendChild(keyBtn);
rightPart.appendChild(settingsBtn);
navBar.appendChild(leftPart);
navBar.appendChild(rightPart);

View file

@ -4,10 +4,24 @@ import {
i2iModels, getAspectRatiosForI2IModel, getResolutionsForI2IModel, getQualityFieldForI2IModel,
getMaxImagesForI2IModel
} from '../lib/models.js';
import { localAI, isLocalAIAvailable } from '../lib/localInferenceClient.js';
import { LOCAL_MODEL_CATALOG, getLocalModelById } from '../lib/localModels.js';
import { ENHANCE_TAGS, QUICK_PROMPTS } from '../lib/promptUtils.js';
import { AuthModal } from './AuthModal.js';
import { createUploadPicker } from './UploadPicker.js';
import { savePendingJob, removePendingJob, getPendingJobs } from '../lib/pendingJobs.js';
function createInlineInstructions(type) {
const el = document.createElement('div');
el.className = 'w-full text-center text-white/30 text-sm flex flex-col items-center gap-2 py-2';
const icon = type === 'image' ? '🖼️' : '🎬';
el.innerHTML = `
<p>${icon} Enter a prompt above and click <span class="text-primary font-semibold">Generate</span> to create your ${type}.</p>
<p class="text-xs text-white/20">Tip: Be descriptive include style, lighting, mood, and subject for best results.</p>
`;
return el;
}
export function ImageStudio() {
const container = document.createElement('div');
container.className = 'w-full h-full flex flex-col items-center justify-center bg-app-bg relative p-4 md:p-6 overflow-y-auto custom-scrollbar overflow-x-hidden';
@ -21,6 +35,33 @@ export function ImageStudio() {
let uploadedImageUrls = []; // array of uploaded image URLs (multi-image support)
let imageMode = false; // false = t2i models, true = i2i models
// Local inference state — only image-capable models surface here.
// sd.cpp uses type='sd1'|'sdxl'|'z-image'; Wan2GP image models use type='image'.
// Wan2GP video models (type='video') are hidden from ImageStudio.
const LOCAL_IMAGE_MODELS = LOCAL_MODEL_CATALOG.filter(m => m.type !== 'video');
let useLocalModel = false;
let selectedLocalModel = LOCAL_IMAGE_MODELS[0]?.id || null;
let localGenProgress = 0; // 01
// Advanced parameters state
let negativePrompt = '';
let guidanceScale = 7.5;
let steps = 25;
let seed = -1;
let showAdvanced = false;
let selectedStyle = 'None';
let batchCount = 1;
// New advanced controls
let customWidth = 0; // 0 means use default (aspect ratio based)
let customHeight = 0;
let referenceStrength = 50; // 0-100, for style reference models
let selectedLora = ''; // LoRA model ID from Civitai
let loraWeight = 1.0;
// Quick tools panel state
let showToolsPanel = false;
const getCurrentModels = () => imageMode ? i2iModels : t2iModels;
const getCurrentAspectRatios = (id) => imageMode ? getAspectRatiosForI2IModel(id) : getAspectRatiosForModel(id);
const getCurrentResolutions = (id) => imageMode ? getResolutionsForI2IModel(id) : getResolutionsForModel(id);
@ -71,6 +112,8 @@ export function ImageStudio() {
// --- Image Upload Picker (Image-to-Image) ---
const picker = createUploadPicker({
anchorContainer: container,
uploadFn: (file) => useLocalModel ? URL.createObjectURL(file) : muapi.uploadFile(file),
requireApiKey: () => !useLocalModel,
onSelect: ({ url, urls }) => {
uploadedImageUrls = urls || [url];
if (!imageMode) {
@ -127,10 +170,11 @@ export function ImageStudio() {
const controlsLeft = document.createElement('div');
controlsLeft.className = 'flex items-center gap-1.5 md:gap-2.5 relative overflow-x-auto no-scrollbar pb-1 md:pb-0';
const createControlBtn = (icon, label, id) => {
const createControlBtn = (icon, label, id, tooltip) => {
const btn = document.createElement('button');
btn.id = id;
btn.className = 'flex items-center gap-1.5 md:gap-2.5 px-3 md:px-4 py-2 md:py-2.5 bg-white/5 hover:bg-white/10 rounded-xl md:rounded-2xl transition-all border border-white/5 group whitespace-nowrap';
if (tooltip) btn.setAttribute('data-tooltip', tooltip);
btn.innerHTML = `
${icon}
<span id="${id}-label" class="text-xs font-bold text-white group-hover:text-primary transition-colors">${label}</span>
@ -143,26 +187,73 @@ export function ImageStudio() {
<div class="w-5 h-5 bg-primary rounded-md flex items-center justify-center shadow-lg shadow-primary/20">
<span class="text-[10px] font-black text-black">G</span>
</div>
`, selectedModelName, 'model-btn');
`, selectedModelName, 'model-btn', 'Select AI generation model');
const arBtn = createControlBtn(`
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="opacity-60 text-secondary"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/></svg>
`, selectedAr, 'ar-btn');
`, selectedAr, 'ar-btn', 'Change aspect ratio');
const qualityBtn = createControlBtn(`
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="opacity-60 text-secondary"><path d="M6 2L3 6v15a2 2 0 002 2h14a2 2 0 002-2V6l-3-4H6z"/></svg>
`, '720p', 'quality-btn');
`, '720p', 'quality-btn', 'Set output quality');
// Local / API source toggle (only shown in Electron)
let localToggleBtn = null;
if (isLocalAIAvailable()) {
localToggleBtn = document.createElement('button');
localToggleBtn.id = 'local-toggle-btn';
localToggleBtn.className = 'flex items-center gap-1.5 px-3 py-2 rounded-xl transition-all border text-xs font-bold whitespace-nowrap';
const updateLocalToggleStyle = () => {
if (useLocalModel) {
localToggleBtn.className = 'flex items-center gap-1.5 px-3 py-2 rounded-xl transition-all border text-xs font-bold whitespace-nowrap bg-primary/20 border-primary/40 text-primary';
localToggleBtn.textContent = '⚡ Local';
} else {
localToggleBtn.className = 'flex items-center gap-1.5 px-3 py-2 rounded-xl transition-all border text-xs font-bold whitespace-nowrap bg-white/5 border-white/5 text-white/60 hover:bg-white/10';
localToggleBtn.textContent = '☁ API';
}
};
updateLocalToggleStyle();
localToggleBtn.onclick = (e) => {
e.stopPropagation();
useLocalModel = !useLocalModel;
updateLocalToggleStyle();
// Reflect active model in the button label
if (useLocalModel) {
const lm = getLocalModelById(selectedLocalModel);
if (lm) document.getElementById('model-btn-label').textContent = lm.name;
} else {
document.getElementById('model-btn-label').textContent = selectedModelName;
}
};
controlsLeft.appendChild(localToggleBtn);
}
controlsLeft.appendChild(modelBtn);
controlsLeft.appendChild(arBtn);
controlsLeft.appendChild(qualityBtn);
// Advanced options toggle button
const advancedBtn = createControlBtn(`
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="opacity-60 text-secondary"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06a1.65 1.65 0 001.82-.33 1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06a1.65 1.65 0 00-1.82.33A1.65 1.65 0 0019.4 9a1.65 1.65 0 00-1.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
`, 'Advanced', 'advanced-btn', 'Show advanced options');
controlsLeft.appendChild(advancedBtn);
// Quick Tools toggle button
const toolsBtn = createControlBtn(`
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="opacity-60 text-secondary"><path d="M14.7 6.3a1 1 0 000 1.4l1.6 1.6a1 1 0 001.4 0l3.77-3.77a6 6 0 01-7.94 7.94l-6.91 6.91a2.12 2.12 0 01-3-3l6.91-6.91a6 6 0 017.94-7.94l-3.76 3.76z"/></svg>
`, 'Tools', 'tools-btn', 'Quick starters & prompt enhancer');
controlsLeft.appendChild(toolsBtn);
// Show quality button if the default model has quality/resolution options
const _initResolutions = getResolutionsForModel(defaultModel.id);
qualityBtn.style.display = _initResolutions.length > 0 ? 'flex' : 'none';
if (_initResolutions.length > 0) document.getElementById('quality-btn-label').textContent = _initResolutions[0];
if (_initResolutions.length > 0) {
const qlabel = qualityBtn.querySelector('#quality-btn-label');
if (qlabel) qlabel.textContent = _initResolutions[0];
}
const generateBtn = document.createElement('button');
generateBtn.className = 'bg-primary text-black px-6 md:px-8 py-3 md:py-3.5 rounded-xl md:rounded-[1.5rem] font-black text-sm md:text-base hover:shadow-glow hover:scale-105 active:scale-95 transition-all flex items-center justify-center gap-2.5 w-full sm:w-auto shadow-lg';
generateBtn.setAttribute('data-tooltip', 'Generate AI image from prompt');
generateBtn.innerHTML = `Generate ✨`;
bottomRow.appendChild(controlsLeft);
@ -171,6 +262,445 @@ export function ImageStudio() {
promptWrapper.appendChild(bar);
container.appendChild(promptWrapper);
const inlineInstructions = createInlineInstructions('image');
inlineInstructions.classList.add('max-w-4xl', 'mt-8');
container.appendChild(inlineInstructions);
// Local generation progress bar (hidden until active)
const localProgressWrap = document.createElement('div');
localProgressWrap.className = 'w-full max-w-4xl mt-4 hidden flex-col gap-2';
localProgressWrap.id = 'local-progress-wrap';
localProgressWrap.innerHTML = `
<div class="flex items-center justify-between">
<span class="text-xs font-bold text-white/60">Generating locally...</span>
<span id="local-progress-pct" class="text-xs font-bold text-primary">0%</span>
</div>
<div class="h-1.5 rounded-full bg-white/10 overflow-hidden">
<div id="local-progress-fill" class="h-full bg-primary transition-all duration-200" style="width:0%"></div>
</div>
<div class="flex justify-end">
<button id="local-cancel-btn" class="text-xs text-red-400 hover:text-red-300 transition-colors">Cancel</button>
</div>
`;
container.appendChild(localProgressWrap);
localProgressWrap.querySelector('#local-cancel-btn')?.addEventListener('click', () => {
localAI.cancelGeneration();
localProgressWrap.classList.remove('flex');
localProgressWrap.classList.add('hidden');
generateBtn.disabled = false;
generateBtn.innerHTML = `Generate ✨`;
});
// ==========================================
// 3. QUICK TOOLS PANEL (Prompt Enhancer + Quick Starters)
// ==========================================
const toolsPanel = document.createElement('div');
toolsPanel.className = 'w-full max-w-4xl mt-6 animate-fade-in-up hidden';
toolsPanel.id = 'tools-panel';
// Build tools panel HTML
toolsPanel.innerHTML = `
<div class="bg-[#111]/90 backdrop-blur-xl border border-white/10 rounded-2xl p-5 flex flex-col gap-4">
<div class="flex items-center justify-between pb-3 border-b border-white/5">
<h3 class="text-sm font-bold text-white">Quick Tools</h3>
<button id="close-tools-btn" class="text-white/40 hover:text-white transition-colors">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
</button>
</div>
<div class="flex flex-col lg:flex-row gap-6">
<!-- Quick Starters Section -->
<div class="flex-1">
<h4 class="text-xs font-bold text-secondary uppercase tracking-wider mb-3">Quick Starters</h4>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2">
${QUICK_PROMPTS.map(q => `
<button class="quick-starter-btn px-3 py-2 rounded-lg text-xs font-bold bg-white/5 text-secondary hover:bg-white/10 hover:text-primary transition-all text-left border border-white/5 hover:border-primary/30" data-prompt="${q.prompt}">
${q.label}
</button>
`).join('')}
</div>
</div>
<!-- Prompt Enhancer Section -->
<div class="flex-1">
<h4 class="text-xs font-bold text-secondary uppercase tracking-wider mb-3">Prompt Enhancer</h4>
<div class="flex flex-col gap-3">
<input type="text" id="base-prompt-input"
placeholder="Enter base prompt..."
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-2.5 text-white text-sm placeholder:text-muted focus:outline-none focus:border-primary/50 transition-colors">
<div>
<label class="text-[10px] font-bold text-muted uppercase tracking-wider mb-2 block">Enhancement Tags</label>
<div id="enhance-tags-area" class="flex flex-wrap gap-1.5">
${Object.entries(ENHANCE_TAGS).map(([category, tags]) =>
tags.map(tag => `<button class="enhance-tag-btn px-2 py-1 rounded-full text-[10px] font-bold bg-white/5 text-secondary hover:bg-white/10 transition-all" data-tag="${tag}">${tag}</button>`).join('')
).join('')}
</div>
</div>
<div class="flex flex-col gap-2">
<label class="text-[10px] font-bold text-muted uppercase tracking-wider">Enhanced Prompt</label>
<div id="enhanced-prompt-display" class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-2.5 text-white text-xs min-h-[40px]"></div>
<div class="flex gap-2">
<button id="copy-enhanced-btn" class="px-3 py-1.5 rounded-lg text-xs font-bold bg-white/5 text-secondary hover:bg-white/10 transition-all">
Copy
</button>
<button id="use-enhanced-btn" class="px-3 py-1.5 rounded-lg text-xs font-bold bg-primary text-black hover:shadow-glow transition-all">
Use in Generator
</button>
</div>
</div>
</div>
</div>
</div>
</div>
`;
container.appendChild(toolsPanel);
// ==========================================
// 4. ADVANCED OPTIONS PANEL
// ==========================================
const STYLE_PRESETS = ['None', 'Photorealistic', 'Anime', 'Cinematic', 'Oil Painting', 'Watercolor', 'Digital Art', 'Concept Art', 'Cyberpunk'];
const advancedPanel = document.createElement('div');
advancedPanel.className = 'w-full max-w-4xl mt-6 animate-fade-in-up hidden';
advancedPanel.id = 'advanced-panel';
advancedPanel.innerHTML = `
<div class="bg-[#111]/90 backdrop-blur-xl border border-white/10 rounded-2xl p-5 flex flex-col gap-4">
<div class="flex items-center justify-between pb-3 border-b border-white/5">
<h3 class="text-sm font-bold text-white">Advanced Options</h3>
<button id="close-adv-btn" class="text-white/40 hover:text-white transition-colors">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
</button>
</div>
<!-- Style Presets -->
<div class="flex flex-col gap-2">
<label class="text-xs font-bold text-secondary uppercase tracking-wider">Style Preset</label>
<div class="flex gap-2 flex-wrap">
${STYLE_PRESETS.map(s => `<button class="style-preset-btn px-3 py-1.5 rounded-lg text-xs font-bold bg-white/5 text-secondary hover:bg-white/10 transition-all" data-style="${s}">${s}</button>`).join('')}
</div>
</div>
<!-- Negative Prompt -->
<div class="flex flex-col gap-2">
<label class="text-xs font-bold text-secondary uppercase tracking-wider">Negative Prompt</label>
<input type="text" id="negative-prompt-input"
placeholder="What to exclude from the image (e.g., blurry, distorted, watermark)"
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-2.5 text-white text-sm placeholder:text-muted focus:outline-none focus:border-primary/50 transition-colors">
</div>
<!-- Guidance Scale & Steps Row -->
<div class="flex gap-4 flex-wrap">
<div class="flex-1 min-w-[200px] flex flex-col gap-2">
<div class="flex items-center justify-between">
<label class="text-xs font-bold text-secondary uppercase tracking-wider">Guidance Scale</label>
<span id="guidance-value" class="text-xs font-bold text-primary">7.5</span>
</div>
<input type="range" id="guidance-slider" min="1" max="20" step="0.5" value="7.5"
class="w-full h-2 bg-white/10 rounded-lg appearance-none cursor-pointer accent-primary">
</div>
<div class="flex-1 min-w-[200px] flex flex-col gap-2">
<div class="flex items-center justify-between">
<label class="text-xs font-bold text-secondary uppercase tracking-wider">Steps</label>
<span id="steps-value" class="text-xs font-bold text-primary">25</span>
</div>
<input type="range" id="steps-slider" min="1" max="50" step="1" value="25"
class="w-full h-2 bg-white/10 rounded-lg appearance-none cursor-pointer accent-primary">
</div>
</div>
<!-- Seed -->
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<label class="text-xs font-bold text-secondary uppercase tracking-wider">Seed</label>
<button id="randomize-seed-btn" class="text-xs font-bold text-primary hover:text-primary/80 transition-colors">Randomize</button>
</div>
<input type="number" id="seed-input"
placeholder="-1 for random"
value="-1"
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-2.5 text-white text-sm placeholder:text-muted focus:outline-none focus:border-primary/50 transition-colors">
</div>
<!-- Batch Count -->
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<label class="text-xs font-bold text-secondary uppercase tracking-wider">Batch Count</label>
<span id="batch-value" class="text-xs font-bold text-primary">1</span>
</div>
<input type="range" id="batch-slider" min="1" max="4" step="1" value="1"
class="w-full h-2 bg-white/10 rounded-lg appearance-none cursor-pointer accent-primary">
</div>
<!-- Width & Height -->
<div class="flex gap-4 flex-wrap">
<div class="flex-1 min-w-[120px] flex flex-col gap-2">
<label class="text-xs font-bold text-secondary uppercase tracking-wider">Width</label>
<input type="number" id="width-input"
placeholder="Auto"
value=""
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-2.5 text-white text-sm placeholder:text-muted focus:outline-none focus:border-primary/50 transition-colors">
</div>
<div class="flex-1 min-w-[120px] flex flex-col gap-2">
<label class="text-xs font-bold text-secondary uppercase tracking-wider">Height</label>
<input type="number" id="height-input"
placeholder="Auto"
value=""
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-2.5 text-white text-sm placeholder:text-muted focus:outline-none focus:border-primary/50 transition-colors">
</div>
</div>
<!-- Reference Strength (for I2I models) -->
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<label class="text-xs font-bold text-secondary uppercase tracking-wider">Reference Strength</label>
<span id="reference-strength-value" class="text-xs font-bold text-primary">50%</span>
</div>
<input type="range" id="reference-strength-slider" min="0" max="100" step="5" value="50"
class="w-full h-2 bg-white/10 rounded-lg appearance-none cursor-pointer accent-primary">
<p class="text-xs text-muted">How much to preserve the reference image characteristics</p>
</div>
<!-- LoRA Model Selection -->
<div class="flex flex-col gap-2">
<label class="text-xs font-bold text-secondary uppercase tracking-wider">LoRA Model (Optional)</label>
<input type="text" id="lora-input"
placeholder="e.g., civitai:1642876@1864626"
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-2.5 text-white text-sm placeholder:text-muted focus:outline-none focus:border-primary/50 transition-colors">
<div class="flex items-center gap-2 mt-1">
<label class="text-xs font-bold text-secondary">LoRA Weight:</label>
<input type="number" id="lora-weight-input"
value="1.0" min="0" max="4" step="0.1"
class="w-20 bg-white/5 border border-white/10 rounded-xl px-3 py-1.5 text-white text-sm focus:outline-none focus:border-primary/50 transition-colors">
</div>
<p class="text-xs text-muted">Enter a LoRA model ID from Civitai (format: civitai:id@version)</p>
</div>
</div>
`;
container.appendChild(advancedPanel);
// Advanced panel toggle logic
const toggleAdvanced = () => {
showAdvanced = !showAdvanced;
advancedPanel.classList.toggle('hidden', !showAdvanced);
document.getElementById('advanced-btn-label').textContent = showAdvanced ? 'Less' : 'Advanced';
};
// Add tools panel and advanced panel to container first before accessing their elements
container.appendChild(toolsPanel);
container.appendChild(advancedPanel);
// Now set up event handlers after elements are in DOM
advancedBtn.onclick = toggleAdvanced;
const closeAdvBtn = advancedPanel.querySelector('#close-adv-btn');
if (closeAdvBtn) closeAdvBtn.onclick = toggleAdvanced;
// Quick Tools Panel toggle
const toggleTools = () => {
showToolsPanel = !showToolsPanel;
toolsPanel.classList.toggle('hidden', !showToolsPanel);
if (showToolsPanel) {
// Close advanced panel when opening tools
if (!showAdvanced) {
showAdvanced = true;
advancedPanel.classList.remove('hidden');
}
}
document.getElementById('tools-btn-label').textContent = showToolsPanel ? 'Tools' : 'Tools';
};
toolsBtn.onclick = toggleTools;
const closeToolsBtn = toolsPanel.querySelector('#close-tools-btn');
if (closeToolsBtn) closeToolsBtn.onclick = toggleTools;
// Quick Starter buttons
const quickStarterBtns = toolsPanel.querySelectorAll('.quick-starter-btn');
quickStarterBtns.forEach(btn => {
btn.onclick = () => {
const prompt = btn.dataset.prompt;
textarea.value = prompt;
textarea.style.height = 'auto';
const maxHeight = window.innerWidth < 768 ? 150 : 250;
textarea.style.height = Math.min(textarea.scrollHeight, maxHeight) + 'px';
// Close tools panel after selection
showToolsPanel = false;
toolsPanel.classList.add('hidden');
};
});
// Prompt Enhancer - selected tags state
const enhanceSelectedTags = new Set();
const basePromptInput = toolsPanel.querySelector('#base-prompt-input');
const enhancedPromptDisplay = toolsPanel.querySelector('#enhanced-prompt-display');
// Update enhanced prompt display
const updateEnhancedPrompt = () => {
const base = basePromptInput?.value?.trim() || '';
const tags = Array.from(enhanceSelectedTags).join(', ');
const enhanced = [base, tags].filter(p => p).join(', ');
if (enhancedPromptDisplay) {
enhancedPromptDisplay.textContent = enhanced || 'Your enhanced prompt will appear here...';
enhancedPromptDisplay.classList.toggle('text-muted', !enhanced);
}
};
// Base prompt input handler
if (basePromptInput) {
basePromptInput.oninput = updateEnhancedPrompt;
}
// Enhance tag buttons
const enhanceTagBtns = toolsPanel.querySelectorAll('.enhance-tag-btn');
enhanceTagBtns.forEach(btn => {
btn.onclick = () => {
const tag = btn.dataset.tag;
if (enhanceSelectedTags.has(tag)) {
enhanceSelectedTags.delete(tag);
btn.classList.remove('bg-primary', 'text-black');
btn.classList.add('bg-white/5', 'text-secondary');
} else {
enhanceSelectedTags.add(tag);
btn.classList.remove('bg-white/5', 'text-secondary');
btn.classList.add('bg-primary', 'text-black');
}
updateEnhancedPrompt();
};
});
// Copy enhanced button
const copyEnhancedBtn = toolsPanel.querySelector('#copy-enhanced-btn');
if (copyEnhancedBtn) {
copyEnhancedBtn.onclick = () => {
const text = enhancedPromptDisplay?.textContent || '';
if (text && text !== 'Your enhanced prompt will appear here...') {
navigator.clipboard.writeText(text);
copyEnhancedBtn.textContent = 'Copied!';
setTimeout(() => { copyEnhancedBtn.textContent = 'Copy'; }, 1500);
}
};
}
// Use enhanced button
const useEnhancedBtn = toolsPanel.querySelector('#use-enhanced-btn');
if (useEnhancedBtn) {
useEnhancedBtn.onclick = () => {
const text = enhancedPromptDisplay?.textContent || '';
if (text && text !== 'Your enhanced prompt will appear here...') {
textarea.value = text;
textarea.style.height = 'auto';
const maxHeight = window.innerWidth < 768 ? 150 : 250;
textarea.style.height = Math.min(textarea.scrollHeight, maxHeight) + 'px';
// Close tools panel after use
showToolsPanel = false;
toolsPanel.classList.add('hidden');
}
};
}
// Negative prompt
const negPromptInput = advancedPanel.querySelector('#negative-prompt-input');
if (negPromptInput) negPromptInput.oninput = (e) => { negativePrompt = e.target.value; };
// Guidance scale slider
const guidanceSlider = advancedPanel.querySelector('#guidance-slider');
const guidanceValue = advancedPanel.querySelector('#guidance-value');
if (guidanceSlider && guidanceValue) {
guidanceSlider.oninput = (e) => {
guidanceScale = parseFloat(e.target.value);
guidanceValue.textContent = guidanceScale;
};
}
// Steps slider
const stepsSlider = advancedPanel.querySelector('#steps-slider');
const stepsValue = advancedPanel.querySelector('#steps-value');
if (stepsSlider && stepsValue) {
stepsSlider.oninput = (e) => {
steps = parseInt(e.target.value);
stepsValue.textContent = steps;
};
}
// Seed input
const seedInput = advancedPanel.querySelector('#seed-input');
if (seedInput) seedInput.oninput = (e) => { seed = parseInt(e.target.value) || -1; };
// Randomize seed button
const randSeedBtn = advancedPanel.querySelector('#randomize-seed-btn');
if (randSeedBtn) {
randSeedBtn.onclick = () => {
seed = Math.floor(Math.random() * 999999999);
if (seedInput) seedInput.value = seed;
};
}
// Batch count slider
const batchSlider = advancedPanel.querySelector('#batch-slider');
const batchValueEl = advancedPanel.querySelector('#batch-value');
if (batchSlider && batchValueEl) {
batchSlider.oninput = (e) => {
batchCount = parseInt(e.target.value);
batchValueEl.textContent = batchCount;
};
}
// Width input
const widthInput = advancedPanel.querySelector('#width-input');
if (widthInput) {
widthInput.oninput = (e) => {
customWidth = parseInt(e.target.value) || 0;
};
}
// Height input
const heightInput = advancedPanel.querySelector('#height-input');
if (heightInput) {
heightInput.oninput = (e) => {
customHeight = parseInt(e.target.value) || 0;
};
}
// Reference strength slider
const refStrengthSlider = advancedPanel.querySelector('#reference-strength-slider');
const refStrengthValue = advancedPanel.querySelector('#reference-strength-value');
if (refStrengthSlider && refStrengthValue) {
refStrengthSlider.oninput = (e) => {
referenceStrength = parseInt(e.target.value);
refStrengthValue.textContent = referenceStrength + '%';
};
}
// LoRA input
const loraInput = advancedPanel.querySelector('#lora-input');
if (loraInput) {
loraInput.oninput = (e) => {
selectedLora = e.target.value.trim();
};
}
// LoRA weight input
const loraWeightInput = advancedPanel.querySelector('#lora-weight-input');
if (loraWeightInput) {
loraWeightInput.oninput = (e) => {
loraWeight = parseFloat(e.target.value) || 1.0;
};
}
// Style preset handlers
advancedPanel.querySelectorAll('.style-preset-btn').forEach(btn => {
btn.onclick = () => {
selectedStyle = btn.dataset.style;
advancedPanel.querySelectorAll('.style-preset-btn').forEach(b => {
b.classList.remove('bg-primary/20', 'text-primary', 'border-primary/30');
b.classList.add('bg-white/5', 'text-secondary');
});
btn.classList.add('bg-primary/20', 'text-primary', 'border-primary/30');
btn.classList.remove('bg-white/5', 'text-secondary');
};
});
// ==========================================
// 3. DROPDOWNS (Professional implementation)
// ==========================================
@ -201,6 +731,48 @@ export function ImageStudio() {
const renderModels = (filter = '') => {
list.innerHTML = '';
if (useLocalModel) {
// ── Local model list (Wan2GP image-capable models only) ───
const filtered = LOCAL_IMAGE_MODELS.filter(m =>
m.name.toLowerCase().includes(filter.toLowerCase()) ||
m.id.toLowerCase().includes(filter.toLowerCase())
);
if (filtered.length === 0) {
list.innerHTML = `<div class="text-xs text-muted text-center py-4">No local models match</div>`;
return;
}
filtered.forEach(m => {
const item = document.createElement('div');
item.className = `flex items-center justify-between p-3.5 hover:bg-white/5 rounded-2xl cursor-pointer transition-all border border-transparent hover:border-white/5 ${selectedLocalModel === m.id ? 'bg-white/5 border-white/5' : ''}`;
item.innerHTML = `
<div class="flex items-center gap-3.5">
<div class="w-10 h-10 ${m.featured ? 'bg-primary/10 text-primary' : 'bg-green-500/10 text-green-400'} border border-white/5 rounded-xl flex items-center justify-center font-black text-sm shadow-inner uppercase">${m.featured ? '⚡' : m.name.charAt(0)}</div>
<div class="flex flex-col gap-0.5">
<div class="flex items-center gap-1.5">
<span class="text-xs font-bold text-white tracking-tight">${m.name}</span>
${m.featured ? '<span class="text-[9px] font-black px-1 py-0.5 rounded bg-primary/20 text-primary">FEATURED</span>' : ''}
</div>
<span class="text-[10px] text-muted">${m.type.toUpperCase()} · ${m.family}</span>
</div>
</div>
${selectedLocalModel === m.id ? '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#d9ff00" stroke-width="4"><polyline points="20 6 9 17 4 12"/></svg>' : ''}
`;
item.onclick = (e) => {
e.stopPropagation();
selectedLocalModel = m.id;
document.getElementById('model-btn-label').textContent = m.name;
selectedAr = m.aspectRatios[0];
document.getElementById('ar-btn-label').textContent = selectedAr;
qualityBtn.style.display = 'none';
closeDropdown();
};
list.appendChild(item);
});
return;
}
// ── Remote (API) model list ───────────────────────────────────
const filtered = getCurrentModels().filter(m => m.name.toLowerCase().includes(filter.toLowerCase()) || m.id.toLowerCase().includes(filter.toLowerCase()));
filtered.forEach(m => {
@ -595,6 +1167,74 @@ export function ImageStudio() {
}
}
// ── Local inference path ──────────────────────────────────────────────
if (useLocalModel) {
const lm = getLocalModelById(selectedLocalModel);
if (!lm) { alert('No local model selected.'); return; }
hero.classList.add('opacity-0', 'scale-95', '-translate-y-10', 'pointer-events-none');
generateBtn.disabled = true;
generateBtn.innerHTML = `<span class="animate-spin inline-block mr-2 text-black">◌</span> Generating...`;
const progressWrap = document.getElementById('local-progress-wrap');
const progressFill = document.getElementById('local-progress-fill');
const progressPct = document.getElementById('local-progress-pct');
progressWrap.classList.remove('hidden');
progressWrap.classList.add('flex');
const unsub = localAI.onProgress(({ progress, status }) => {
const pct = Math.round((progress ?? 0) * 100);
if (progressFill) progressFill.style.width = `${pct}%`;
if (progressPct) progressPct.textContent = status === 'starting' ? 'Starting...' : `${pct}%`;
generateBtn.innerHTML = `<span class="animate-spin inline-block mr-2 text-black">◌</span> ${status === 'starting' ? '...' : pct + '%'}`;
});
let hadError = false;
try {
const res = await localAI.generate({
model: selectedLocalModel,
prompt,
negative_prompt: negativePrompt || undefined,
aspect_ratio: selectedAr,
steps: steps,
guidance_scale: guidanceScale,
seed,
});
unsub();
progressWrap.classList.replace('flex', 'hidden');
progressWrap.classList.add('hidden');
if (!res?.url) throw new Error('No output returned from local generation');
if (res.mediaType === 'video') {
throw new Error('This model produces video — use the Video studio instead.');
}
addToHistory({
id: Date.now().toString(),
url: res.url,
prompt,
model: `local:${selectedLocalModel}`,
aspect_ratio: selectedAr,
seed: res.seed,
timestamp: new Date().toISOString()
});
showImageInCanvas(res.url);
} catch (e) {
hadError = true;
unsub();
progressWrap.classList.add('hidden');
console.error('[Local] generation error:', e);
hero.classList.remove('opacity-0', 'scale-95', '-translate-y-10', 'pointer-events-none');
console.error('[Local] full error:', e.message);
generateBtn.innerHTML = `Error: ${e.message.slice(0, 120)}`;
setTimeout(() => { generateBtn.innerHTML = `Generate ✨`; }, 6000);
} finally {
generateBtn.disabled = false;
if (!hadError) generateBtn.innerHTML = `Generate ✨`;
}
return;
}
// ── Remote API path ───────────────────────────────────────────────────
const apiKey = localStorage.getItem('muapi_key');
if (!apiKey) {
AuthModal(() => generateBtn.click());

View file

@ -0,0 +1,397 @@
import { localAI, isLocalAIAvailable } from '../lib/localInferenceClient.js';
// ─── Icons ────────────────────────────────────────────────────────────────────
const DownloadIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg>`;
const TrashIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6M14 11v6"/></svg>`;
const CheckIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>`;
// ─── Helpers ─────────────────────────────────────────────────────────────────
function fmtGB(gb) {
return gb >= 1 ? `${gb.toFixed(1)} GB` : `${(gb * 1024).toFixed(0)} MB`;
}
function tagEl(text) {
const span = document.createElement('span');
span.className = 'px-1.5 py-0.5 rounded-md text-[10px] font-bold bg-white/5 text-muted';
span.textContent = text;
return span;
}
// ─── Binary Status Bar ────────────────────────────────────────────────────────
function BinaryStatusBar(onStatusChange) {
const bar = document.createElement('div');
bar.className = 'flex items-center justify-between gap-3 p-3 rounded-xl bg-white/3 border border-white/5';
const label = document.createElement('div');
label.className = 'flex flex-col gap-0.5';
label.innerHTML = `
<span class="text-xs font-bold text-white">sd.cpp inference engine</span>
<span id="binary-status-text" class="text-[11px] text-muted">Checking...</span>
`;
const btn = document.createElement('button');
btn.id = 'binary-action-btn';
btn.className = 'px-3 py-1.5 rounded-lg text-xs font-bold transition-all hidden';
btn.textContent = 'Install';
bar.appendChild(label);
bar.appendChild(btn);
const progressBar = document.createElement('div');
progressBar.className = 'h-1 rounded-full bg-white/5 mt-2 hidden overflow-hidden';
progressBar.id = 'binary-progress-bar';
progressBar.innerHTML = `<div id="binary-progress-fill" class="h-full bg-primary transition-all" style="width:0%"></div>`;
bar.appendChild(progressBar);
const refresh = async () => {
const status = await localAI.getBinaryStatus();
const text = bar.querySelector('#binary-status-text');
if (status.exists) {
text.textContent = 'Installed and ready';
text.className = 'text-[11px] text-green-400';
btn.classList.add('hidden');
} else {
text.textContent = 'Not installed — required for local generation';
text.className = 'text-[11px] text-yellow-400';
btn.textContent = 'Install Engine';
btn.className = 'px-3 py-1.5 rounded-lg text-xs font-bold bg-primary text-black transition-all';
btn.classList.remove('hidden');
}
if (onStatusChange) onStatusChange(status.exists);
};
btn.onclick = async () => {
btn.disabled = true;
btn.textContent = 'Downloading...';
progressBar.classList.remove('hidden');
const unsub = localAI.onDownloadProgress(({ id, phase, progress }) => {
if (id !== '__binary__') return;
const fill = document.getElementById('binary-progress-fill');
const text = bar.querySelector('#binary-status-text');
if (fill) fill.style.width = `${Math.round(progress * 100)}%`;
if (text) text.textContent = phase === 'extracting' ? 'Extracting...' : `Downloading... ${Math.round(progress * 100)}%`;
});
try {
await localAI.downloadBinary();
unsub();
progressBar.classList.add('hidden');
await refresh();
} catch (err) {
unsub();
const text = bar.querySelector('#binary-status-text');
if (text) text.textContent = `Error: ${err.message}`;
btn.disabled = false;
btn.textContent = 'Retry';
}
};
if (isLocalAIAvailable()) refresh();
return bar;
}
// ─── Auxiliary file row (text encoder / VAE for Z-Image) ─────────────────────
function AuxRow(label, auxKey, initStatus, onStateChange) {
const row = document.createElement('div');
row.className = 'flex items-center justify-between gap-2 px-3 py-2 rounded-lg bg-white/3 border border-white/5';
const isReady = initStatus === 'downloaded';
row.innerHTML = `
<div class="flex items-center gap-2 min-w-0">
${isReady
? `<span class="text-green-400 shrink-0">${CheckIcon}</span>`
: `<span class="text-yellow-400 shrink-0">!</span>`}
<span class="text-[11px] text-white truncate">${label}</span>
</div>
<div class="flex items-center gap-2 shrink-0">
${isReady
? `<span class="text-[10px] text-green-400">Ready</span>`
: `<button class="aux-dl-btn flex items-center gap-1 px-2.5 py-1 rounded-lg text-[11px] font-bold bg-primary/20 text-primary border border-primary/30 hover:bg-primary/30 transition-all">${DownloadIcon} Get</button>`}
</div>
<div class="aux-progress hidden w-full col-span-2 mt-1">
<div class="h-1 rounded-full bg-white/10 overflow-hidden">
<div class="aux-fill h-full bg-primary transition-all" style="width:0%"></div>
</div>
<span class="aux-text text-[10px] text-muted block mt-0.5">Downloading...</span>
</div>
`;
const btn = row.querySelector('.aux-dl-btn');
if (btn) {
btn.onclick = async () => {
btn.disabled = true;
btn.innerHTML = `<span class="animate-spin">◌</span>`;
const progWrap = row.querySelector('.aux-progress');
const progFill = row.querySelector('.aux-fill');
const progText = row.querySelector('.aux-text');
progWrap.classList.remove('hidden');
const auxId = auxKey === 'llm' ? '__llm__' : '__vae__';
const unsub = localAI.onDownloadProgress(({ id, phase, progress }) => {
if (id !== auxId) return;
progFill.style.width = `${Math.round(progress * 100)}%`;
progText.textContent = phase === 'done' ? 'Complete!' : `Downloading... ${Math.round(progress * 100)}%`;
});
try {
await localAI.downloadAuxiliary(auxKey);
unsub();
if (onStateChange) onStateChange();
} catch (err) {
unsub();
progText.textContent = `Error: ${err.message}`;
btn.disabled = false;
btn.innerHTML = `${DownloadIcon} Retry`;
}
};
}
return row;
}
// ─── Wan2GP Server Config ────────────────────────────────────────────────────
function Wan2gpConfigBar(onChange) {
const wrap = document.createElement('div');
wrap.className = 'flex flex-col gap-3 p-3 rounded-xl bg-white/3 border border-white/5';
wrap.innerHTML = `
<div class="flex flex-col gap-0.5">
<span class="text-xs font-bold text-white">Wan2GP server (optional)</span>
<span class="text-[11px] text-muted leading-relaxed">
Run <a href="https://github.com/deepbeepmeep/Wan2GP" target="_blank" class="text-primary hover:underline">Wan2GP</a>
on a CUDA box (<code class="text-primary/80">python wgp.py --listen --server-name 0.0.0.0</code>) to unlock video models from this UI.
</span>
</div>
<div class="flex items-center gap-2">
<input id="wan2gp-url" type="text" placeholder="http://127.0.0.1:7860"
class="flex-1 bg-white/5 border border-white/5 focus:border-primary/40 rounded-lg px-3 py-1.5 text-xs text-white placeholder-white/30 focus:outline-none"/>
<button id="wan2gp-test" class="px-3 py-1.5 rounded-lg text-xs font-bold bg-primary/20 text-primary border border-primary/30 hover:bg-primary/30 transition-all">Test</button>
<button id="wan2gp-save" class="px-3 py-1.5 rounded-lg text-xs font-bold bg-primary text-black hover:shadow-glow transition-all">Save</button>
</div>
<div id="wan2gp-status" class="text-[11px] text-muted">Not configured</div>
`;
const input = wrap.querySelector('#wan2gp-url');
const testBtn = wrap.querySelector('#wan2gp-test');
const saveBtn = wrap.querySelector('#wan2gp-save');
const statusEl = wrap.querySelector('#wan2gp-status');
const setStatus = (text, kind = 'muted') => {
const colorMap = { muted: 'text-muted', ok: 'text-green-400', warn: 'text-yellow-400', err: 'text-red-400' };
statusEl.className = `text-[11px] ${colorMap[kind] || colorMap.muted}`;
statusEl.textContent = text;
};
(async () => {
const cfg = await localAI.getWan2gpConfig();
if (cfg.url) {
input.value = cfg.url;
const r = await localAI.probeWan2gp(cfg.url);
setStatus(r.ok ? `Connected · Gradio ${r.version}` : `Saved URL not reachable: ${r.error}`, r.ok ? 'ok' : 'warn');
} else {
setStatus('Not configured (Wan2GP models will appear offline)', 'muted');
}
})();
testBtn.onclick = async () => {
const url = input.value.trim();
if (!url) { setStatus('Enter a URL first', 'warn'); return; }
setStatus('Probing...', 'muted');
testBtn.disabled = true;
try {
const r = await localAI.probeWan2gp(url);
setStatus(r.ok ? `Reachable · Gradio ${r.version}` : `Unreachable: ${r.error}`, r.ok ? 'ok' : 'err');
} finally { testBtn.disabled = false; }
};
saveBtn.onclick = async () => {
const url = input.value.trim();
saveBtn.disabled = true;
try {
await localAI.setWan2gpUrl(url);
const r = url ? await localAI.probeWan2gp(url) : { ok: false, error: 'cleared' };
setStatus(r.ok ? `Saved · Connected to Gradio ${r.version}` : (url ? `Saved, not reachable: ${r.error}` : 'Cleared'), r.ok ? 'ok' : 'warn');
onChange?.();
} finally { saveBtn.disabled = false; }
};
return wrap;
}
// ─── Model Card ───────────────────────────────────────────────────────────────
function Wan2gpModelCard(model) {
const card = document.createElement('div');
card.className = 'flex items-start justify-between gap-3 p-4 rounded-xl border border-white/5 bg-white/3';
const ready = !!model.ready;
card.innerHTML = `
<div class="flex flex-col gap-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-sm font-bold text-white truncate">${model.name}</span>
${ready ? `<span class="text-green-400">${CheckIcon}</span>` : ''}
</div>
<p class="text-[11px] text-muted leading-relaxed">${model.description}</p>
<div class="flex items-center gap-1.5 flex-wrap mt-1">
<span class="px-1.5 py-0.5 rounded-md text-[10px] font-bold ${model.type === 'video' ? 'bg-purple-500/15 text-purple-300' : 'bg-primary/10 text-primary'}">${model.type.toUpperCase()}</span>
<span class="px-1.5 py-0.5 rounded-md text-[10px] font-bold bg-white/5 text-muted">via Wan2GP</span>
${(model.tags || []).filter(t => !['featured', 'remote'].includes(t)).map(t => `<span class="px-1.5 py-0.5 rounded-md text-[10px] font-bold bg-white/5 text-muted">${t}</span>`).join('')}
</div>
</div>
<div class="shrink-0">
<span class="text-[10px] font-bold ${ready ? 'text-green-400' : 'text-yellow-400'}">${ready ? 'Available' : 'Server offline'}</span>
</div>
`;
return card;
}
function ModelCard(model, onStateChange) {
if (model.provider === 'wan2gp') return Wan2gpModelCard(model);
const card = document.createElement('div');
card.className = 'flex flex-col gap-3 p-4 rounded-xl border border-white/5 bg-white/3 hover:border-white/10 transition-all';
const isDownloaded = model.state === 'downloaded';
const auxStatus = model.auxiliaryStatus || {};
const auxReady = !model.requiresAuxiliary || (auxStatus.llm === 'downloaded' && auxStatus.vae === 'downloaded');
const fullyReady = isDownloaded && auxReady;
card.innerHTML = `
<div class="flex items-start justify-between gap-3">
<div class="flex flex-col gap-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-sm font-bold text-white truncate">${model.name}</span>
${model.featured ? `<span class="px-1.5 py-0.5 rounded-md text-[10px] font-black bg-primary/20 text-primary border border-primary/30">⚡ Featured</span>` : ''}
${fullyReady ? `<span class="text-green-400">${CheckIcon}</span>` : ''}
</div>
<p class="text-[11px] text-muted leading-relaxed">${model.description}</p>
<div class="flex items-center gap-1.5 flex-wrap mt-1">
<span class="px-1.5 py-0.5 rounded-md text-[10px] font-bold bg-primary/10 text-primary">${model.type.toUpperCase()}</span>
<span class="px-1.5 py-0.5 rounded-md text-[10px] font-bold bg-white/5 text-muted">${fmtGB(model.sizeGB)}</span>
${(model.tags || []).filter(t => t !== 'featured').map(t => `<span class="px-1.5 py-0.5 rounded-md text-[10px] font-bold bg-white/5 text-muted">${t}</span>`).join('')}
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
${isDownloaded
? `<button class="delete-btn p-2 rounded-lg text-red-400 hover:bg-red-500/10 transition-all">${TrashIcon}</button>`
: `<button class="download-btn flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold bg-primary text-black hover:shadow-glow transition-all">${DownloadIcon} Download</button>`
}
</div>
</div>
<div class="progress-wrap hidden">
<div class="h-1 rounded-full bg-white/10 overflow-hidden">
<div class="progress-fill h-full bg-primary transition-all" style="width:0%"></div>
</div>
<span class="progress-text text-[10px] text-muted mt-1 block">Preparing...</span>
</div>
${model.requiresAuxiliary ? `<div class="aux-section flex flex-col gap-1.5 pt-1 border-t border-white/5"></div>` : ''}
`;
// Auxiliary files section for Z-Image
if (model.requiresAuxiliary) {
const auxSection = card.querySelector('.aux-section');
auxSection.appendChild(document.createElement('span')).className = 'text-[10px] text-muted uppercase tracking-wider font-bold';
auxSection.querySelector('span').textContent = 'Required components';
auxSection.appendChild(AuxRow('Qwen3-4B Text Encoder (2.4 GB)', 'llm', auxStatus.llm, onStateChange));
auxSection.appendChild(AuxRow('FLUX VAE (335 MB)', 'vae', auxStatus.vae, onStateChange));
}
const progressWrap = card.querySelector('.progress-wrap');
const progressFill = card.querySelector('.progress-fill');
const progressText = card.querySelector('.progress-text');
const downloadBtn = card.querySelector('.download-btn');
if (downloadBtn) {
downloadBtn.onclick = async () => {
downloadBtn.disabled = true;
downloadBtn.innerHTML = `<span class="animate-spin">◌</span> Starting...`;
progressWrap.classList.remove('hidden');
const unsub = localAI.onDownloadProgress(({ id, phase, progress }) => {
if (id !== model.id) return;
progressFill.style.width = `${Math.round(progress * 100)}%`;
progressText.textContent = phase === 'done' ? 'Complete!' : `Downloading... ${Math.round(progress * 100)}%`;
});
try {
await localAI.downloadModel(model.id);
unsub();
if (onStateChange) onStateChange();
} catch (err) {
unsub();
progressText.textContent = `Error: ${err.message}`;
downloadBtn.disabled = false;
downloadBtn.innerHTML = `${DownloadIcon} Retry`;
}
};
}
const deleteBtn = card.querySelector('.delete-btn');
if (deleteBtn) {
deleteBtn.onclick = async () => {
if (!confirm(`Delete "${model.name}"? You'll need to re-download it to use it again.`)) return;
await localAI.deleteModel(model.id);
if (onStateChange) onStateChange();
};
}
return card;
}
// ─── Main component ───────────────────────────────────────────────────────────
export function LocalModelManager() {
const root = document.createElement('div');
root.className = 'flex flex-col gap-5';
if (!isLocalAIAvailable()) {
root.innerHTML = `
<div class="flex flex-col items-center gap-3 py-8 text-center">
<p class="text-sm font-bold text-white">Local Models</p>
<p class="text-xs text-muted max-w-xs">Local model inference is only available in the desktop app (Electron build). Use <span class="text-primary font-bold">npm run electron:build</span> to build.</p>
</div>
`;
return root;
}
// ── Section: engine status
const engineSection = document.createElement('div');
engineSection.className = 'flex flex-col gap-2';
engineSection.innerHTML = `<h3 class="text-xs font-bold text-secondary uppercase tracking-wider">Inference Engine</h3>`;
let binaryReady = false;
const binaryBar = BinaryStatusBar((ready) => { binaryReady = ready; });
engineSection.appendChild(binaryBar);
const wan2gpBar = Wan2gpConfigBar(() => renderModels());
engineSection.appendChild(wan2gpBar);
root.appendChild(engineSection);
// ── Section: models
const modelsSection = document.createElement('div');
modelsSection.className = 'flex flex-col gap-3';
modelsSection.innerHTML = `
<div class="flex items-center justify-between">
<h3 class="text-xs font-bold text-secondary uppercase tracking-wider">Local Models</h3>
<span class="text-[10px] text-muted">Stored in your app data folder</span>
</div>
<div id="local-model-list" class="flex flex-col gap-3"></div>
`;
root.appendChild(modelsSection);
const listEl = modelsSection.querySelector('#local-model-list');
const renderModels = async () => {
listEl.innerHTML = `<div class="text-xs text-muted text-center py-4">Loading...</div>`;
try {
const models = await localAI.listModels();
listEl.innerHTML = '';
models.forEach(m => {
listEl.appendChild(ModelCard(m, renderModels));
});
} catch (err) {
listEl.innerHTML = `<div class="text-xs text-red-400 text-center py-4">Error loading models: ${err.message}</div>`;
}
};
renderModels();
return root;
}

View file

@ -0,0 +1,153 @@
export function McpCliStudio() {
const container = document.createElement('div');
container.className = 'w-full h-full overflow-y-auto bg-app-bg text-white';
const inner = document.createElement('div');
inner.className = 'max-w-5xl mx-auto px-6 py-12 flex flex-col gap-12';
container.appendChild(inner);
// Hero
const hero = document.createElement('section');
hero.className = 'flex flex-col items-center text-center gap-4';
hero.innerHTML = `
<div class="px-3 py-1 rounded-full border border-white/10 bg-white/5 text-[11px] font-bold uppercase tracking-widest text-secondary">
For developers & AI agents
</div>
<h1 class="text-4xl md:text-5xl font-bold tracking-tight">MCP &amp; CLI</h1>
<p class="text-secondary text-base md:text-lg max-w-2xl">
Use Open Higgsfield AI from your terminal, your IDE, or any MCP-compatible
assistant. Generate cinematic images, videos, and audio across 100+ models
without leaving your workflow.
</p>
`;
inner.appendChild(hero);
// Quick start
const quick = document.createElement('section');
quick.className = 'glass-panel rounded-2xl p-6 md:p-8 flex flex-col gap-4';
quick.innerHTML = `
<div class="flex items-center gap-2">
<span class="text-[11px] font-bold uppercase tracking-widest text-secondary">Quick start</span>
<div class="flex-1 h-px bg-white/5"></div>
</div>
<div class="grid md:grid-cols-3 gap-4">
${quickStep('1', 'Install the CLI', 'npm install -g muapi-cli')}
${quickStep('2', 'Sign in', 'muapi auth login')}
${quickStep('3', 'Generate from chat', 'npx skills add SamurAIGPT/Generative-Media-Skills')}
</div>
`;
inner.appendChild(quick);
// Feature cards
const cards = document.createElement('section');
cards.className = 'grid md:grid-cols-3 gap-4';
cards.appendChild(featureCard({
tag: 'CLI',
title: 'muapi-cli',
body: 'Generate images, videos, and audio from the terminal across 14+ AI models. Dual interface — colored human output plus JSON for agents (--output-json, --jq filtering). Async workflows, file uploads, credit tracking.',
code: 'npm install -g muapi-cli\nmuapi image generate "a cyberpunk city" \\\n --model flux-dev',
link: 'https://github.com/SamurAIGPT/muapi-cli',
linkLabel: 'View muapi-cli on GitHub',
icon: `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>`,
}));
cards.appendChild(featureCard({
tag: 'MCP',
title: 'muapi-mcp-server',
body: 'Connect Claude, Cursor, Windsurf, and any MCP-compatible assistant to 100+ generative models. Hosted endpoint — no install. 19 structured tools with input/output schemas, async polling, and account management.',
code: 'claude mcp add --transport http muapi \\\n https://api.muapi.ai/mcp \\\n --header "Authorization: Bearer YOUR_KEY"',
link: 'https://github.com/SamurAIGPT/muapi-mcp-server',
linkLabel: 'View muapi-mcp-server on GitHub',
icon: `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 1v6M12 17v6M4.22 4.22l4.24 4.24M15.54 15.54l4.24 4.24M1 12h6M17 12h6M4.22 19.78l4.24-4.24M15.54 8.46l4.24-4.24"/></svg>`,
}));
cards.appendChild(featureCard({
tag: 'Skills',
title: 'Generative Media Skills',
body: 'Multimodal toolkit for Claude Code, Cursor, and Gemini CLI. Cinema Director, Nano-Banana, UI Designer, Logo Creator, Seedance 2, AI Clipping, and YouTube Shorts presets. Agent-native with JSON outputs and semantic exit codes.',
code: 'npx skills add SamurAIGPT/Generative-Media-Skills --all',
link: 'https://github.com/SamurAIGPT/Generative-Media-Skills',
linkLabel: 'View Generative-Media-Skills on GitHub',
icon: `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l2.39 4.84L20 8l-4 3.9.94 5.5L12 14.77 7.06 17.4 8 11.9 4 8l5.61-1.16L12 2z"/></svg>`,
}));
inner.appendChild(cards);
// Usage examples
const examples = document.createElement('section');
examples.className = 'flex flex-col gap-4';
examples.innerHTML = `
<div class="flex items-center gap-2">
<span class="text-[11px] font-bold uppercase tracking-widest text-secondary">Examples</span>
<div class="flex-1 h-px bg-white/5"></div>
</div>
<div class="grid md:grid-cols-2 gap-4">
${exampleBlock('Image generation', 'muapi image generate "a serene mountain lake at sunrise" \\\n --model flux-dev --download ./outputs')}
${exampleBlock('Text-to-video', 'muapi video generate "a dog running on a beach" \\\n --model kling-master')}
${exampleBlock('Audio creation', 'muapi audio create "upbeat lo-fi hip hop for studying"')}
${exampleBlock('Run a skill', 'bash library/visual/nano-banana/scripts/\\\n generate-nano-art.sh --file image.jpg --view')}
</div>
`;
inner.appendChild(examples);
// Footer note
const footer = document.createElement('p');
footer.className = 'text-center text-xs text-secondary opacity-60 pb-4';
footer.textContent = 'Open-source · MIT licensed · Works with Claude, Cursor, Windsurf, and Gemini CLI';
inner.appendChild(footer);
return container;
}
function quickStep(num, title, code) {
return `
<div class="rounded-xl border border-white/5 bg-white/[0.02] p-4 flex flex-col gap-2">
<div class="flex items-center gap-2">
<span class="w-6 h-6 rounded-full bg-white text-black text-xs font-bold flex items-center justify-center">${num}</span>
<span class="text-sm font-bold">${title}</span>
</div>
<code class="text-[12px] font-mono text-primary bg-black/40 rounded-md px-2 py-1.5 break-all">${escapeHtml(code)}</code>
</div>
`;
}
function featureCard({ tag, title, body, code, link, linkLabel, icon }) {
const card = document.createElement('a');
card.href = link;
card.target = '_blank';
card.rel = 'noopener noreferrer';
card.setAttribute('aria-label', linkLabel);
card.className = 'glass-panel rounded-2xl p-6 flex flex-col gap-3 hover:bg-white/[0.04] transition-colors group';
card.innerHTML = `
<div class="flex items-center justify-between">
<div class="w-10 h-10 rounded-xl bg-white/5 flex items-center justify-center text-white">${icon}</div>
<span class="text-[10px] font-bold uppercase tracking-widest text-secondary">${tag}</span>
</div>
<h3 class="text-lg font-bold">${title}</h3>
<p class="text-[13px] text-secondary leading-relaxed">${body}</p>
<pre class="text-[11px] font-mono text-primary bg-black/40 rounded-md px-3 py-2 overflow-x-auto whitespace-pre">${escapeHtml(code)}</pre>
<div class="flex items-center gap-1 text-[12px] font-bold text-secondary group-hover:text-white transition-colors mt-auto">
<span>View on GitHub</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="7" y1="17" x2="17" y2="7"/><polyline points="7 7 17 7 17 17"/></svg>
</div>
`;
return card;
}
function exampleBlock(title, code) {
return `
<div class="rounded-xl border border-white/5 bg-white/[0.02] p-4 flex flex-col gap-2">
<span class="text-[12px] font-bold text-white/80">${title}</span>
<pre class="text-[11px] font-mono text-primary bg-black/40 rounded-md px-3 py-2 overflow-x-auto whitespace-pre">${escapeHtml(code)}</pre>
</div>
`;
}
function escapeHtml(s) {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

View file

@ -1,92 +1,117 @@
import { LocalModelManager } from './LocalModelManager.js';
import { isLocalAIAvailable } from '../lib/localInferenceClient.js';
export function SettingsModal(onClose) {
const overlay = document.createElement('div');
overlay.className = 'fixed inset-0 bg-black/80 flex items-center justify-center z-50';
overlay.style.position = 'fixed';
overlay.style.top = '0';
overlay.style.left = '0';
overlay.style.right = '0';
overlay.style.bottom = '0';
overlay.style.backgroundColor = 'rgba(0,0,0,0.8)';
overlay.style.display = 'flex';
overlay.style.alignItems = 'center';
overlay.style.justifyContent = 'center';
overlay.style.zIndex = '100';
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.8);display:flex;align-items:center;justify-content:center;z-index:100;';
const modal = document.createElement('div');
modal.className = 'bg-card p-6 rounded-xl border border-border-color w-96 glass';
modal.style.background = 'var(--bg-card)';
modal.style.padding = '1.5rem';
modal.style.borderRadius = 'var(--border-radius-xl)';
modal.style.border = '1px solid var(--border-color)';
modal.style.width = '24rem';
modal.style.cssText = 'background:var(--bg-card,#111);border-radius:1rem;border:1px solid rgba(255,255,255,0.08);width:min(90vw,36rem);max-height:85vh;display:flex;flex-direction:column;overflow:hidden;';
const title = document.createElement('h2');
title.textContent = 'Settings';
title.className = 'text-xl font-bold mb-4';
title.style.marginBottom = '1rem';
// ── Header ────────────────────────────────────────────────────────────────
const header = document.createElement('div');
header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:1.25rem 1.5rem;border-bottom:1px solid rgba(255,255,255,0.06);flex-shrink:0;';
header.innerHTML = `
<h2 style="font-size:1rem;font-weight:800;color:#fff;margin:0;">Settings</h2>
<button id="settings-close-btn" style="color:rgba(255,255,255,0.4);background:none;border:none;cursor:pointer;padding:4px;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M18 6L6 18M6 6l12 12"/></svg>
</button>
`;
modal.appendChild(header);
const label = document.createElement('label');
label.textContent = 'Muapi API Key';
label.className = 'block text-sm text-secondary mb-2';
// ── Tabs ──────────────────────────────────────────────────────────────────
const TABS = [
{ id: 'api', label: 'API Key' },
...(isLocalAIAvailable() ? [{ id: 'local', label: 'Local Models' }] : []),
];
const input = document.createElement('input');
input.type = 'password';
input.className = 'w-full mb-4 p-2 rounded bg-input border border-border-color';
input.value = localStorage.getItem('muapi_key') || '';
input.placeholder = 'Enter your Muapi API key...';
input.style.width = '100%';
input.style.marginBottom = '1rem';
let activeTab = 'api';
const btnContainer = document.createElement('div');
btnContainer.className = 'flex justify-end gap-2';
btnContainer.style.display = 'flex';
btnContainer.style.justifyContent = 'flex-end';
btnContainer.style.gap = '0.5rem';
const tabBar = document.createElement('div');
tabBar.style.cssText = 'display:flex;gap:0.25rem;padding:0.75rem 1.5rem 0;border-bottom:1px solid rgba(255,255,255,0.06);flex-shrink:0;';
const cancelBtn = document.createElement('button');
cancelBtn.textContent = 'Cancel';
cancelBtn.className = 'px-4 py-2 rounded hover:bg-white/5';
cancelBtn.onclick = () => {
document.body.removeChild(overlay);
const tabBtns = {};
TABS.forEach(({ id, label }) => {
const btn = document.createElement('button');
btn.textContent = label;
btn.style.cssText = 'padding:0.4rem 0.75rem;border-radius:0.5rem 0.5rem 0 0;font-size:0.75rem;font-weight:700;border:none;cursor:pointer;transition:all 0.15s;';
btn.onclick = () => switchTab(id);
tabBtns[id] = btn;
tabBar.appendChild(btn);
});
modal.appendChild(tabBar);
// ── Body ──────────────────────────────────────────────────────────────────
const body = document.createElement('div');
body.style.cssText = 'flex:1;overflow-y:auto;padding:1.5rem;';
modal.appendChild(body);
// ── Tab: API Key ──────────────────────────────────────────────────────────
const apiPanel = document.createElement('div');
apiPanel.innerHTML = `
<div style="display:flex;flex-direction:column;gap:0.75rem;">
<div>
<label style="display:block;font-size:0.75rem;color:rgba(255,255,255,0.5);margin-bottom:0.4rem;font-weight:600;">Muapi API Key</label>
<input id="settings-api-key" type="password"
style="width:100%;box-sizing:border-box;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.1);border-radius:0.75rem;padding:0.6rem 0.9rem;color:#fff;font-size:0.875rem;outline:none;"
placeholder="Enter your Muapi API key..."
value="${localStorage.getItem('muapi_key') || ''}">
</div>
<p style="font-size:0.7rem;color:rgba(255,255,255,0.3);margin:0;">
Your API key is stored locally and never sent anywhere except api.muapi.ai.
</p>
<div style="display:flex;justify-content:flex-end;gap:0.5rem;margin-top:0.5rem;">
<button id="settings-cancel-btn" style="padding:0.5rem 1rem;border-radius:0.5rem;background:none;border:1px solid rgba(255,255,255,0.1);color:rgba(255,255,255,0.6);font-size:0.75rem;font-weight:700;cursor:pointer;">Cancel</button>
<button id="settings-save-btn" style="padding:0.5rem 1rem;border-radius:0.5rem;background:var(--color-primary,#d9ff00);color:#000;font-size:0.75rem;font-weight:700;cursor:pointer;border:none;">Save</button>
</div>
</div>
`;
// ── Tab: Local Models ─────────────────────────────────────────────────────
const localPanel = LocalModelManager();
// ── Tab switching ─────────────────────────────────────────────────────────
const switchTab = (id) => {
activeTab = id;
body.innerHTML = '';
TABS.forEach(({ id: tid }) => {
const btn = tabBtns[tid];
if (tid === id) {
btn.style.background = 'rgba(255,255,255,0.08)';
btn.style.color = '#fff';
} else {
btn.style.background = 'transparent';
btn.style.color = 'rgba(255,255,255,0.4)';
}
});
if (id === 'api') body.appendChild(apiPanel);
if (id === 'local') body.appendChild(localPanel);
};
switchTab('api');
// ── API key save/cancel handlers ──────────────────────────────────────────
const close = () => {
if (document.body.contains(overlay)) document.body.removeChild(overlay);
if (onClose) onClose();
};
const saveBtn = document.createElement('button');
saveBtn.textContent = 'Save';
saveBtn.className = 'px-4 py-2 rounded bg-primary text-black font-medium';
saveBtn.style.backgroundColor = 'var(--color-primary)';
saveBtn.style.color = 'black';
saveBtn.style.fontWeight = '500';
saveBtn.onclick = () => {
const key = input.value.trim();
apiPanel.querySelector('#settings-cancel-btn').onclick = close;
apiPanel.querySelector('#settings-save-btn').onclick = () => {
const key = apiPanel.querySelector('#settings-api-key').value.trim();
if (key) {
localStorage.setItem('muapi_key', key);
alert('API Key saved!');
document.body.removeChild(overlay);
if (onClose) onClose();
close();
} else {
alert('Please enter a valid key');
alert('Please enter a valid API key.');
}
};
modal.appendChild(title);
modal.appendChild(label);
modal.appendChild(input);
btnContainer.appendChild(cancelBtn);
btnContainer.appendChild(saveBtn);
modal.appendChild(btnContainer);
header.querySelector('#settings-close-btn').onclick = close;
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
overlay.appendChild(modal);
// Close on outside click
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
document.body.removeChild(overlay);
if (onClose) onClose();
}
});
return overlay;
}

View file

@ -13,7 +13,12 @@ import { getUploadHistory, saveUpload, removeUpload, generateThumbnail } from '.
* @param {number} [options.maxImages=1] - Maximum number of images selectable
* @returns {{ trigger: HTMLElement, panel: HTMLElement, reset: function, setMaxImages: function }}
*/
export function createUploadPicker({ anchorContainer, onSelect, onClear, maxImages: initialMaxImages = 1 }) {
export function createUploadPicker({ anchorContainer, onSelect, onClear, maxImages: initialMaxImages = 1, uploadFn, requireApiKey }) {
// uploadFn(file) → Promise<string url>. Defaults to Muapi-hosted upload.
// requireApiKey() → boolean. Lets the caller suppress the AuthModal when
// the active provider doesn't need a Muapi key (e.g. local Wan2GP).
const doUpload = uploadFn || ((file) => muapi.uploadFile(file));
const needsKey = typeof requireApiKey === 'function' ? requireApiKey : () => true;
let panelOpen = false;
let maxImages = initialMaxImages;
let selectedEntries = []; // [{ url, thumbnail }, ...]
@ -318,10 +323,12 @@ export function createUploadPicker({ anchorContainer, onSelect, onClear, maxImag
const files = Array.from(e.target.files);
if (!files.length) return;
const apiKey = localStorage.getItem('muapi_key');
if (!apiKey) {
AuthModal(() => fileInput.click());
return;
if (needsKey()) {
const apiKey = localStorage.getItem('muapi_key');
if (!apiKey) {
AuthModal(() => fileInput.click());
return;
}
}
showSpinner();
@ -330,10 +337,11 @@ export function createUploadPicker({ anchorContainer, onSelect, onClear, maxImag
if (maxImages === 1) {
// Single mode: upload first file only, replace selection
const file = files[0];
const [uploadedUrl, thumbnail] = await Promise.all([
muapi.uploadFile(file),
const [uploadResult, thumbnail] = await Promise.all([
doUpload(file),
generateThumbnail(file)
]);
const uploadedUrl = typeof uploadResult === 'string' ? uploadResult : uploadResult?.url;
const entry = { id: Date.now().toString(), name: file.name, uploadedUrl, thumbnail, timestamp: new Date().toISOString() };
saveUpload(entry);
selectedEntries = [{ url: uploadedUrl, thumbnail }];
@ -346,10 +354,11 @@ export function createUploadPicker({ anchorContainer, onSelect, onClear, maxImag
// Upload all in parallel
const results = await Promise.all(toUpload.map(async (file) => {
const [uploadedUrl, thumbnail] = await Promise.all([
muapi.uploadFile(file),
const [uploadResult, thumbnail] = await Promise.all([
doUpload(file),
generateThumbnail(file)
]);
const uploadedUrl = typeof uploadResult === 'string' ? uploadResult : uploadResult?.url;
return { id: Date.now().toString() + Math.random(), name: file.name, uploadedUrl, thumbnail, timestamp: new Date().toISOString() };
}));

View file

@ -3,13 +3,36 @@ import { t2vModels, getAspectRatiosForVideoModel, getDurationsForModel, getResol
import { AuthModal } from './AuthModal.js';
import { createUploadPicker } from './UploadPicker.js';
import { savePendingJob, removePendingJob, getPendingJobs } from '../lib/pendingJobs.js';
import { localAI, isLocalAIAvailable } from '../lib/localInferenceClient.js';
import { isWan2gpModelId, getLocalModelById, localT2VModels, localI2VModels } from '../lib/localModels.js';
// Promotes a wan2gp catalog entry (lib/localModels.js shape) into the
// `inputs`-shaped descriptor the Video Studio dropdowns/controls expect.
const adaptLocalToVideoEntry = (m) => ({
id: m.id,
name: m.name,
provider: 'wan2gp',
inputs: {
prompt: { type: 'string', name: 'prompt', title: 'Prompt' },
aspect_ratio: { type: 'string', name: 'aspect_ratio', enum: m.aspectRatios || ['16:9', '1:1', '9:16'], default: (m.aspectRatios || ['16:9'])[0] },
},
});
export function VideoStudio() {
const container = document.createElement('div');
container.className = 'w-full h-full flex flex-col items-center justify-center bg-app-bg relative p-4 md:p-6 overflow-y-auto custom-scrollbar overflow-x-hidden';
// Merge Wan2GP video models in only when running inside Electron AND the
// user has a Wan2GP server configured. We can't probe synchronously, so
// we always include them when isLocalAIAvailable() — getCurrentModel()
// reads from these arrays, so they need to be present from init.
const localT2V = isLocalAIAvailable() ? localT2VModels.map(adaptLocalToVideoEntry) : [];
const localI2V = isLocalAIAvailable() ? localI2VModels.map(adaptLocalToVideoEntry) : [];
const allT2V = [...t2vModels, ...localT2V];
const allI2V = [...i2vModels, ...localI2V];
// --- State ---
const defaultModel = t2vModels[0];
const defaultModel = allT2V[0];
let selectedModel = defaultModel.id;
let selectedModelName = defaultModel.name;
let selectedAr = defaultModel.inputs?.aspect_ratio?.default || '16:9';
@ -17,24 +40,43 @@ export function VideoStudio() {
let selectedResolution = defaultModel.inputs?.resolution?.default || '';
let selectedQuality = defaultModel.inputs?.quality?.default || '';
let selectedMode = '';
let selectedEffectName = '';
let lastGenerationId = null;
let lastGenerationModel = null;
let dropdownOpen = null;
let uploadedImageUrl = null;
let uploadedEndImageUrl = null; // optional end-frame for FLF i2v models
let imageMode = false; // false = t2v models, true = i2v models
let v2vMode = false; // true = video-to-video tools mode
let uploadedVideoUrl = null;
const getCurrentModels = () => v2vMode ? v2vModels : (imageMode ? i2vModels : t2vModels);
const getCurrentAspectRatios = (id) => imageMode ? getAspectRatiosForI2VModel(id) : getAspectRatiosForVideoModel(id);
const getCurrentDurations = (id) => imageMode ? getDurationsForI2VModel(id) : getDurationsForModel(id);
const getCurrentResolutions = (id) => imageMode ? getResolutionsForI2VModel(id) : getResolutionsForVideoModel(id);
const getCurrentModels = () => v2vMode ? v2vModels : (imageMode ? allI2V : allT2V);
// Local Wan2GP entries don't live in the Muapi-derived helpers, so we
// resolve aspect ratios off the catalog when the selected id is local.
const getCurrentAspectRatios = (id) => {
const local = getLocalModelById(id);
if (local) return local.aspectRatios || ['16:9', '1:1', '9:16'];
return imageMode ? getAspectRatiosForI2VModel(id) : getAspectRatiosForVideoModel(id);
};
const getCurrentDurations = (id) => {
if (getLocalModelById(id)) return [];
return imageMode ? getDurationsForI2VModel(id) : getDurationsForModel(id);
};
const getCurrentResolutions = (id) => {
if (getLocalModelById(id)) return [];
return imageMode ? getResolutionsForI2VModel(id) : getResolutionsForVideoModel(id);
};
const getCurrentModes = (id) => getModesForModel(id);
const getCurrentModel = () => getCurrentModels().find(m => m.id === selectedModel);
const isMotionControlV2V = () => v2vMode && !!getCurrentModel()?.imageField;
const getQualitiesForModel = (id) => {
const model = getCurrentModels().find(m => m.id === id);
return model?.inputs?.quality?.enum || [];
};
const getEffectNamesForModel = (id) => {
const model = getCurrentModels().find(m => m.id === id);
return model?.inputs?.name?.enum || [];
};
// ==========================================
// 1. HERO SECTION
@ -81,6 +123,14 @@ export function VideoStudio() {
anchorContainer: container,
onSelect: ({ url }) => {
uploadedImageUrl = url;
// Motion-control v2v: image is a second input alongside the video, not a mode switch
if (isMotionControlV2V()) {
textarea.disabled = false;
textarea.placeholder = uploadedVideoUrl
? (getCurrentModel()?.promptRequired ? 'Describe the motion' : 'Describe the motion (optional)')
: 'Now upload a reference video using the 🎥 button';
return;
}
// Clear video mode if active
if (v2vMode) {
uploadedVideoUrl = null;
@ -89,8 +139,13 @@ export function VideoStudio() {
}
if (!imageMode) {
imageMode = true;
selectedModel = i2vModels[0].id;
selectedModelName = i2vModels[0].name;
const currentT2V = allT2V.find(m => m.id === selectedModel);
const sibling = currentT2V?.family
? allI2V.find(m => m.family === currentT2V.family)
: null;
const target = sibling || allI2V[0];
selectedModel = target.id;
selectedModelName = target.name;
document.getElementById('v-model-btn-label').textContent = selectedModelName;
updateControlsForModel(selectedModel);
}
@ -99,18 +154,67 @@ export function VideoStudio() {
},
onClear: () => {
uploadedImageUrl = null;
// Motion-control v2v: keep the model selection; just lose the image
if (isMotionControlV2V()) return;
imageMode = false;
selectedModel = t2vModels[0].id;
selectedModelName = t2vModels[0].name;
// Clearing the start frame invalidates any selected end frame.
uploadedEndImageUrl = null;
endPicker?.reset();
selectedModel = allT2V[0].id;
selectedModelName = allT2V[0].name;
document.getElementById('v-model-btn-label').textContent = selectedModelName;
updateControlsForModel(selectedModel);
textarea.placeholder = 'Describe the video you want to create';
textarea.disabled = false;
}
},
// Route the upload through the configured Wan2GP server when the active
// model is local; otherwise fall back to the Muapi-hosted upload.
uploadFn: (file) => isWan2gpModelId(selectedModel) ? localAI.uploadFileToWan2gp(file) : muapi.uploadFile(file),
requireApiKey: () => !isWan2gpModelId(selectedModel),
});
topRow.appendChild(picker.trigger);
container.appendChild(picker.panel);
// --- End-Frame Upload Picker (FLF i2v models — kling/veo/seedance/etc.) ---
// Shown only when imageMode is on AND the selected i2v model declares a
// `lastImageField` in its catalog entry. Reuses the same UploadPicker UI;
// a corner badge differentiates it from the start-frame picker.
const endPicker = createUploadPicker({
anchorContainer: container,
onSelect: ({ url }) => { uploadedEndImageUrl = url; },
onClear: () => { uploadedEndImageUrl = null; },
uploadFn: (file) => muapi.uploadFile(file),
requireApiKey: () => true,
});
endPicker.trigger.title = 'End frame (optional)';
// Visual marker: small "L" badge in the corner so users can tell the two
// pickers apart at a glance. The wrapper keeps it from interfering with
// UploadPicker's own thumbnail/spinner state swapping.
const endBadge = document.createElement('div');
endBadge.className = 'absolute top-0.5 left-0.5 px-1 h-4 bg-white/20 rounded-md flex items-center justify-center pointer-events-none';
endBadge.innerHTML = '<span class="text-[8px] font-black text-white leading-none">END</span>';
endPicker.trigger.appendChild(endBadge);
endPicker.trigger.classList.add('hidden'); // start hidden until updateEndFrameVisibility flips it on
topRow.appendChild(endPicker.trigger);
container.appendChild(endPicker.panel);
const updateEndFrameVisibility = () => {
const model = getCurrentModel();
const supports = imageMode && !!model?.lastImageField;
if (supports) {
endPicker.trigger.classList.remove('hidden');
endPicker.trigger.classList.add('flex');
} else {
endPicker.trigger.classList.add('hidden');
endPicker.trigger.classList.remove('flex');
// Drop any stale end-frame selection when leaving FLF-capable state
if (uploadedEndImageUrl) {
uploadedEndImageUrl = null;
endPicker.reset();
}
}
};
// --- Video Upload Picker (Video-to-Video) ---
const videoFileInput = document.createElement('input');
videoFileInput.type = 'file';
@ -165,10 +269,15 @@ export function VideoStudio() {
const clearVideoUpload = () => {
uploadedVideoUrl = null;
v2vMode = false;
showVideoIcon();
selectedModel = t2vModels[0].id;
selectedModelName = t2vModels[0].name;
// Motion-control v2v: keep the model and image; user can re-upload a video
if (isMotionControlV2V()) {
textarea.placeholder = 'Upload a reference video using the 🎥 button';
return;
}
v2vMode = false;
selectedModel = allT2V[0].id;
selectedModelName = allT2V[0].name;
document.getElementById('v-model-btn-label').textContent = selectedModelName;
updateControlsForModel(selectedModel);
textarea.placeholder = 'Describe the video you want to create';
@ -200,19 +309,27 @@ export function VideoStudio() {
uploadedVideoUrl = url;
showVideoReady(file.name);
// Switch to v2v mode
if (imageMode) {
picker.reset();
uploadedImageUrl = null;
imageMode = false;
// If a motion-control v2v model is already selected, keep it and the image upload
if (isMotionControlV2V()) {
textarea.disabled = false;
textarea.placeholder = uploadedImageUrl
? (getCurrentModel()?.promptRequired ? 'Describe the motion' : 'Describe the motion (optional)')
: 'Now upload a reference image using the 🖼 button';
} else {
// Default v2v flow (e.g. watermark remover) — auto-pick the first v2v model
if (imageMode) {
picker.reset();
uploadedImageUrl = null;
imageMode = false;
}
v2vMode = true;
selectedModel = v2vModels[0].id;
selectedModelName = v2vModels[0].name;
document.getElementById('v-model-btn-label').textContent = selectedModelName;
updateControlsForModel(selectedModel);
textarea.placeholder = 'Video ready — click Generate to remove watermark';
textarea.disabled = true;
}
v2vMode = true;
selectedModel = v2vModels[0].id;
selectedModelName = v2vModels[0].name;
document.getElementById('v-model-btn-label').textContent = selectedModelName;
updateControlsForModel(selectedModel);
textarea.placeholder = 'Video ready — click Generate to remove watermark';
textarea.disabled = true;
} catch (err) {
console.error('[VideoStudio] Video upload failed:', err);
showVideoIcon();
@ -252,10 +369,11 @@ export function VideoStudio() {
const controlsLeft = document.createElement('div');
controlsLeft.className = 'flex items-center gap-1.5 md:gap-2.5 relative overflow-x-auto no-scrollbar pb-1 md:pb-0';
const createControlBtn = (icon, label, id) => {
const createControlBtn = (icon, label, id, tooltip) => {
const btn = document.createElement('button');
btn.id = id;
btn.className = 'flex items-center gap-1.5 md:gap-2.5 px-3 md:px-4 py-2 md:py-2.5 bg-white/5 hover:bg-white/10 rounded-xl md:rounded-2xl transition-all border border-white/5 group whitespace-nowrap';
if (tooltip) btn.setAttribute('data-tooltip', tooltip);
btn.innerHTML = `
${icon}
<span id="${id}-label" class="text-xs font-bold text-white group-hover:text-primary transition-colors">${label}</span>
@ -268,34 +386,44 @@ export function VideoStudio() {
<div class="w-5 h-5 bg-primary rounded-md flex items-center justify-center shadow-lg shadow-primary/20">
<span class="text-[10px] font-black text-black">V</span>
</div>
`, selectedModelName, 'v-model-btn');
`, selectedModelName, 'v-model-btn', 'Select AI video model');
const arBtn = createControlBtn(`
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="opacity-60 text-secondary"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/></svg>
`, selectedAr, 'v-ar-btn');
`, selectedAr, 'v-ar-btn', 'Change aspect ratio');
const durationBtn = createControlBtn(`
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="opacity-60 text-secondary"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
`, `${selectedDuration}s`, 'v-duration-btn');
`, `${selectedDuration}s`, 'v-duration-btn', 'Set video duration');
const resolutionBtn = createControlBtn(`
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="opacity-60 text-secondary"><path d="M6 2L3 6v15a2 2 0 002 2h14a2 2 0 002-2V6l-3-4H6z"/></svg>
`, selectedResolution || '720p', 'v-resolution-btn');
`, selectedResolution || '720p', 'v-resolution-btn', 'Set output resolution');
const qualityBtn = createControlBtn(`
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="opacity-60 text-secondary"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
`, selectedQuality || 'basic', 'v-quality-btn');
`, selectedQuality || 'basic', 'v-quality-btn', 'Set output quality');
const modeBtn = createControlBtn(`
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="opacity-60 text-secondary"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
`, selectedMode || 'normal', 'v-mode-btn');
const effectNameBtn = createControlBtn(`
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="opacity-60 text-secondary"><path d="M12 2l2.4 7.4H22l-6.2 4.5 2.4 7.4L12 17l-6.2 4.3 2.4-7.4L2 9.4h7.6L12 2z"/></svg>
`, 'Effect', 'v-effect-btn', 'Select effect type');
controlsLeft.appendChild(modelBtn);
controlsLeft.appendChild(arBtn);
controlsLeft.appendChild(durationBtn);
controlsLeft.appendChild(resolutionBtn);
controlsLeft.appendChild(qualityBtn);
controlsLeft.appendChild(modeBtn);
controlsLeft.appendChild(effectNameBtn);
// Advanced options toggle button
const advancedBtn = createControlBtn(`
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="opacity-60 text-secondary"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06a1.65 1.65 0 001.82-.33 1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06a1.65 1.65 0 00-1.82.33A1.65 1.65 0 0019.4 9a1.65 1.65 0 00-1.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
`, 'Advanced', 'v-advanced-btn', 'Show advanced options');
controlsLeft.appendChild(advancedBtn);
// Initial visibility (t2v mode)
const initDurations = getDurationsForModel(defaultModel.id);
@ -304,9 +432,11 @@ export function VideoStudio() {
resolutionBtn.style.display = initResolutions.length > 0 ? 'flex' : 'none';
qualityBtn.style.display = 'none';
modeBtn.style.display = getModesForModel(defaultModel.id).length > 0 ? 'flex' : 'none';
effectNameBtn.style.display = 'none';
const generateBtn = document.createElement('button');
generateBtn.className = 'bg-primary text-black px-6 md:px-8 py-3 md:py-3.5 rounded-xl md:rounded-[1.5rem] font-black text-sm md:text-base hover:shadow-glow hover:scale-105 active:scale-95 transition-all flex items-center justify-center gap-2.5 w-full sm:w-auto shadow-lg';
generateBtn.setAttribute('data-tooltip', 'Generate AI video from prompt');
generateBtn.innerHTML = `Generate ✨`;
bottomRow.appendChild(controlsLeft);
@ -324,6 +454,9 @@ export function VideoStudio() {
const updateControlsForModel = (modelId) => {
const model = getCurrentModels().find(m => m.id === modelId);
// End-frame picker visibility depends on imageMode + model.lastImageField.
updateEndFrameVisibility();
// In v2v mode, hide all parameter controls — no prompt/AR/duration/etc needed
if (v2vMode) {
arBtn.style.display = 'none';
@ -331,6 +464,7 @@ export function VideoStudio() {
resolutionBtn.style.display = 'none';
qualityBtn.style.display = 'none';
modeBtn.style.display = 'none';
effectNameBtn.style.display = 'none';
extendBanner.classList.add('hidden');
extendBanner.classList.remove('flex');
return;
@ -388,6 +522,17 @@ export function VideoStudio() {
modeBtn.style.display = 'none';
}
// Effect name (ai-video-effects / motion-controls)
const effectNames = getEffectNamesForModel(modelId);
if (effectNames.length > 0) {
selectedEffectName = model?.inputs?.name?.default || effectNames[0];
document.getElementById('v-effect-btn-label').textContent = selectedEffectName;
effectNameBtn.style.display = 'flex';
} else {
selectedEffectName = '';
effectNameBtn.style.display = 'none';
}
// Extend banner (extend model only)
if (model?.requiresRequestId) {
extendBanner.classList.remove('hidden');
@ -429,7 +574,7 @@ export function VideoStudio() {
<div class="w-10 h-10 ${iconColor} border border-white/5 rounded-xl flex items-center justify-center font-black text-sm shadow-inner uppercase">${m.name.charAt(0)}</div>
<div class="flex flex-col gap-0.5">
<span class="text-xs font-bold text-white tracking-tight">${m.name}</span>
${isV2V ? '<span class="text-[9px] text-orange-400/70">Upload a video to use</span>' : ''}
${isV2V ? `<span class="text-[9px] text-orange-400/70">${m.imageField ? 'Upload a video and image' : 'Upload a video to use'}</span>` : ''}
</div>
</div>
${selectedModel === m.id ? '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#d9ff00" stroke-width="4"><polyline points="20 6 9 17 4 12"/></svg>' : ''}
@ -440,14 +585,25 @@ export function VideoStudio() {
// Switch to v2v mode
v2vMode = true;
imageMode = false;
picker.reset();
uploadedImageUrl = null;
const isMC = !!m.imageField;
if (!isMC) {
// Single-input v2v (watermark remover etc.) — drop any image
picker.reset();
uploadedImageUrl = null;
}
selectedModel = m.id;
selectedModelName = m.name;
document.getElementById('v-model-btn-label').textContent = selectedModelName;
updateControlsForModel(selectedModel);
textarea.placeholder = 'Upload a video using the 🎥 button, then click Generate';
textarea.disabled = true;
if (isMC) {
textarea.placeholder = m.promptRequired
? 'Upload a reference video and image, then describe the motion'
: 'Upload a reference video and image, then describe the motion (optional)';
textarea.disabled = false;
} else {
textarea.placeholder = 'Upload a video using the 🎥 button, then click Generate';
textarea.disabled = true;
}
} else {
// Leaving v2v mode if was in it
if (v2vMode) {
@ -472,7 +628,7 @@ export function VideoStudio() {
const lf = filter.toLowerCase();
// Regular generation models (always t2v or i2v, never v2v)
const generationModels = imageMode ? i2vModels : t2vModels;
const generationModels = imageMode ? allI2V : allT2V;
const filteredMain = generationModels
.filter(m => m.name.toLowerCase().includes(lf) || m.id.toLowerCase().includes(lf));
filteredMain.forEach(m => list.appendChild(makeModelItem(m, false)));
@ -610,6 +766,29 @@ export function VideoStudio() {
list.appendChild(item);
});
dropdown.appendChild(list);
} else if (type === 'effect') {
dropdown.classList.add('max-w-[240px]');
dropdown.classList.remove('max-w-[200px]');
dropdown.innerHTML = `<div class="text-[10px] font-bold text-secondary uppercase tracking-widest px-3 py-2 border-b border-white/5 mb-2">Effect Type</div>`;
const list = document.createElement('div');
list.className = 'flex flex-col gap-1 max-h-[50vh] overflow-y-auto custom-scrollbar';
getEffectNamesForModel(selectedModel).forEach(e => {
const item = document.createElement('div');
item.className = 'flex items-center justify-between p-3 hover:bg-white/5 rounded-2xl cursor-pointer transition-all group';
item.innerHTML = `
<span class="text-xs font-bold text-white opacity-80 group-hover:opacity-100">${e}</span>
${selectedEffectName === e ? '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#d9ff00" stroke-width="4"><polyline points="20 6 9 17 4 12"/></svg>' : ''}
`;
item.onclick = (ev) => {
ev.stopPropagation();
selectedEffectName = e;
document.getElementById('v-effect-btn-label').textContent = e;
closeDropdown();
};
list.appendChild(item);
});
dropdown.appendChild(list);
}
// Position dropdown
@ -643,6 +822,7 @@ export function VideoStudio() {
resolutionBtn.onclick = toggleDropdown('resolution', resolutionBtn);
qualityBtn.onclick = toggleDropdown('quality', qualityBtn);
modeBtn.onclick = toggleDropdown('mode', modeBtn);
effectNameBtn.onclick = toggleDropdown('effect', effectNameBtn);
window.addEventListener('click', closeDropdown);
container.appendChild(dropdown);
@ -872,8 +1052,8 @@ export function VideoStudio() {
uploadedVideoUrl = null;
v2vMode = false;
showVideoIcon();
selectedModel = t2vModels[0].id;
selectedModelName = t2vModels[0].name;
selectedModel = allT2V[0].id;
selectedModelName = allT2V[0].name;
document.getElementById('v-model-btn-label').textContent = selectedModelName;
updateControlsForModel(selectedModel);
textarea.placeholder = 'Describe the video you want to create';
@ -909,6 +1089,14 @@ export function VideoStudio() {
alert('Please upload a video first.');
return;
}
if (model?.imageField && !uploadedImageUrl) {
alert('Please upload a reference image for motion control.');
return;
}
if (model?.promptRequired && !prompt) {
alert('Please describe the motion you want.');
return;
}
} else if (isExtendMode) {
if (!lastGenerationId) {
alert('No Seedance 2.0 generation found to extend. Generate a video first.');
@ -926,16 +1114,30 @@ export function VideoStudio() {
}
}
const apiKey = localStorage.getItem('muapi_key');
if (!apiKey) {
AuthModal(() => generateBtn.click());
return;
const isLocal = isWan2gpModelId(selectedModel);
// Local Wan2GP generations don't go through Muapi — skip the auth gate.
if (!isLocal) {
const apiKey = localStorage.getItem('muapi_key');
if (!apiKey) {
AuthModal(() => generateBtn.click());
return;
}
}
hero.classList.add('opacity-0', 'scale-95', '-translate-y-10', 'pointer-events-none');
generateBtn.disabled = true;
generateBtn.innerHTML = `<span class="animate-spin inline-block mr-2 text-black">◌</span> Generating...`;
// For local generations, surface step progress in the button label.
let unsubscribeProgress = null;
if (isLocal) {
unsubscribeProgress = localAI.onProgress(({ status, progress }) => {
const pct = typeof progress === 'number' ? Math.round(progress * 100) : null;
generateBtn.innerHTML = `<span class="animate-spin inline-block mr-2 text-black">◌</span> ${status || 'Generating'}${pct != null ? ` ${pct}%` : '…'}`;
});
}
let hadError = false;
let capturedRequestId = null;
const historyMeta = { prompt, model: selectedModel, aspect_ratio: selectedAr, duration: selectedDuration };
@ -946,15 +1148,44 @@ export function VideoStudio() {
};
try {
// ─── Local Wan2GP path ───────────────────────────────────────────
// Uploaded image URLs were minted by uploadFileToWan2gp(), so
// wan2gpProvider can rehydrate the Gradio file descriptor.
if (isLocal) {
const localParams = {
model: selectedModel,
prompt: prompt || '',
aspect_ratio: selectedAr,
};
if (imageMode && uploadedImageUrl) localParams.image = uploadedImageUrl;
const res = await localAI.generate(localParams);
console.log('[VideoStudio] Local response:', res);
if (res && res.url) {
const genId = Date.now().toString();
lastGenerationId = null;
lastGenerationModel = null;
addToHistory({ id: genId, url: res.url, prompt, model: selectedModel, aspect_ratio: selectedAr, timestamp: new Date().toISOString() });
showVideoInCanvas(res.url, selectedModel);
} else {
throw new Error('No video URL returned by Wan2GP');
}
generateBtn.disabled = false;
generateBtn.innerHTML = `Generate ✨`;
return;
}
if (v2vMode) {
const res = await muapi.processV2V({ model: selectedModel, video_url: uploadedVideoUrl, onRequestId });
const v2vParams = { model: selectedModel, video_url: uploadedVideoUrl, onRequestId };
if (model?.imageField && uploadedImageUrl) v2vParams.image_url = uploadedImageUrl;
if (model?.hasPrompt && prompt) v2vParams.prompt = prompt;
const res = await muapi.processV2V(v2vParams);
console.log('[VideoStudio] V2V response:', res);
if (res && res.url) {
if (capturedRequestId) removePendingJob(capturedRequestId);
const genId = res.id || capturedRequestId || Date.now().toString();
lastGenerationId = null;
lastGenerationModel = null;
addToHistory({ id: genId, url: res.url, prompt: '', model: selectedModel, timestamp: new Date().toISOString() });
addToHistory({ id: genId, url: res.url, prompt: model?.hasPrompt ? prompt : '', model: selectedModel, timestamp: new Date().toISOString() });
showVideoInCanvas(res.url, selectedModel);
} else {
throw new Error('No video URL returned by API');
@ -970,14 +1201,18 @@ export function VideoStudio() {
image_url: uploadedImageUrl,
onRequestId,
};
if (prompt) i2vParams.prompt = prompt;
i2vParams.prompt = prompt || '';
i2vParams.aspect_ratio = selectedAr;
if (uploadedEndImageUrl && getCurrentModel()?.lastImageField) {
i2vParams.last_image = uploadedEndImageUrl;
}
const durations = getCurrentDurations(selectedModel);
if (durations.length > 0) i2vParams.duration = selectedDuration;
const resolutions = getCurrentResolutions(selectedModel);
if (resolutions.length > 0) i2vParams.resolution = selectedResolution;
if (selectedQuality) i2vParams.quality = selectedQuality;
if (selectedMode) i2vParams.mode = selectedMode;
if (selectedEffectName) i2vParams.name = selectedEffectName;
const res = await muapi.generateI2V(i2vParams);
console.log('[VideoStudio] I2V response:', res);
@ -1064,6 +1299,7 @@ export function VideoStudio() {
}, 4000);
} finally {
generateBtn.disabled = false;
if (typeof unsubscribeProgress === 'function') unsubscribeProgress();
// Only reset the label on success; the catch timeout handles the error case
if (!hadError) generateBtn.innerHTML = `Generate ✨`;
}

View file

@ -0,0 +1,24 @@
export function WorkflowStudio() {
const container = document.createElement('div');
container.className = 'w-full h-full flex flex-col items-center justify-center bg-app-bg text-white gap-4';
const icon = document.createElement('div');
icon.innerHTML = `<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" opacity="0.4">
<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/>
<rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/>
<path d="M6.5 10v4M17.5 10v4M10 6.5h4M10 17.5h4"/>
</svg>`;
const title = document.createElement('p');
title.textContent = 'Workflow Studio';
title.className = 'text-lg font-bold opacity-60';
const sub = document.createElement('p');
sub.textContent = 'Available in the web app at open-generative-ai.com';
sub.className = 'text-sm opacity-40';
container.appendChild(icon);
container.appendChild(title);
container.appendChild(sub);
return container;
}

View file

@ -0,0 +1,107 @@
// Frontend client for local inference — wraps window.localAI (Electron IPC).
// Two providers live behind the same surface:
// - sd.cpp: bundled engine, downloads weights to disk, runs locally
// - wan2gp: user-run Gradio server, generation is remote HTTP
// Provider is read off the model entry's `provider` field.
import { getLocalModelById } from './localModels.js';
export const isLocalAIAvailable = () => typeof window !== 'undefined' && !!window.localAI?.isElectron;
class LocalInferenceClient {
// ── sd.cpp APIs ───────────────────────────────────────────────────────
async getBinaryStatus() {
if (!isLocalAIAvailable()) return { exists: false };
return window.localAI.getBinaryStatus();
}
async downloadBinary() {
if (!isLocalAIAvailable()) throw new Error('Local AI only available in the desktop app.');
return window.localAI.downloadBinary();
}
async downloadModel(modelId) {
if (!isLocalAIAvailable()) throw new Error('Local AI only available in the desktop app.');
return window.localAI.downloadModel(modelId);
}
async downloadAuxiliary(auxKey) {
if (!isLocalAIAvailable()) throw new Error('Local AI only available in the desktop app.');
return window.localAI.downloadAuxiliary(auxKey);
}
async deleteModel(modelId) {
if (!isLocalAIAvailable()) throw new Error('Local AI only available in the desktop app.');
return window.localAI.deleteModel(modelId);
}
// ── Wan2GP APIs ───────────────────────────────────────────────────────
async getWan2gpConfig() {
if (!isLocalAIAvailable()) return { url: '' };
return window.localAI.wan2gp.getConfig();
}
async setWan2gpUrl(url) {
if (!isLocalAIAvailable()) throw new Error('Local AI only available in the desktop app.');
return window.localAI.wan2gp.setUrl(url);
}
async probeWan2gp(url) {
if (!isLocalAIAvailable()) return { ok: false, error: 'Not in desktop app' };
return window.localAI.wan2gp.probe(url);
}
// Pushes a File/Blob to the configured Wan2GP server's /upload endpoint
// and returns { url, path }. URL is a previewable HTTP link; the provider
// also remembers the path so a subsequent generate(params.image=url) call
// can rehydrate it as a Gradio file descriptor.
async uploadFileToWan2gp(file) {
if (!isLocalAIAvailable()) throw new Error('Local AI only available in the desktop app.');
const buf = await file.arrayBuffer();
return window.localAI.wan2gp.uploadFile({
name: file.name,
type: file.type,
bytes: new Uint8Array(buf),
});
}
// ── Unified model list (both providers merged) ────────────────────────
async listModels() {
if (!isLocalAIAvailable()) return [];
const [sdcpp, wan2gp] = await Promise.all([
window.localAI.listModels(),
window.localAI.wan2gp.listModels().catch(() => []),
]);
return [
...sdcpp.map(m => ({ ...m, provider: m.provider || 'sdcpp' })),
...wan2gp,
];
}
// ── Provider-aware generate ───────────────────────────────────────────
async generate(params) {
if (!isLocalAIAvailable()) throw new Error('Local AI only available in the desktop app.');
const model = getLocalModelById(params.model);
if (model?.provider === 'wan2gp') {
return window.localAI.wan2gp.generate(params);
}
return window.localAI.generate(params);
}
cancelGeneration() {
if (!isLocalAIAvailable()) return;
// Ask both — only the running one reacts.
window.localAI.cancelGeneration();
window.localAI.wan2gp.cancelGeneration();
}
/**
* Subscribe to generation progress events.
* sd.cpp emits { step, totalSteps, progress, status }.
* Wan2GP emits { progress, status }.
*/
onProgress(callback) {
if (!isLocalAIAvailable()) return () => {};
return window.localAI.onProgress(callback);
}
onDownloadProgress(callback) {
if (!isLocalAIAvailable()) return () => {};
return window.localAI.onDownloadProgress(callback);
}
}
export const localAI = new LocalInferenceClient();

176
src/lib/localModels.js Normal file
View file

@ -0,0 +1,176 @@
// Frontend-side local model catalog.
// Two providers:
// - sdcpp: bundled engine, weights live on disk
// - wan2gp: user-run remote Gradio server
// Mirrors electron/lib/modelCatalog.js (sd.cpp) and electron/lib/wan2gpProvider.js (wan2gp).
export const LOCAL_MODEL_CATALOG = [
// ── sd.cpp: Z-Image (Tongyi-MAI) ────────────────────────────────────────
{
id: 'z-image-turbo',
name: 'Z-Image Turbo',
description: 'WaveSpeed\'s featured local model — 6B params, ultra-fast 8-step generation. No API key needed.',
type: 'z-image',
provider: 'sdcpp',
filename: 'z_image_turbo-Q4_K.gguf',
sizeGB: 3.4,
aspectRatios: ['1:1', '4:3', '3:4', '16:9', '9:16'],
defaultSteps: 8,
defaultGuidance: 1.0,
tags: ['turbo', 'fast', 'local', 'featured'],
featured: true,
},
{
id: 'z-image-base',
name: 'Z-Image Base',
description: 'Full-quality 6B parameter model from Tongyi-MAI — higher detail, 50-step generation.',
type: 'z-image',
provider: 'sdcpp',
filename: 'Z-Image-Q4_K_M.gguf',
sizeGB: 3.5,
aspectRatios: ['1:1', '4:3', '3:4', '16:9', '9:16'],
defaultSteps: 50,
defaultGuidance: 7.5,
tags: ['high-quality', 'local', 'detailed'],
featured: true,
},
// ── sd.cpp: SD 1.5 (small, M2-friendly) ─────────────────────────────────
{
id: 'dreamshaper-8',
name: 'Dreamshaper 8',
description: 'Versatile SD 1.5 model — great for portraits, landscapes, and artistic styles.',
type: 'sd1',
provider: 'sdcpp',
filename: 'DreamShaper_8_pruned.safetensors',
sizeGB: 2.1,
aspectRatios: ['1:1', '4:3', '3:4', '16:9', '9:16'],
defaultSteps: 20,
defaultGuidance: 7.5,
tags: ['photorealistic', 'artistic', 'versatile'],
},
{
id: 'realistic-vision-v51',
name: 'Realistic Vision v5.1',
description: 'Highly photorealistic people and scenes, based on SD 1.5.',
type: 'sd1',
provider: 'sdcpp',
filename: 'realisticVisionV51_v51VAE.safetensors',
sizeGB: 2.1,
aspectRatios: ['1:1', '4:3', '3:4', '16:9', '9:16'],
defaultSteps: 25,
defaultGuidance: 7,
tags: ['photorealistic', 'portraits', 'people'],
},
{
id: 'anything-v5',
name: 'Anything v5',
description: 'High quality anime and illustration style image generation.',
type: 'sd1',
provider: 'sdcpp',
filename: 'Anything-v5.0-PRT.safetensors',
sizeGB: 2.1,
aspectRatios: ['1:1', '4:3', '3:4', '16:9', '9:16'],
defaultSteps: 20,
defaultGuidance: 7,
tags: ['anime', 'illustration', 'artistic'],
},
// ── sd.cpp: SDXL ────────────────────────────────────────────────────────
{
id: 'stable-diffusion-xl-base',
name: 'SDXL Base 1.0',
description: 'Official Stable Diffusion XL base model — higher resolution, excellent quality.',
type: 'sdxl',
provider: 'sdcpp',
filename: 'sd_xl_base_1.0.safetensors',
sizeGB: 6.9,
aspectRatios: ['1:1', '4:3', '3:4', '16:9', '9:16'],
defaultSteps: 30,
defaultGuidance: 7.5,
tags: ['sdxl', 'high-quality', 'versatile'],
},
// ── Wan2GP: image models ────────────────────────────────────────────────
{
id: 'wan2gp:flux-dev',
name: 'Flux.1 Dev (Wan2GP)',
description: 'Image — FLUX.1 dev served by Wan2GP. Requires running Wan2GP server.',
type: 'image',
family: 'flux',
provider: 'wan2gp',
aspectRatios: ['1:1', '4:3', '3:4', '16:9', '9:16'],
defaultSteps: 28,
defaultGuidance: 3.5,
tags: ['image', 'flux', 'remote'],
},
{
id: 'wan2gp:qwen-image',
name: 'Qwen Image (Wan2GP)',
description: 'Image — Qwen-Image text-to-image served by Wan2GP.',
type: 'image',
family: 'qwen',
provider: 'wan2gp',
aspectRatios: ['1:1', '4:3', '3:4', '16:9', '9:16'],
defaultSteps: 30,
defaultGuidance: 4.0,
tags: ['image', 'qwen', 'remote'],
},
// ── Wan2GP: video models ────────────────────────────────────────────────
{
id: 'wan2gp:wan22-t2v',
name: 'Wan 2.2 (Text-to-Video)',
description: 'Video — Wan 2.2 text-to-video. Slow on consumer GPUs.',
type: 'video',
family: 'wan',
provider: 'wan2gp',
aspectRatios: ['16:9', '1:1', '9:16'],
defaultSteps: 25,
defaultGuidance: 5.0,
tags: ['video', 'wan', 'text-to-video'],
},
{
id: 'wan2gp:wan22-i2v',
name: 'Wan 2.2 (Image-to-Video)',
description: 'Video — Wan 2.2 image-to-video. Provide a start frame.',
type: 'video',
family: 'wan',
provider: 'wan2gp',
needsImage: true,
aspectRatios: ['16:9', '1:1', '9:16'],
defaultSteps: 25,
defaultGuidance: 5.0,
tags: ['video', 'wan', 'image-to-video'],
},
{
id: 'wan2gp:hunyuan-video',
name: 'Hunyuan Video (Wan2GP)',
description: 'Video — Hunyuan text-to-video via Wan2GP.',
type: 'video',
family: 'hunyuan',
provider: 'wan2gp',
aspectRatios: ['16:9', '1:1', '9:16'],
defaultSteps: 30,
defaultGuidance: 6.0,
tags: ['video', 'hunyuan'],
},
{
id: 'wan2gp:ltx-video',
name: 'LTX Video (Wan2GP)',
description: 'Video — LTX text-to-video. Fastest video option in Wan2GP.',
type: 'video',
family: 'ltx',
provider: 'wan2gp',
aspectRatios: ['16:9', '1:1', '9:16'],
defaultSteps: 20,
defaultGuidance: 3.0,
tags: ['video', 'ltx', 'fast'],
},
];
export function getLocalModelById(id) {
return LOCAL_MODEL_CATALOG.find(m => m.id === id) || null;
}
export const isWan2gpModelId = (id) => getLocalModelById(id)?.provider === 'wan2gp';
export const isLocalModelId = (id) => !!getLocalModelById(id);
export const localT2VModels = LOCAL_MODEL_CATALOG.filter(m => m.provider === 'wan2gp' && m.type === 'video' && !m.needsImage);
export const localI2VModels = LOCAL_MODEL_CATALOG.filter(m => m.provider === 'wan2gp' && m.type === 'video' && m.needsImage);

File diff suppressed because it is too large Load diff

View file

@ -245,7 +245,7 @@ export class MuapiClient {
const finalPayload = {};
// Only include prompt if the model supports it and one was provided
if (params.prompt) finalPayload.prompt = params.prompt;
finalPayload.prompt = params.prompt || '';
// Place the uploaded image(s) in the correct field for this model
const imageField = modelInfo?.imageField || 'image_url';
@ -326,11 +326,19 @@ export class MuapiClient {
}
}
// Optional end-frame image — only for models declaring lastImageField.
// Server-side param name varies (last_image vs end_image_url).
const lastImageField = modelInfo?.lastImageField;
if (lastImageField && params.last_image) {
finalPayload[lastImageField] = params.last_image;
}
if (params.aspect_ratio) finalPayload.aspect_ratio = params.aspect_ratio;
if (params.duration) finalPayload.duration = params.duration;
if (params.resolution) finalPayload.resolution = params.resolution;
if (params.quality) finalPayload.quality = params.quality;
if (params.mode) finalPayload.mode = params.mode;
if (params.name) finalPayload.name = params.name;
console.log('[Muapi] I2V Request:', url);
console.log('[Muapi] I2V Payload:', finalPayload);
@ -399,10 +407,14 @@ export class MuapiClient {
}
/**
* Processes a video through a Video-to-Video model (e.g. watermark remover).
* Processes a video through a Video-to-Video model.
* Single-input tools (e.g. watermark remover) only need `video_url`.
* Motion-control models additionally need `image_url` and (often) `prompt`.
* @param {Object} params
* @param {string} params.model - v2vModel id
* @param {string} params.video_url - The uploaded video URL
* @param {string} [params.image_url] - Reference image URL (motion-control models)
* @param {string} [params.prompt] - Motion description (motion-control models)
*/
async processV2V(params) {
const key = this.getKey();
@ -413,6 +425,13 @@ export class MuapiClient {
const videoField = modelInfo?.videoField || 'video_url';
const finalPayload = { [videoField]: params.video_url };
if (modelInfo?.imageField && params.image_url) {
finalPayload[modelInfo.imageField] = params.image_url;
}
if (modelInfo?.hasPrompt && params.prompt) {
finalPayload.prompt = params.prompt;
}
console.log('[Muapi] V2V Request:', url);
console.log('[Muapi] V2V Payload:', finalPayload);
@ -470,7 +489,7 @@ export class MuapiClient {
if (params.audio_url) finalPayload.audio_url = params.audio_url;
if (params.image_url) finalPayload.image_url = params.image_url;
if (params.video_url) finalPayload.video_url = params.video_url;
if (params.prompt) finalPayload.prompt = params.prompt;
if (modelInfo?.hasPrompt) finalPayload.prompt = params.prompt || '';
if (params.resolution) finalPayload.resolution = params.resolution;
if (params.seed !== undefined && params.seed !== -1) finalPayload.seed = params.seed;

View file

@ -1,3 +1,21 @@
export const ENHANCE_TAGS = {
quality: ['professional photography', 'ultra-detailed', '8K resolution', 'high dynamic range', 'award-winning'],
lighting: ['cinematic lighting', 'golden hour', 'dramatic studio lighting', 'soft diffused light', 'neon glow', 'volumetric rays'],
mood: ['moody atmosphere', 'serene and peaceful', 'epic and dramatic', 'warm and cozy', 'dark and mysterious'],
style: ['photorealistic', 'oil painting style', 'watercolor', 'digital art', 'concept art', 'anime style', 'cyberpunk aesthetic'],
};
export const QUICK_PROMPTS = [
{ label: 'Portrait', prompt: 'Professional portrait photograph, shallow depth of field, soft studio lighting, 85mm lens' },
{ label: 'Landscape', prompt: 'Breathtaking landscape photograph, golden hour, wide angle, dramatic clouds, 4K' },
{ label: 'Product', prompt: 'Commercial product photography, clean white background, studio lighting, professional' },
{ label: 'Fantasy', prompt: 'Epic fantasy scene, magical atmosphere, volumetric lighting, highly detailed, concept art' },
{ label: 'Sci-Fi', prompt: 'Futuristic sci-fi environment, neon lights, cyberpunk city, rain reflections, cinematic' },
{ label: 'Food', prompt: 'Professional food photography, appetizing, warm lighting, shallow depth of field, editorial' },
{ label: 'Architecture', prompt: 'Architectural photography, dramatic angles, clean lines, modern design, professional' },
{ label: 'Fashion', prompt: 'High fashion editorial, avant-garde styling, studio lighting, Vogue aesthetic, professional' },
];
export const CAMERA_MAP = {
"Modular 8K Digital": "modular 8K digital cinema camera",
"Full-Frame Cine Digital": "full-frame digital cinema camera",

View file

@ -24,6 +24,18 @@ function navigate(page) {
import('./components/LipSyncStudio.js').then(({ LipSyncStudio }) => {
contentArea.appendChild(LipSyncStudio());
});
} else if (page === 'workflows') {
import('./components/WorkflowStudio.js').then(({ WorkflowStudio }) => {
contentArea.appendChild(WorkflowStudio());
});
} else if (page === 'agents') {
import('./components/AgentStudio.js').then(({ AgentStudio }) => {
contentArea.appendChild(AgentStudio());
});
} else if (page === 'mcp-cli') {
import('./components/McpCliStudio.js').then(({ McpCliStudio }) => {
contentArea.appendChild(McpCliStudio());
});
}
}

View file

@ -1,24 +1,6 @@
@import "tailwindcss";
@theme {
--color-primary: #d9ff00;
--color-primary-hover: #c4e600;
--color-app-bg: #050505;
--color-panel-bg: #0a0a0a;
--color-card-bg: #141414;
--color-secondary: #a1a1aa;
--color-muted: #52525b;
--font-sans: "Inter", "system-ui", "-apple-system", "sans-serif";
--radius-xl: 1rem;
--radius-2xl: 1.5rem;
--radius-3xl: 2rem;
--shadow-glow: 0 0 20px rgba(217, 255, 0, 0.4);
--shadow-glow-accent: 0 0 20px rgba(168, 85, 247, 0.4);
--shadow-3xl: 0 35px 60px -15px rgba(0, 0, 0, 0.8);
}
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@ -72,4 +54,181 @@
.animate-fade-in-up {
animation: fade-in-up 0.6s cubic-bezier(0.23, 1, 0.32, 1) forwards;
}
/* Thumbnail cards */
.thumb-hero {
position: relative;
overflow: hidden;
}
.thumb-hero img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: top center;
transition: transform 0.4s cubic-bezier(0.23, 1, 0.32, 1);
}
.thumb-hero::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 60%;
background: linear-gradient(to top, rgba(0, 0, 0, 0.75), transparent);
pointer-events: none;
}
.thumb-hero:hover img,
.group:hover .thumb-hero img {
transform: scale(1.05);
}
.thumb-skeleton {
background: rgba(255, 255, 255, 0.03);
animation: skeleton-pulse 1.5s ease-in-out infinite;
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.thumb-fallback .thumb-hero {
display: none;
}
.hero-banner img {
transition: transform 0.6s cubic-bezier(0.23, 1, 0.32, 1);
}
.hero-banner:hover img {
transform: scale(1.03);
}
@media (prefers-reduced-motion: reduce) {
.animate-fade-in-up {
animation: none;
opacity: 1;
}
.thumb-hero img {
transition: none;
}
.thumb-skeleton {
animation: none;
}
}
/* ========================
TOOLTIP SYSTEM
======================== */
/* Base tooltip container */
[data-tooltip] {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
}
/* Tooltip arrow */
[data-tooltip]::before {
content: '';
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%) translateY(8px);
border: 6px solid transparent;
border-top-color: #1a1a1a;
opacity: 0;
pointer-events: none;
transition: all 0.2s ease;
z-index: 9999;
}
/* Tooltip body */
[data-tooltip]::after {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%) translateY(12px);
padding: 8px 14px;
background: #1a1a1a;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 10px;
font-size: 12px;
font-weight: 600;
color: #fff;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: all 0.2s ease;
z-index: 9999;
box-shadow: 0 10px 30px -10px rgba(0,0,0,0.5);
}
/* Show tooltip on hover */
[data-tooltip]:hover::before,
[data-tooltip]:hover::after {
opacity: 1;
transform: translateX(-50%) translateY(-6px);
}
/* Tooltip positioning variants */
[data-tooltip-bottom]::before {
bottom: auto;
top: 100%;
border-top-color: transparent;
border-bottom-color: #1a1a1a;
transform: translateX(-50%) translateY(-8px);
}
[data-tooltip-bottom]::after {
bottom: auto;
top: 100%;
transform: translateX(-50%) translateY(-12px);
}
[data-tooltip-bottom]:hover::before,
[data-tooltip-bottom]:hover::after {
transform: translateX(-50%) translateY(6px);
}
/* Tooltip for left-aligned elements */
[data-tooltip-left]::before {
left: 0;
transform: translateX(-100%) translateY(-50%);
}
[data-tooltip-left]::after {
left: 0;
transform: translateX(calc(-100% - 10px)) translateY(-50%);
}
[data-tooltip-left]:hover::before,
[data-tooltip-left]:hover::after {
transform: translateX(calc(-100% - 6px)) translateY(-50%);
}
/* Tooltip for right-aligned elements */
[data-tooltip-right]::before {
left: auto;
right: 0;
transform: translateX(100%) translateY(-50%);
}
[data-tooltip-right]::after {
left: auto;
right: 0;
transform: translateX(calc(100% + 10px)) translateY(-50%);
}
[data-tooltip-right]:hover::before,
[data-tooltip-right]:hover::after {
transform: translateX(calc(100% + 6px)) translateY(-50%);
}

View file

@ -1,11 +1,7 @@
import { defineConfig } from 'vite';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
base: './',
plugins: [
tailwindcss(),
],
server: {
proxy: {
'/api': {