Add Nano Banana 2, Seedream 5.0 models with multi-image input support

- Add nano-banana-2 and seedream-5.0 to t2i models
- Add nano-banana-2-edit and seedream-5.0-edit to i2i models
- Add maxImages to 22 i2i models based on schema maxItems (up to 14 for nano-banana-2-edit)
- UploadPicker: multi-select mode with order badges, batch file upload, count/+ trigger badge
- ImageStudio: track uploadedImageUrls[], setMaxImages() on model switch, pass images_list to API
- muapi: pass quality and images_list array params in generateImage/generateI2I
- Expose quality/resolution controls for t2i models (nano-banana-2, seedream-5.0)
- Update README with new models, multi-image picker docs, and comparison table
This commit is contained in:
Anil Matcha 2026-02-27 19:03:06 +05:30
commit a65bdb2a77
5 changed files with 545 additions and 94 deletions

View file

@ -2,12 +2,13 @@
> **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.
Open Higgsfield AI is an open-source AI image, video, and cinema 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, and image-to-video generation across models like Flux, Nano Banana, Midjourney, Kling, Sora, Veo, and more — all from a sleek, modern interface you can self-host and customize.
Open Higgsfield AI is an open-source AI image, video, and cinema 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, and image-to-video generation across models like Flux, Nano Banana, Midjourney, Kling, Sora, Veo, Seedream, and more — all from a sleek, modern interface you can self-host and customize.
**Why Open Higgsfield AI instead of Higgsfield AI?**
- **Free & open-source** — no subscription, no vendor lock-in
- **Self-hosted** — your data stays on your machine
- **200+ models** — text-to-image, image-to-image, text-to-video, image-to-video
- **Multi-image input** — feed up to 14 reference images into compatible 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).
@ -16,11 +17,12 @@ For a deep dive into the technical architecture and the philosophy behind the "I
## ✨ 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.
- **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.
- **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.
- **Cinema Studio** — Higgsfield AI-style interface for photorealistic cinematic shots with pro camera controls (Lens, Focal Length, Aperture)
- **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, and duration pickers that adapt to each model's capabilities
- **Smart Controls** — Dynamic aspect ratio, resolution/quality, and duration pickers that adapt to each model's capabilities (including t2i models with resolution or quality options)
- **Generation History** — Browse, revisit, and download all past generations (persisted in browser storage)
- **Image & Video Download** — One-click download of generated outputs in full resolution
- **API Key Management** — Secure API key storage in browser localStorage (never sent to any server except Muapi)
@ -32,8 +34,44 @@ The Image Studio automatically switches between two model sets:
| Mode | Trigger | Models | Prompt |
| :--- | :--- | :--- | :--- |
| **Text-to-Image** | Default (no image) | 50+ t2i models (Flux, Nano Banana, Ideogram, GPT-4o, Midjourney…) | Required |
| **Image-to-Image** | Reference image uploaded | 55+ i2i models (Kontext, Seededit, Nano Banana Edit, Upscaler, Background Remover…) | Optional |
| **Text-to-Image** | Default (no image) | 50+ t2i models (Flux, Nano Banana 2, Seedream 5.0, Ideogram, GPT-4o, Midjourney…) | Required |
| **Image-to-Image** | Reference image uploaded | 55+ i2i models (Kontext, Nano Banana 2 Edit, Seedream 5.0 Edit, Seededit, Upscaler…) | Optional |
#### Newly Added Models
| Model | Type | Key Features |
| :--- | :--- | :--- |
| **Nano Banana 2** | Text-to-Image | Google Gemini 3.1 Flash Image · Resolution 1K/2K/4K · Google Search enhancement · aspect ratio `auto` |
| **Nano Banana 2 Edit** | Image-to-Image | Up to **14 reference images** · Resolution 1K/2K/4K · Google Search enhancement |
| **Seedream 5.0** | Text-to-Image | ByteDance · Quality basic/high · 8 aspect ratios · up to 4K |
| **Seedream 5.0 Edit** | Image-to-Image | ByteDance · Natural language style transfer · Quality basic/high |
#### Multi-Image Input
Models that accept multiple reference images expose a multi-select picker when active:
| Model | Max Images |
| :--- | :--- |
| Nano Banana 2 Edit | 14 |
| Nano Banana Edit | 10 |
| Flux Kontext Dev I2I | 10 |
| Kling O1 Edit Image | 10 |
| GPT-4o Edit / GPT Image 1.5 Edit | 10 |
| Bytedance Seedream Edit v4 / v4.5 | 10 |
| Vidu Q2 Reference to Image | 7 |
| Flux 2 Flex/Pro Edit | 8 |
| Nano Banana Pro Edit | 8 |
| Flux Kontext Pro/Max I2I | 2 |
| Wan 2.5/2.6 Image Edit | 23 |
| Qwen Image Edit Plus / 2511 | 3 |
| GPT-4o Image to Image | 5 |
| Flux 2 Klein 4b/9b Edit | 4 |
When a multi-image model is selected the upload trigger switches to multi-select mode:
- **Checkboxes with order numbers** — images are sent to the model in the order you select them
- **Batch upload** — pick multiple files at once from your file dialog
- **Count badge** on the trigger shows how many images are active; a `+` badge appears when more slots are available
- **"Use Selected" button** confirms and closes the picker
### 🎬 Video Studio — Dual Mode
@ -61,8 +99,9 @@ Every image you upload is saved locally (URL + thumbnail) so you never upload th
- Click the upload button to open the **reference image picker**
- Previously uploaded images appear in a 3-column grid with thumbnails
- Click any thumbnail to instantly reuse it — no API call needed
- Upload a new image with the "Upload new" button in the panel
- **Single-image models** — click a thumbnail to instantly select and close
- **Multi-image models** — toggle multiple thumbnails (shown with order numbers), then click **Use Selected**
- Upload new images with the **Upload files** button (supports multi-file selection in multi-image mode)
- Remove individual images from history with the ✕ button
- History persists across browser sessions (stored in `localStorage`)
@ -101,10 +140,10 @@ npm run preview
```
src/
├── components/
│ ├── ImageStudio.js # Dual-mode t2i/i2i studio with dynamic model switching
│ ├── ImageStudio.js # Dual-mode t2i/i2i studio with dynamic model switching & multi-image support
│ ├── VideoStudio.js # Dual-mode t2v/i2v studio with dynamic model switching
│ ├── CinemaStudio.js # Pro studio with camera controls & infinite canvas flow
│ ├── UploadPicker.js # Reusable upload button + history panel component
│ ├── UploadPicker.js # Upload button + history panel; single & multi-image select modes
│ ├── CameraControls.js # Scrollable picker for camera/lens/focal/aperture
│ ├── Header.js # App header with settings and controls
│ ├── AuthModal.js # API key input modal
@ -112,7 +151,7 @@ src/
│ └── Sidebar.js # Navigation sidebar
├── lib/
│ ├── muapi.js # API client: generateImage, generateVideo, generateI2I, generateI2V, uploadFile
│ ├── models.js # 200+ model definitions (t2i, t2v, i2i, i2v) with endpoint & input mappings
│ ├── models.js # 200+ model definitions with endpoints, inputs, maxImages, quality/resolution mappings
│ └── uploadHistory.js # localStorage CRUD + canvas thumbnail generation for upload history
├── styles/
│ ├── global.css # Global styles and animations
@ -131,14 +170,14 @@ The app communicates with [Muapi.ai](https://muapi.ai) using a two-step pattern:
Authentication uses the `x-api-key` header. During development, a Vite proxy handles CORS by routing `/api` requests to `https://api.muapi.ai`.
File uploads use `POST /api/v1/upload_file` (multipart/form-data) and return a hosted URL that is passed to image-conditioned models.
File uploads use `POST /api/v1/upload_file` (multipart/form-data) and return a hosted URL that is passed to image-conditioned models. For multi-image models the full `images_list` array is forwarded to the API in one request.
## 🎨 Supported Model Categories
| Category | Count | Examples |
|---|---|---|
| **Text-to-Image** | 50+ | Flux Dev, Nano Banana Pro, Ideogram v3, Midjourney v7, GPT-4o, SDXL |
| **Image-to-Image** | 55+ | Nano Banana Edit, Flux Kontext Pro, GPT-4o Edit, Seededit v3, Upscaler, Background Remover |
| **Text-to-Image** | 50+ | Flux Dev, Nano Banana 2, Seedream 5.0, Ideogram v3, Midjourney v7, GPT-4o, SDXL |
| **Image-to-Image** | 55+ | Nano Banana 2 Edit (×14), Flux Kontext Pro, GPT-4o Edit, Seededit v3, Upscaler, Background Remover |
| **Text-to-Video** | 40+ | Kling v3, Sora 2, Veo 3, Wan 2.6, Seedance Pro, Hailuo 2.3, Runway Gen-3 |
| **Image-to-Video** | 60+ | Kling v2.1 I2V, Veo3 I2V, Runway I2V, Midjourney v7 I2V, Hunyuan I2V, Wan2.2 I2V |
@ -157,6 +196,7 @@ Higgsfield AI is a proprietary AI video and image generation platform. **Open Hi
| :--- | :--- | :--- |
| **Cost** | Subscription-based | Free (open-source) |
| **Models** | Proprietary | 200+ open & commercial models |
| **Multi-image input** | Limited | Up to 14 images per request |
| **Self-hosting** | No | Yes |
| **Customizable** | No | Fully hackable |
| **Data privacy** | Cloud-based | Your data stays local |

