mirror of
https://github.com/Anil-matcha/Open-Generative-AI.git
synced 2026-05-07 01:17:18 +00:00
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:
parent
b61de547e5
commit
a65bdb2a77
5 changed files with 545 additions and 94 deletions
66
README.md
66
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 |
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue