diff --git a/README.md b/README.md
index d3e5eb5..962b296 100644
--- a/README.md
+++ b/README.md
@@ -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 | 2–3 |
+| Qwen Image Edit Plus / 2511 | 3 |
+| GPT-4o Image to Image | 5 |
+| Flux 2 Klein 4b/9b Edit | 4 |
+
+When a multi-image model is selected the upload trigger switches to multi-select mode:
+- **Checkboxes with order numbers** — images are sent to the model in the order you select them
+- **Batch upload** — pick multiple files at once from your file dialog
+- **Count badge** on the trigger shows how many images are active; a `+` badge appears when more slots are available
+- **"Use Selected" button** confirms and closes the picker
### 🎬 Video Studio — Dual Mode
@@ -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 |
diff --git a/src/components/ImageStudio.js b/src/components/ImageStudio.js
index 41a462a..62f2f66 100644
--- a/src/components/ImageStudio.js
+++ b/src/components/ImageStudio.js
@@ -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);
}
diff --git a/src/components/UploadPicker.js b/src/components/UploadPicker.js
index 8957448..2d9879d 100644
--- a/src/components/UploadPicker.js
+++ b/src/components/UploadPicker.js
@@ -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 = `◌`;
- // 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 = ``;
+ 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 = ``;
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 = `${count}`;
+ 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 = `+`;
+ 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 = ``;
+ 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 = `Reference Images`;
+
+ const headerLeft = document.createElement('div');
+ headerLeft.className = 'flex flex-col gap-0.5';
+ headerLeft.innerHTML = `Reference Images`;
+ 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 = ` Upload new`;
+ const uploadLabel = isMulti ? 'Upload files' : 'Upload new';
+ uploadNewBtn.innerHTML = ` ${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 = ``;
- 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 = `${selIdx + 1}`;
+ } else {
+ badge.innerHTML = ``;
+ }
+ 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 = `${selectedEntries.length} of ${maxImages} selected`;
+ 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 };
}
diff --git a/src/lib/models.js b/src/lib/models.js
index b08142d..c0611b7 100644
--- a/src/lib/models.js
+++ b/src/lib/models.js
@@ -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;
+};
diff --git a/src/lib/muapi.js b/src/lib/muapi.js
index 39d78c3..36b1140 100644
--- a/src/lib/muapi.js
+++ b/src/lib/muapi.js
@@ -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);