View file

@ -1,5 +1,9 @@
import { muapi } from '../lib/muapi.js';
import { t2iModels, getAspectRatiosForModel, i2iModels, getAspectRatiosForI2IModel, getResolutionsForI2IModel } from '../lib/models.js';
import {
t2iModels, getAspectRatiosForModel, getResolutionsForModel, getQualityFieldForModel,
i2iModels, getAspectRatiosForI2IModel, getResolutionsForI2IModel, getQualityFieldForI2IModel,
getMaxImagesForI2IModel
} from '../lib/models.js';
import { AuthModal } from './AuthModal.js';
import { createUploadPicker } from './UploadPicker.js';
@ -13,12 +17,13 @@ export function ImageStudio() {
let selectedModelName = defaultModel.name;
let selectedAr = defaultModel.inputs?.aspect_ratio?.default || '1:1';
let dropdownOpen = null;
let uploadedImageUrl = null;
let uploadedImageUrls = []; // array of uploaded image URLs (multi-image support)
let imageMode = false; // false = t2i models, true = i2i models
const getCurrentModels = () => imageMode ? i2iModels : t2iModels;
const getCurrentAspectRatios = (id) => imageMode ? getAspectRatiosForI2IModel(id) : getAspectRatiosForModel(id);
const getCurrentResolutions = (id) => imageMode ? getResolutionsForI2IModel(id) : [];
const getCurrentResolutions = (id) => imageMode ? getResolutionsForI2IModel(id) : getResolutionsForModel(id);
const getCurrentQualityField = (id) => imageMode ? getQualityFieldForI2IModel(id) : getQualityFieldForModel(id);
// ==========================================
// 1. HERO SECTION
@ -65,8 +70,8 @@ export function ImageStudio() {
// --- Image Upload Picker (Image-to-Image) ---
const picker = createUploadPicker({
anchorContainer: container,
onSelect: ({ url }) => {
uploadedImageUrl = url;
onSelect: ({ url, urls }) => {
uploadedImageUrls = urls || [url];
if (!imageMode) {
imageMode = true;
selectedModel = i2iModels[0].id;
@ -77,18 +82,24 @@ export function ImageStudio() {
const validResolutions = getResolutionsForI2IModel(selectedModel);
qualityBtn.style.display = validResolutions.length > 0 ? 'flex' : 'none';
if (validResolutions.length > 0) document.getElementById('quality-btn-label').textContent = validResolutions[0];
picker.setMaxImages(getMaxImagesForI2IModel(selectedModel));
}
textarea.placeholder = 'Describe how to transform this image (optional)';
textarea.placeholder = uploadedImageUrls.length > 1
? `${uploadedImageUrls.length} images selected — describe the transformation (optional)`
: 'Describe how to transform this image (optional)';
},
onClear: () => {
uploadedImageUrl = null;
uploadedImageUrls = [];
imageMode = false;
selectedModel = t2iModels[0].id;
selectedModelName = t2iModels[0].name;
selectedAr = getAspectRatiosForModel(selectedModel)[0];
document.getElementById('model-btn-label').textContent = selectedModelName;
document.getElementById('ar-btn-label').textContent = selectedAr;
qualityBtn.style.display = 'none';
const t2iResolutions = getResolutionsForModel(selectedModel);
qualityBtn.style.display = t2iResolutions.length > 0 ? 'flex' : 'none';
if (t2iResolutions.length > 0) document.getElementById('quality-btn-label').textContent = t2iResolutions[0];
picker.setMaxImages(1);
textarea.placeholder = 'Describe the image you want to create';
}
});
@ -144,7 +155,10 @@ export function ImageStudio() {
controlsLeft.appendChild(modelBtn);
controlsLeft.appendChild(arBtn);
controlsLeft.appendChild(qualityBtn);
qualityBtn.style.display = 'none'; // hidden in t2i mode, shown when i2i model has resolutions
// 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];
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';
@ -215,6 +229,11 @@ export function ImageStudio() {
document.getElementById('quality-btn-label').textContent = validResolutions[0];
}
// Update picker's max images when switching i2i models
if (imageMode) {
picker.setMaxImages(getMaxImagesForI2IModel(selectedModel));
}
closeDropdown();
};
list.appendChild(item);
@ -508,7 +527,8 @@ export function ImageStudio() {
promptWrapper.classList.remove('hidden', 'opacity-40');
textarea.value = '';
picker.reset();
uploadedImageUrl = null;
uploadedImageUrls = [];
picker.setMaxImages(1);
// Reset to t2i mode
imageMode = false;
selectedModel = t2iModels[0].id;
@ -516,7 +536,9 @@ export function ImageStudio() {
selectedAr = getAspectRatiosForModel(selectedModel)[0];
document.getElementById('model-btn-label').textContent = selectedModelName;
document.getElementById('ar-btn-label').textContent = selectedAr;
qualityBtn.style.display = 'none';
const resetResolutions = getResolutionsForModel(selectedModel);
qualityBtn.style.display = resetResolutions.length > 0 ? 'flex' : 'none';
if (resetResolutions.length > 0) document.getElementById('quality-btn-label').textContent = resetResolutions[0];
textarea.placeholder = 'Describe the image you want to create';
textarea.focus();
};
@ -527,7 +549,7 @@ export function ImageStudio() {
generateBtn.onclick = async () => {
const prompt = textarea.value.trim();
if (imageMode) {
if (!uploadedImageUrl) {
if (uploadedImageUrls.length === 0) {
alert('Please upload a reference image first.');
return;
}
@ -550,13 +572,17 @@ export function ImageStudio() {
try {
let res;
const qualityLabel = document.getElementById('quality-btn-label')?.textContent;
if (imageMode) {
const genParams = {
model: selectedModel,
image_url: uploadedImageUrl,
images_list: uploadedImageUrls,
image_url: uploadedImageUrls[0], // backward compat for single-image models
aspect_ratio: selectedAr
};
if (prompt) genParams.prompt = prompt;
const qualityField = getCurrentQualityField(selectedModel);
if (qualityField && qualityLabel) genParams[qualityField] = qualityLabel;
res = await muapi.generateI2I(genParams);
} else {
const genParams = {
@ -564,6 +590,8 @@ export function ImageStudio() {
prompt,
aspect_ratio: selectedAr
};
const qualityField = getCurrentQualityField(selectedModel);
if (qualityField && qualityLabel) genParams[qualityField] = qualityLabel;
res = await muapi.generateImage(genParams);
}

View file

@ -4,24 +4,27 @@ import { getUploadHistory, saveUpload, removeUpload, generateThumbnail } from '.
/**
* Creates a self-contained upload picker: a trigger button + history panel.
* Supports single-image (maxImages=1) and multi-image (maxImages>1) modes.
*
* @param {object} options
* @param {HTMLElement} options.anchorContainer - The container element the panel is positioned relative to
* @param {function({ url: string, thumbnail: string }): void} options.onSelect - Called when an image is selected
* @param {function(): void} [options.onClear] - Called when the active selection is removed from history
* @returns {{ trigger: HTMLElement, panel: HTMLElement, reset: function }}
* @param {function({ url: string, urls: string[], thumbnail: string }): void} options.onSelect
* @param {function(): void} [options.onClear]
* @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 }) {
export function createUploadPicker({ anchorContainer, onSelect, onClear, maxImages: initialMaxImages = 1 }) {
let panelOpen = false;
let selectedEntry = null; // { url, thumbnail }
let maxImages = initialMaxImages;
let selectedEntries = []; // [{ url, thumbnail }, ...]
// ── Hidden file input ────────────────────────────────────────────────────
// ── Hidden file input ────────────────────────────────────────────────────
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'image/*';
fileInput.className = 'hidden';
// ── Trigger button ───────────────────────────────────────────────────────
// ── Trigger button ───────────────────────────────────────────────────────
const trigger = document.createElement('button');
trigger.type = 'button';
trigger.title = 'Reference image';
@ -37,23 +40,23 @@ export function createUploadPicker({ anchorContainer, onSelect, onClear }) {
spinnerState.className = 'hidden items-center justify-center w-full h-full';
spinnerState.innerHTML = `<span class="animate-spin text-primary text-sm">◌</span>`;
// State: thumbnail with checkmark badge
// State: thumbnail (first selected image + optional count badge)
const thumbnailState = document.createElement('div');
thumbnailState.className = 'hidden w-full h-full';
const thumbImg = document.createElement('img');
thumbImg.className = 'w-full h-full object-cover';
const badge = document.createElement('div');
badge.className = 'absolute bottom-0.5 right-0.5 w-4 h-4 bg-primary rounded-full flex items-center justify-center';
badge.innerHTML = `<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="4"><polyline points="20 6 9 17 4 12"/></svg>`;
const countBadge = document.createElement('div');
countBadge.className = 'absolute bottom-0.5 right-0.5 min-w-[16px] h-4 bg-primary rounded-full flex items-center justify-center px-0.5';
countBadge.innerHTML = `<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="4"><polyline points="20 6 9 17 4 12"/></svg>`;
thumbnailState.appendChild(thumbImg);
thumbnailState.appendChild(badge);
thumbnailState.appendChild(countBadge);
trigger.appendChild(fileInput);
trigger.appendChild(iconState);
trigger.appendChild(spinnerState);
trigger.appendChild(thumbnailState);
// ── Trigger state helpers ────────────────────────────────────────────────
// ── Trigger state helpers ────────────────────────────────────────────────
const showIcon = () => {
iconState.classList.replace('hidden', 'flex');
spinnerState.classList.add('hidden'); spinnerState.classList.remove('flex');
@ -68,16 +71,43 @@ export function createUploadPicker({ anchorContainer, onSelect, onClear }) {
thumbnailState.classList.add('hidden'); thumbnailState.classList.remove('flex');
};
const showThumbnail = (src) => {
thumbImg.src = src;
const updateTrigger = () => {
if (selectedEntries.length === 0) {
showIcon();
trigger.title = maxImages > 1 ? `Add up to ${maxImages} images` : 'Reference image';
return;
}
// Show first image thumbnail
thumbImg.src = selectedEntries[0].thumbnail;
iconState.classList.add('hidden'); iconState.classList.remove('flex');
spinnerState.classList.add('hidden'); spinnerState.classList.remove('flex');
thumbnailState.classList.replace('hidden', 'flex');
trigger.classList.remove('border-white/10');
trigger.classList.add('border-primary/60');
const count = selectedEntries.length;
const canAddMore = maxImages > 1 && count < maxImages;
if (count > 1) {
// Multiple selected — show count
countBadge.className = 'absolute bottom-0.5 right-0.5 min-w-[16px] h-4 bg-primary rounded-full flex items-center justify-center px-0.5';
countBadge.innerHTML = `<span class="text-[9px] font-black text-black leading-none">${count}</span>`;
trigger.title = `${count} of ${maxImages} images selected — click to manage`;
} else if (canAddMore) {
// 1 selected, multi-mode active — show "+" to invite adding more
countBadge.className = 'absolute bottom-0.5 right-0.5 min-w-[16px] h-4 bg-white/80 rounded-full flex items-center justify-center px-0.5 border border-primary/60';
countBadge.innerHTML = `<span class="text-[9px] font-black text-black leading-none">+</span>`;
trigger.title = `1 image selected — click to add more (up to ${maxImages})`;
} else {
// Single mode or at max — show checkmark
countBadge.className = 'absolute bottom-0.5 right-0.5 min-w-[16px] h-4 bg-primary rounded-full flex items-center justify-center px-0.5';
countBadge.innerHTML = `<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="4"><polyline points="20 6 9 17 4 12"/></svg>`;
trigger.title = count > 1 ? `${count} images selected` : 'Reference image';
}
};
// ── Panel ────────────────────────────────────────────────────────────────
// ── Panel ────────────────────────────────────────────────────────────────
const panel = document.createElement('div');
panel.className = 'absolute z-50 opacity-0 pointer-events-none scale-95 origin-bottom-left glass rounded-3xl p-3 shadow-4xl border border-white/10 w-72 transition-all';
@ -85,7 +115,6 @@ export function createUploadPicker({ anchorContainer, onSelect, onClear }) {
renderPanel();
panel.classList.remove('opacity-0', 'pointer-events-none', 'scale-95');
panel.classList.add('opacity-100', 'pointer-events-auto', 'scale-100');
// Position relative to anchorContainer (matches existing dropdown math)
const btnRect = trigger.getBoundingClientRect();
const containerRect = anchorContainer.getBoundingClientRect();
panel.style.left = `${btnRect.left - containerRect.left}px`;
@ -99,21 +128,61 @@ export function createUploadPicker({ anchorContainer, onSelect, onClear }) {
panelOpen = false;
};
const fireOnSelect = () => {
if (selectedEntries.length === 0) return;
const urls = selectedEntries.map(e => e.url);
onSelect({
url: urls[0], // backward-compatible single URL
urls, // full array for multi-image models
thumbnail: selectedEntries[0].thumbnail
});
};
const renderPanel = () => {
panel.innerHTML = '';
const history = getUploadHistory();
const isMulti = maxImages > 1;
// Header
// ── Header ──
const header = document.createElement('div');
header.className = 'flex items-center justify-between px-1 pb-3 mb-2 border-b border-white/5';
header.innerHTML = `<span class="text-[10px] font-bold text-secondary uppercase tracking-widest">Reference Images</span>`;
const headerLeft = document.createElement('div');
headerLeft.className = 'flex flex-col gap-0.5';
headerLeft.innerHTML = `<span class="text-[10px] font-bold text-secondary uppercase tracking-widest">Reference Images</span>`;
if (isMulti) {
const hint = document.createElement('span');
hint.className = 'text-[9px] text-muted';
hint.textContent = `Select up to ${maxImages} images`;
headerLeft.appendChild(hint);
}
header.appendChild(headerLeft);
const headerRight = document.createElement('div');
headerRight.className = 'flex items-center gap-2';
// Done button (multi-select only)
if (isMulti && selectedEntries.length > 0) {
const doneBtn = document.createElement('button');
doneBtn.type = 'button';
doneBtn.className = 'flex items-center gap-1 px-3 py-1.5 bg-primary text-black rounded-xl text-xs font-black transition-all hover:scale-105';
doneBtn.innerHTML = `✓ Done (${selectedEntries.length})`;
doneBtn.onclick = (e) => {
e.stopPropagation();
closePanel();
fireOnSelect();
};
headerRight.appendChild(doneBtn);
}
const uploadNewBtn = document.createElement('button');
uploadNewBtn.type = 'button';
uploadNewBtn.className = 'flex items-center gap-1.5 px-3 py-1.5 bg-primary/10 hover:bg-primary/20 text-primary rounded-xl text-xs font-bold transition-all border border-primary/20';
uploadNewBtn.innerHTML = `<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg> Upload new`;
const uploadLabel = isMulti ? 'Upload files' : 'Upload new';
uploadNewBtn.innerHTML = `<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg> ${uploadLabel}`;
uploadNewBtn.onclick = (e) => { e.stopPropagation(); closePanel(); fileInput.click(); };
header.appendChild(uploadNewBtn);
headerRight.appendChild(uploadNewBtn);
header.appendChild(headerRight);
panel.appendChild(header);
if (history.length === 0) {
@ -127,12 +196,13 @@ export function createUploadPicker({ anchorContainer, onSelect, onClear }) {
return;
}
// Grid of saved uploads
// ── Grid ──
const grid = document.createElement('div');
grid.className = 'grid grid-cols-3 gap-2 max-h-56 overflow-y-auto custom-scrollbar pr-0.5';
history.forEach(entry => {
const isSelected = selectedEntry?.url === entry.uploadedUrl;
const selIdx = selectedEntries.findIndex(e => e.url === entry.uploadedUrl);
const isSelected = selIdx !== -1;
const cell = document.createElement('div');
cell.className = `relative rounded-xl overflow-hidden border-2 cursor-pointer group/cell aspect-square transition-all ${isSelected ? 'border-primary shadow-glow' : 'border-white/10 hover:border-white/30'}`;
@ -154,21 +224,33 @@ export function createUploadPicker({ anchorContainer, onSelect, onClear }) {
delBtn.onclick = (e) => {
e.stopPropagation();
removeUpload(entry.id);
if (selectedEntry?.url === entry.uploadedUrl) {
selectedEntry = null;
showIcon();
onClear?.();
const idx = selectedEntries.findIndex(e => e.url === entry.uploadedUrl);
if (idx !== -1) {
selectedEntries.splice(idx, 1);
updateTrigger();
if (selectedEntries.length === 0) onClear?.();
}
renderPanel();
};
overlay.appendChild(delBtn);
// Selected checkmark badge
// Selection badge: order number (multi) or checkmark (single)
if (isSelected) {
const check = document.createElement('div');
check.className = 'absolute top-1 left-1 w-5 h-5 bg-primary rounded-full flex items-center justify-center';
check.innerHTML = `<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="4"><polyline points="20 6 9 17 4 12"/></svg>`;
cell.appendChild(check);
const badge = document.createElement('div');
badge.className = 'absolute top-1 left-1 min-w-[20px] h-5 bg-primary rounded-full flex items-center justify-center px-1';
if (isMulti) {
badge.innerHTML = `<span class="text-[10px] font-black text-black">${selIdx + 1}</span>`;
} else {
badge.innerHTML = `<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="4"><polyline points="20 6 9 17 4 12"/></svg>`;
}
cell.appendChild(badge);
}
// Not-yet-reachable dim (when at max)
const atMax = isMulti && !isSelected && selectedEntries.length >= maxImages;
if (atMax) {
cell.classList.add('opacity-40');
cell.style.cursor = 'not-allowed';
}
cell.appendChild(img);
@ -176,19 +258,52 @@ export function createUploadPicker({ anchorContainer, onSelect, onClear }) {
cell.onclick = (e) => {
e.stopPropagation();
selectedEntry = { url: entry.uploadedUrl, thumbnail: entry.thumbnail };
showThumbnail(entry.thumbnail);
onSelect({ url: entry.uploadedUrl, thumbnail: entry.thumbnail });
closePanel();
if (atMax) return; // can't select more
if (!isMulti) {
// Single-select: select & close immediately
selectedEntries = [{ url: entry.uploadedUrl, thumbnail: entry.thumbnail }];
updateTrigger();
fireOnSelect();
closePanel();
} else {
// Multi-select: toggle
if (isSelected) {
selectedEntries.splice(selIdx, 1);
if (selectedEntries.length === 0) onClear?.();
} else {
selectedEntries.push({ url: entry.uploadedUrl, thumbnail: entry.thumbnail });
}
updateTrigger();
renderPanel(); // re-render to update badges / dim state
}
};
grid.appendChild(cell);
});
panel.appendChild(grid);
// Bottom "Done" bar for multi-select (always visible when items selected)
if (isMulti && selectedEntries.length > 0) {
const bottomBar = document.createElement('div');
bottomBar.className = 'mt-3 pt-3 border-t border-white/5 flex items-center justify-between';
bottomBar.innerHTML = `<span class="text-xs text-secondary">${selectedEntries.length} of ${maxImages} selected</span>`;
const doneBtn2 = document.createElement('button');
doneBtn2.type = 'button';
doneBtn2.className = 'px-4 py-1.5 bg-primary text-black rounded-xl text-xs font-black transition-all hover:scale-105';
doneBtn2.textContent = 'Use Selected';
doneBtn2.onclick = (e) => {
e.stopPropagation();
closePanel();
fireOnSelect();
};
bottomBar.appendChild(doneBtn2);
panel.appendChild(bottomBar);
}
};
// ── Trigger click ────────────────────────────────────────────────────────
// ── Trigger click ────────────────────────────────────────────────────────
trigger.onclick = (e) => {
e.stopPropagation();
if (panelOpen) closePanel();
@ -198,10 +313,10 @@ export function createUploadPicker({ anchorContainer, onSelect, onClear }) {
// Close panel on outside click
window.addEventListener('click', closePanel);
// ── File upload handler ──────────────────────────────────────────────────
// ── File upload handler ──────────────────────────────────────────────────
fileInput.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
const files = Array.from(e.target.files);
if (!files.length) return;
const apiKey = localStorage.getItem('muapi_key');
if (!apiKey) {
@ -212,39 +327,73 @@ export function createUploadPicker({ anchorContainer, onSelect, onClear }) {
showSpinner();
try {
// Upload to API and generate thumbnail in parallel
const [uploadedUrl, thumbnail] = await Promise.all([
muapi.uploadFile(file),
generateThumbnail(file)
]);
if (maxImages === 1) {
// Single mode: upload first file only, replace selection
const file = files[0];
const [uploadedUrl, thumbnail] = await Promise.all([
muapi.uploadFile(file),
generateThumbnail(file)
]);
const entry = { id: Date.now().toString(), name: file.name, uploadedUrl, thumbnail, timestamp: new Date().toISOString() };
saveUpload(entry);
selectedEntries = [{ url: uploadedUrl, thumbnail }];
updateTrigger();
fireOnSelect();
} else {
// Multi mode: upload all files (up to remaining slots)
const slots = maxImages - selectedEntries.length;
const toUpload = files.slice(0, Math.max(slots, 1));
const entry = {
id: Date.now().toString(),
name: file.name,
uploadedUrl,
thumbnail,
timestamp: new Date().toISOString()
};
// Upload all in parallel
const results = await Promise.all(toUpload.map(async (file) => {
const [uploadedUrl, thumbnail] = await Promise.all([
muapi.uploadFile(file),
generateThumbnail(file)
]);
return { id: Date.now().toString() + Math.random(), name: file.name, uploadedUrl, thumbnail, timestamp: new Date().toISOString() };
}));
saveUpload(entry);
selectedEntry = { url: uploadedUrl, thumbnail };
showThumbnail(thumbnail);
onSelect({ url: uploadedUrl, thumbnail });
results.forEach(entry => {
saveUpload(entry);
if (selectedEntries.length < maxImages) {
selectedEntries.push({ url: entry.uploadedUrl, thumbnail: entry.thumbnail });
}
});
updateTrigger();
// In multi-mode reopen panel so user can continue selecting / see Done button
openPanel();
}
} catch (err) {
console.error('[UploadPicker] Upload failed:', err);
showIcon();
updateTrigger();
alert(`Image upload failed: ${err.message}`);
}
fileInput.value = '';
};
// ── Public API ───────────────────────────────────────────────────────────
// ── Public API ───────────────────────────────────────────────────────────
const reset = () => {
selectedEntry = null;
selectedEntries = [];
showIcon();
closePanel();
};
return { trigger, panel, reset };
const setMaxImages = (n) => {
maxImages = n;
// Enable multi-file selection in file picker when multi-mode
fileInput.multiple = n > 1;
// Trim selection if exceeding new limit
if (selectedEntries.length > n) {
selectedEntries = selectedEntries.slice(0, n);
if (selectedEntries.length === 0) onClear?.();
}
// Always refresh trigger so badge/tooltip reflects new mode
updateTrigger();
};
const getSelectedUrls = () => selectedEntries.map(e => e.url);
return { trigger, panel, reset, setMaxImages, getSelectedUrls };
}

View file

@ -2005,6 +2005,90 @@ export const t2iModels = [
"step": 0.01
}
}
},
{
"id": "nano-banana-2",
"name": "Nano Banana 2",
"endpoint": "nano-banana-2",
"family": "nano",
"inputs": {
"prompt": {
"description": "Positive prompt for generation.",
"type": "string",
"title": "Prompt",
"name": "prompt",
"examples": [
"A futuristic cityscape with glowing neon lights reflected in rain-soaked streets, ultra-detailed 4K photography."
]
},
"aspect_ratio": {
"enum": [
"1:1", "1:4", "1:8", "2:3", "3:2", "3:4", "4:1", "4:3", "4:5",
"5:4", "8:1", "9:16", "16:9", "21:9", "auto"
],
"title": "Aspect Ratio",
"name": "aspect_ratio",
"type": "string",
"description": "The aspect ratio of the generated image.",
"default": "auto"
},
"resolution": {
"enum": ["1K", "2K", "4K"],
"title": "Resolution",
"name": "resolution",
"type": "string",
"description": "The resolution of the generated image.",
"default": "1K"
},
"google_search": {
"title": "Google Search",
"name": "google_search",
"type": "boolean",
"description": "Whether to use Google Search for prompt enhancement.",
"default": false
},
"output_format": {
"enum": ["jpg", "png"],
"title": "Output Format",
"name": "output_format",
"type": "string",
"description": "The format of the output image.",
"default": "jpg"
}
}
},
{
"id": "seedream-5.0",
"name": "Seedream 5.0",
"endpoint": "seedream-5.0",
"family": "seedream",
"inputs": {
"prompt": {
"type": "string",
"title": "Prompt",
"name": "prompt",
"description": "Text prompt describing the image to generate.",
"examples": [
"A futuristic city with soaring crystalline towers, suspended gardens, and neon-lit skyways under a twin-moon sky, captured in a cinematic, high-detail digital art style."
]
},
"aspect_ratio": {
"enum": ["1:1", "16:9", "9:16", "4:3", "3:4", "2:3", "3:2", "21:9"],
"title": "Aspect Ratio",
"name": "aspect_ratio",
"type": "string",
"description": "Aspect ratio of the output image.",
"default": "1:1"
},
"quality": {
"enum": ["basic", "high"],
"title": "Quality",
"name": "quality",
"type": "string",
"description": "Quality of the output image.",
"default": "basic"
}
}
}
];
@ -2519,6 +2603,7 @@ export const i2iModels = [
"family": "kontext",
"imageField": "images_list",
"hasPrompt": true,
"maxImages": 10,
"inputs": {
"prompt": {
"type": "string",
@ -2612,6 +2697,7 @@ export const i2iModels = [
"family": "kontext",
"imageField": "images_list",
"hasPrompt": true,
"maxImages": 2,
"inputs": {
"prompt": {
"type": "string",
@ -2647,6 +2733,7 @@ export const i2iModels = [
"family": "kontext",
"imageField": "images_list",
"hasPrompt": true,
"maxImages": 2,
"inputs": {
"prompt": {
"type": "string",
@ -2682,6 +2769,7 @@ export const i2iModels = [
"family": "gpt",
"imageField": "images_list",
"hasPrompt": true,
"maxImages": 5,
"inputs": {
"prompt": {
"type": "string",
@ -3260,6 +3348,7 @@ export const i2iModels = [
"family": "nano",
"imageField": "images_list",
"hasPrompt": true,
"maxImages": 10,
"inputs": {
"prompt": {
"type": "string",
@ -3358,6 +3447,7 @@ export const i2iModels = [
"family": "seedream",
"imageField": "images_list",
"hasPrompt": true,
"maxImages": 10,
"inputs": {
"prompt": {
"type": "string",
@ -3560,6 +3650,7 @@ export const i2iModels = [
"family": "qwen",
"imageField": "images_list",
"hasPrompt": true,
"maxImages": 3,
"inputs": {
"prompt": {
"type": "string",
@ -3599,6 +3690,7 @@ export const i2iModels = [
"family": "wan2.5",
"imageField": "images_list",
"hasPrompt": true,
"maxImages": 2,
"inputs": {
"prompt": {
"type": "string",
@ -3875,6 +3967,7 @@ export const i2iModels = [
"family": "qwen",
"imageField": "images_list",
"hasPrompt": false,
"maxImages": 3,
"inputs": {
"rotate_right_left": {
"type": "int",
@ -3942,6 +4035,7 @@ export const i2iModels = [
"family": "nano",
"imageField": "images_list",
"hasPrompt": true,
"maxImages": 8,
"inputs": {
"prompt": {
"type": "string",
@ -4008,6 +4102,7 @@ export const i2iModels = [
"family": "kling-o1",
"imageField": "images_list",
"hasPrompt": true,
"maxImages": 10,
"inputs": {
"prompt": {
"type": "string",
@ -4056,6 +4151,7 @@ export const i2iModels = [
"family": "flux-2",
"imageField": "images_list",
"hasPrompt": true,
"maxImages": 3,
"inputs": {
"prompt": {
"type": "string",
@ -4095,6 +4191,7 @@ export const i2iModels = [
"family": "flux-2",
"imageField": "images_list",
"hasPrompt": true,
"maxImages": 8,
"inputs": {
"prompt": {
"type": "string",
@ -4142,6 +4239,7 @@ export const i2iModels = [
"family": "flux-2",
"imageField": "images_list",
"hasPrompt": true,
"maxImages": 8,
"inputs": {
"prompt": {
"type": "string",
@ -4189,6 +4287,7 @@ export const i2iModels = [
"family": "vidu-q2",
"imageField": "images_list",
"hasPrompt": true,
"maxImages": 7,
"inputs": {
"prompt": {
"type": "string",
@ -4238,6 +4337,7 @@ export const i2iModels = [
"family": "seedream-v45",
"imageField": "images_list",
"hasPrompt": true,
"maxImages": 10,
"inputs": {
"prompt": {
"type": "string",
@ -4285,6 +4385,7 @@ export const i2iModels = [
"family": "qwen",
"imageField": "images_list",
"hasPrompt": true,
"maxImages": 3,
"inputs": {
"prompt": {
"type": "string",
@ -4324,6 +4425,7 @@ export const i2iModels = [
"family": "wan2.6",
"imageField": "images_list",
"hasPrompt": true,
"maxImages": 3,
"inputs": {
"prompt": {
"type": "string",
@ -4382,6 +4484,7 @@ export const i2iModels = [
"family": "gpt-1.5",
"imageField": "images_list",
"hasPrompt": true,
"maxImages": 10,
"inputs": {
"prompt": {
"type": "string",
@ -4472,6 +4575,7 @@ export const i2iModels = [
"family": "flux-2",
"imageField": "images_list",
"hasPrompt": true,
"maxImages": 4,
"inputs": {
"prompt": {
"type": "string",
@ -4507,6 +4611,7 @@ export const i2iModels = [
"family": "flux-2",
"imageField": "images_list",
"hasPrompt": true,
"maxImages": 4,
"inputs": {
"prompt": {
"type": "string",
@ -4572,6 +4677,95 @@ export const i2iModels = [
"default": 0.2
}
}
},
{
"id": "nano-banana-2-edit",
"name": "Nano Banana 2 Edit",
"endpoint": "nano-banana-2-edit",
"family": "nano",
"imageField": "images_list",
"hasPrompt": true,
"maxImages": 14,
"inputs": {
"prompt": {
"type": "string",
"title": "Prompt",
"name": "prompt",
"description": "Positive prompt for generation.",
"examples": [
"Transform the portrait into a cyberpunk style with neon lighting, metallic accessories, and a rain-soaked city background, maintaining the subject's facial features."
]
},
"aspect_ratio": {
"enum": [
"1:1", "1:4", "1:8", "2:3", "3:2", "3:4", "4:1", "4:3", "4:5",
"5:4", "8:1", "9:16", "16:9", "21:9", "auto"
],
"title": "Aspect Ratio",
"name": "aspect_ratio",
"type": "string",
"description": "The aspect ratio of the generated image.",
"default": "auto"
},
"resolution": {
"enum": ["1K", "2K", "4K"],
"title": "Resolution",
"name": "resolution",
"type": "string",
"description": "The resolution of the generated image.",
"default": "1K"
},
"google_search": {
"title": "Google Search",
"name": "google_search",
"type": "boolean",
"description": "Whether to use Google Search for prompt enhancement.",
"default": false
},
"output_format": {
"enum": ["jpg", "png"],
"title": "Output Format",
"name": "output_format",
"type": "string",
"description": "The format of the output image.",
"default": "jpg"
}
}
},
{
"id": "seedream-5.0-edit",
"name": "Seedream 5.0 Edit",
"endpoint": "seedream-5.0-edit",
"family": "seedream",
"imageField": "images_list",
"hasPrompt": true,
"inputs": {
"prompt": {
"type": "string",
"title": "Prompt",
"name": "prompt",
"description": "Text prompt describing the desired modification.",
"examples": [
"Change the daytime forest scene to a moonlit winter landscape with shimmering snow on the trees and a soft blue glow from a distant cottage window."
]
},
"aspect_ratio": {
"enum": ["1:1", "16:9", "9:16", "4:3", "3:4", "2:3", "3:2", "21:9"],
"title": "Aspect Ratio",
"name": "aspect_ratio",
"type": "string",
"description": "Aspect ratio of the output image.",
"default": "1:1"
},
"quality": {
"enum": ["basic", "high"],
"title": "Quality",
"name": "quality",
"type": "string",
"description": "Quality of the output image.",
"default": "basic"
}
}
}
];
@ -7707,7 +7901,40 @@ export const getResolutionsForI2VModel = (modelId) => {
export const getResolutionsForI2IModel = (modelId) => {
const model = getI2IModelById(modelId);
if (!model) return [];
const res = model.inputs && model.inputs.resolution;
if (res && res.enum) return res.enum;
if (model.inputs?.resolution?.enum) return model.inputs.resolution.enum;
if (model.inputs?.quality?.enum) return model.inputs.quality.enum;
return [];
};
// Returns the payload field name for quality/resolution for a t2i model ('resolution', 'quality', or null)
export const getQualityFieldForModel = (modelId) => {
const model = getModelById(modelId);
if (!model) return null;
if (model.inputs?.resolution) return 'resolution';
if (model.inputs?.quality) return 'quality';
return null;
};
// Returns quality/resolution options for a t2i model
export const getResolutionsForModel = (modelId) => {
const model = getModelById(modelId);
if (!model) return [];
if (model.inputs?.resolution?.enum) return model.inputs.resolution.enum;
if (model.inputs?.quality?.enum) return model.inputs.quality.enum;
return [];
};
// Returns the payload field name for quality/resolution for an i2i model ('resolution', 'quality', or null)
export const getQualityFieldForI2IModel = (modelId) => {
const model = getI2IModelById(modelId);
if (!model) return null;
if (model.inputs?.resolution) return 'resolution';
if (model.inputs?.quality) return 'quality';
return null;
};
// Returns the maximum number of images an i2i model accepts (defaults to 1)
export const getMaxImagesForI2IModel = (modelId) => {
const model = getI2IModelById(modelId);
return model?.maxImages || 1;
};

View file

@ -47,6 +47,11 @@ export class MuapiClient {
finalPayload.resolution = params.resolution;
}
// Quality (used by seedream and similar models)
if (params.quality) {
finalPayload.quality = params.quality;
}
// Image-to-Image
if (params.image_url) {
finalPayload.image_url = params.image_url;
@ -234,18 +239,20 @@ export class MuapiClient {
// Only include prompt if the model supports it and one was provided
if (params.prompt) finalPayload.prompt = params.prompt;
// Place the uploaded image in the correct field for this model
// Place the uploaded image(s) in the correct field for this model
const imageField = modelInfo?.imageField || 'image_url';
if (params.image_url) {
const imagesList = params.images_list?.length > 0 ? params.images_list : (params.image_url ? [params.image_url] : null);
if (imagesList) {
if (imageField === 'images_list') {
finalPayload.images_list = [params.image_url];
finalPayload.images_list = imagesList;
} else {
finalPayload[imageField] = params.image_url;
finalPayload[imageField] = imagesList[0];
}
}
if (params.aspect_ratio) finalPayload.aspect_ratio = params.aspect_ratio;
if (params.resolution) finalPayload.resolution = params.resolution;
if (params.quality) finalPayload.quality = params.quality;
console.log('[Muapi] I2I Request:', url);
console.log('[Muapi] I2I Payload:', finalPayload);