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);