Merge pull request #33 from Anil-matcha/master

Add image upload history, i2i/i2v model support with dynamic mode swi…
This commit is contained in:
Anil Chandra Naidu Matcha 2026-02-23 21:06:35 +05:30 committed by GitHub
commit 5b1cfcf347
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 5921 additions and 85 deletions

View file

@ -1,6 +1,7 @@
import { muapi } from '../lib/muapi.js';
import { t2iModels, getAspectRatiosForModel } from '../lib/models.js';
import { t2iModels, getAspectRatiosForModel, i2iModels, getAspectRatiosForI2IModel, getResolutionsForI2IModel } from '../lib/models.js';
import { AuthModal } from './AuthModal.js';
import { createUploadPicker } from './UploadPicker.js';
export function ImageStudio() {
const container = document.createElement('div');
@ -10,31 +11,14 @@ export function ImageStudio() {
const defaultModel = t2iModels[0];
let selectedModel = defaultModel.id;
let selectedModelName = defaultModel.name;
let selectedAr = '1:1';
let selectedAr = defaultModel.inputs?.aspect_ratio?.default || '1:1';
let dropdownOpen = null;
let uploadedImageUrl = null;
let imageMode = false; // false = t2i models, true = i2i models
// Helper: Get valid resolutions/quality options for a model
const getResolutionsForModel = (modelId) => {
const model = t2iModels.find(m => m.id === modelId);
if (!model) return ['1K']; // Default
// Check for specific resolution enum
if (model.inputs?.resolution?.enum) {
return model.inputs.resolution.enum.map(r => r.toUpperCase());
}
// Check for megapixels enum
if (model.inputs?.megapixels?.enum) {
return model.inputs.megapixels.enum;
}
// Fallback logic based on common models
if (modelId.includes('flux')) return ['1K']; // Flux usually fixed
if (modelId.includes('midjourney')) return ['1K'];
// Default set for others if not specified
return ['1K', '2K', '4K'];
};
const getCurrentModels = () => imageMode ? i2iModels : t2iModels;
const getCurrentAspectRatios = (id) => imageMode ? getAspectRatiosForI2IModel(id) : getAspectRatiosForModel(id);
const getCurrentResolutions = (id) => imageMode ? getResolutionsForI2IModel(id) : [];
// ==========================================
// 1. HERO SECTION
@ -59,8 +43,8 @@ export function ImageStudio() {
<div class="absolute top-4 right-4 text-primary animate-pulse"></div>
</div>
</div>
<h1 class="text-2xl sm:text-4xl md:text-7xl font-black text-white tracking-widest uppercase mb-4 selection:bg-primary selection:text-black text-center px-4">Nano Banana Pro</h1>
<p class="text-secondary text-sm font-medium tracking-wide opacity-60">Create stunning, high-aesthetic images in seconds</p>
<h1 class="text-2xl sm:text-4xl md:text-7xl font-black text-white tracking-widest uppercase mb-4 selection:bg-primary selection:text-black text-center px-4">Image Studio</h1>
<p class="text-secondary text-sm font-medium tracking-wide opacity-60">Transform images with AI upscale, stylize, animate and more</p>
`;
container.appendChild(hero);
@ -78,10 +62,41 @@ export function ImageStudio() {
const topRow = document.createElement('div');
topRow.className = 'flex items-start gap-5 px-2';
topRow.innerHTML = ``;
// --- Image Upload Picker (Image-to-Image) ---
const picker = createUploadPicker({
anchorContainer: container,
onSelect: ({ url }) => {
uploadedImageUrl = url;
if (!imageMode) {
imageMode = true;
selectedModel = i2iModels[0].id;
selectedModelName = i2iModels[0].name;
selectedAr = getAspectRatiosForI2IModel(selectedModel)[0];
document.getElementById('model-btn-label').textContent = selectedModelName;
document.getElementById('ar-btn-label').textContent = selectedAr;
const validResolutions = getResolutionsForI2IModel(selectedModel);
qualityBtn.style.display = validResolutions.length > 0 ? 'flex' : 'none';
if (validResolutions.length > 0) document.getElementById('quality-btn-label').textContent = validResolutions[0];
}
textarea.placeholder = 'Describe how to transform this image (optional)';
},
onClear: () => {
uploadedImageUrl = null;
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';
textarea.placeholder = 'Describe the image you want to create';
}
});
topRow.appendChild(picker.trigger);
container.appendChild(picker.panel);
const textarea = document.createElement('textarea');
textarea.placeholder = 'Describe the scene you imagine';
textarea.placeholder = 'Describe the image you want to create';
textarea.className = 'flex-1 bg-transparent border-none text-white text-base md:text-xl placeholder:text-muted focus:outline-none resize-none pt-2.5 leading-relaxed min-h-[40px] max-h-[150px] md:max-h-[250px] overflow-y-auto custom-scrollbar';
textarea.rows = 1;
textarea.oninput = () => {
@ -124,16 +139,12 @@ export function ImageStudio() {
const qualityBtn = createControlBtn(`
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="opacity-60 text-secondary"><path d="M6 2L3 6v15a2 2 0 002 2h14a2 2 0 002-2V6l-3-4H6z"/></svg>
`, '1K', 'quality-btn');
`, '720p', 'quality-btn');
controlsLeft.appendChild(modelBtn);
controlsLeft.appendChild(arBtn);
controlsLeft.appendChild(qualityBtn);
// Initial Resolution Visibility (only show for models with explicit resolution/megapixels enums)
const initialModel = t2iModels[0];
const hasInitialRes = initialModel?.inputs?.resolution?.enum || initialModel?.inputs?.megapixels?.enum;
qualityBtn.style.display = hasInitialRes ? 'flex' : 'none';
qualityBtn.style.display = 'none'; // hidden in t2i mode, shown when i2i model has resolutions
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';
@ -175,14 +186,14 @@ export function ImageStudio() {
const renderModels = (filter = '') => {
list.innerHTML = '';
const filtered = t2iModels.filter(m => m.name.toLowerCase().includes(filter.toLowerCase()) || m.id.toLowerCase().includes(filter.toLowerCase()));
const filtered = getCurrentModels().filter(m => m.name.toLowerCase().includes(filter.toLowerCase()) || m.id.toLowerCase().includes(filter.toLowerCase()));
filtered.forEach(m => {
const item = document.createElement('div');
item.className = `flex items-center justify-between p-3.5 hover:bg-white/5 rounded-2xl cursor-pointer transition-all border border-transparent hover:border-white/5 ${selectedModel === m.id ? 'bg-white/5 border-white/5' : ''}`;
item.innerHTML = `
<div class="flex items-center gap-3.5">
<div class="w-10 h-10 ${m.id.includes('flux') ? 'bg-blue-500/10 text-blue-400' : 'bg-primary/10 text-primary'} border border-white/5 rounded-xl flex items-center justify-center font-black text-sm shadow-inner uppercase">${m.name.charAt(0)}</div>
<div class="w-10 h-10 ${m.family === 'kontext' ? 'bg-blue-500/10 text-blue-400' : m.family === 'effects' ? 'bg-purple-500/10 text-purple-400' : 'bg-primary/10 text-primary'} border border-white/5 rounded-xl flex items-center justify-center font-black text-sm shadow-inner uppercase">${m.name.charAt(0)}</div>
<div class="flex flex-col gap-0.5">
<span class="text-xs font-bold text-white tracking-tight">${m.name}</span>
</div>
@ -193,24 +204,15 @@ export function ImageStudio() {
e.stopPropagation();
selectedModel = m.id;
selectedModelName = m.name;
// Reset AR to first valid for model
const availableArs = getAspectRatiosForModel(selectedModel);
const availableArs = getCurrentAspectRatios(selectedModel);
selectedAr = availableArs[0];
document.getElementById('model-btn-label').textContent = selectedModelName;
document.getElementById('ar-btn-label').textContent = selectedAr;
// Show/Hide quality button based on model support (only resolution/megapixels enums)
const model = t2iModels.find(mod => mod.id === selectedModel);
const hasQuality = model?.inputs?.resolution?.enum || model?.inputs?.megapixels?.enum;
qualityBtn.style.display = hasQuality ? 'flex' : 'none';
// Reset resolution label if current is not valid for new model
if (hasQuality) {
const validResolutions = getResolutionsForModel(selectedModel);
const currentRes = document.getElementById('quality-btn-label').textContent;
if (!validResolutions.includes(currentRes)) {
document.getElementById('quality-btn-label').textContent = validResolutions[0];
}
const validResolutions = getCurrentResolutions(selectedModel);
qualityBtn.style.display = validResolutions.length > 0 ? 'flex' : 'none';
if (validResolutions.length > 0) {
document.getElementById('quality-btn-label').textContent = validResolutions[0];
}
closeDropdown();
@ -231,7 +233,7 @@ export function ImageStudio() {
const list = document.createElement('div');
list.className = 'flex flex-col gap-1';
const availableArs = getAspectRatiosForModel(selectedModel);
const availableArs = getCurrentAspectRatios(selectedModel);
availableArs.forEach(r => {
const item = document.createElement('div');
item.className = 'flex items-center justify-between p-3.5 hover:bg-white/5 rounded-2xl cursor-pointer transition-all group';
@ -259,8 +261,7 @@ export function ImageStudio() {
const list = document.createElement('div');
list.className = 'flex flex-col gap-1';
// Dynamic resolution options
const options = getResolutionsForModel(selectedModel);
const options = getCurrentResolutions(selectedModel);
options.forEach(opt => {
const item = document.createElement('div');
@ -506,6 +507,17 @@ export function ImageStudio() {
hero.classList.remove('hidden', 'opacity-0', 'scale-95', '-translate-y-10', 'pointer-events-none');
promptWrapper.classList.remove('hidden', 'opacity-40');
textarea.value = '';
picker.reset();
uploadedImageUrl = null;
// Reset to t2i mode
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';
textarea.placeholder = 'Describe the image you want to create';
textarea.focus();
};
@ -514,30 +526,46 @@ export function ImageStudio() {
// ==========================================
generateBtn.onclick = async () => {
const prompt = textarea.value.trim();
if (!prompt) return;
if (imageMode) {
if (!uploadedImageUrl) {
alert('Please upload a reference image first.');
return;
}
} else {
if (!prompt) {
alert('Please enter a prompt to generate an image.');
return;
}
}
// Lazy API Key Check
const apiKey = localStorage.getItem('muapi_key');
if (!apiKey) {
AuthModal(() => {
// Key saved, now trigger generation
generateBtn.click();
});
AuthModal(() => generateBtn.click());
return;
}
// Animate Out Hero
hero.classList.add('opacity-0', 'scale-95', '-translate-y-10', 'pointer-events-none');
generateBtn.disabled = true;
generateBtn.innerHTML = `<span class="animate-spin inline-block mr-2 text-black">◌</span> Generating...`;
try {
const res = await muapi.generateImage({
prompt,
model: selectedModel,
aspect_ratio: selectedAr
});
let res;
if (imageMode) {
const genParams = {
model: selectedModel,
image_url: uploadedImageUrl,
aspect_ratio: selectedAr
};
if (prompt) genParams.prompt = prompt;
res = await muapi.generateI2I(genParams);
} else {
const genParams = {
model: selectedModel,
prompt,
aspect_ratio: selectedAr
};
res = await muapi.generateImage(genParams);
}
console.log('[ImageStudio] Full response:', res);

View file

@ -0,0 +1,250 @@
import { muapi } from '../lib/muapi.js';
import { AuthModal } from './AuthModal.js';
import { getUploadHistory, saveUpload, removeUpload, generateThumbnail } from '../lib/uploadHistory.js';
/**
* Creates a self-contained upload picker: a trigger button + history panel.
*
* @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 }}
*/
export function createUploadPicker({ anchorContainer, onSelect, onClear }) {
let panelOpen = false;
let selectedEntry = null; // { url, thumbnail }
// ── Hidden file input ────────────────────────────────────────────────────
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'image/*';
fileInput.className = 'hidden';
// ── Trigger button ───────────────────────────────────────────────────────
const trigger = document.createElement('button');
trigger.type = 'button';
trigger.title = 'Reference image';
trigger.className = 'w-10 h-10 shrink-0 rounded-xl border transition-all flex items-center justify-center relative overflow-hidden mt-1.5 bg-white/5 border-white/10 hover:bg-white/10 hover:border-primary/40 group';
// State: icon
const iconState = document.createElement('div');
iconState.className = 'flex items-center justify-center w-full h-full';
iconState.innerHTML = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-muted group-hover:text-primary transition-colors"><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>`;
// State: spinner
const spinnerState = document.createElement('div');
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
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>`;
thumbnailState.appendChild(thumbImg);
thumbnailState.appendChild(badge);
trigger.appendChild(fileInput);
trigger.appendChild(iconState);
trigger.appendChild(spinnerState);
trigger.appendChild(thumbnailState);
// ── Trigger state helpers ────────────────────────────────────────────────
const showIcon = () => {
iconState.classList.replace('hidden', 'flex');
spinnerState.classList.add('hidden'); spinnerState.classList.remove('flex');
thumbnailState.classList.add('hidden'); thumbnailState.classList.remove('flex');
trigger.classList.remove('border-primary/60');
trigger.classList.add('border-white/10');
};
const showSpinner = () => {
iconState.classList.add('hidden'); iconState.classList.remove('flex');
spinnerState.classList.replace('hidden', 'flex');
thumbnailState.classList.add('hidden'); thumbnailState.classList.remove('flex');
};
const showThumbnail = (src) => {
thumbImg.src = src;
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');
};
// ── 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';
const openPanel = () => {
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`;
panel.style.bottom = `${containerRect.bottom - btnRect.top + 8}px`;
panelOpen = true;
};
const closePanel = () => {
panel.classList.add('opacity-0', 'pointer-events-none', 'scale-95');
panel.classList.remove('opacity-100', 'pointer-events-auto', 'scale-100');
panelOpen = false;
};
const renderPanel = () => {
panel.innerHTML = '';
const history = getUploadHistory();
// 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 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`;
uploadNewBtn.onclick = (e) => { e.stopPropagation(); closePanel(); fileInput.click(); };
header.appendChild(uploadNewBtn);
panel.appendChild(header);
if (history.length === 0) {
const empty = document.createElement('div');
empty.className = 'py-6 flex flex-col items-center gap-2 opacity-40';
empty.innerHTML = `
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="text-secondary"><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>
<span class="text-xs text-secondary">No uploads yet</span>
`;
panel.appendChild(empty);
return;
}
// Grid of saved uploads
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 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'}`;
cell.title = entry.name;
const img = document.createElement('img');
img.src = entry.thumbnail;
img.className = 'w-full h-full object-cover';
// Hover overlay with delete button
const overlay = document.createElement('div');
overlay.className = 'absolute inset-0 bg-black/60 opacity-0 group-hover/cell:opacity-100 transition-opacity flex items-end justify-end p-1';
const delBtn = document.createElement('button');
delBtn.type = 'button';
delBtn.className = 'w-5 h-5 bg-red-500/80 hover:bg-red-500 rounded-md flex items-center justify-center transition-colors';
delBtn.title = 'Remove from history';
delBtn.innerHTML = `<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`;
delBtn.onclick = (e) => {
e.stopPropagation();
removeUpload(entry.id);
if (selectedEntry?.url === entry.uploadedUrl) {
selectedEntry = null;
showIcon();
onClear?.();
}
renderPanel();
};
overlay.appendChild(delBtn);
// Selected checkmark badge
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);
}
cell.appendChild(img);
cell.appendChild(overlay);
cell.onclick = (e) => {
e.stopPropagation();
selectedEntry = { url: entry.uploadedUrl, thumbnail: entry.thumbnail };
showThumbnail(entry.thumbnail);
onSelect({ url: entry.uploadedUrl, thumbnail: entry.thumbnail });
closePanel();
};
grid.appendChild(cell);
});
panel.appendChild(grid);
};
// ── Trigger click ────────────────────────────────────────────────────────
trigger.onclick = (e) => {
e.stopPropagation();
if (panelOpen) closePanel();
else openPanel();
};
// Close panel on outside click
window.addEventListener('click', closePanel);
// ── File upload handler ──────────────────────────────────────────────────
fileInput.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
const apiKey = localStorage.getItem('muapi_key');
if (!apiKey) {
AuthModal(() => fileInput.click());
return;
}
showSpinner();
try {
// Upload to API and generate thumbnail in parallel
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);
selectedEntry = { url: uploadedUrl, thumbnail };
showThumbnail(thumbnail);
onSelect({ url: uploadedUrl, thumbnail });
} catch (err) {
console.error('[UploadPicker] Upload failed:', err);
showIcon();
alert(`Image upload failed: ${err.message}`);
}
fileInput.value = '';
};
// ── Public API ───────────────────────────────────────────────────────────
const reset = () => {
selectedEntry = null;
showIcon();
closePanel();
};
return { trigger, panel, reset };
}

View file

@ -1,6 +1,7 @@
import { muapi } from '../lib/muapi.js';
import { t2vModels, getAspectRatiosForVideoModel, getDurationsForModel, getResolutionsForVideoModel } from '../lib/models.js';
import { t2vModels, getAspectRatiosForVideoModel, getDurationsForModel, getResolutionsForVideoModel, i2vModels, getAspectRatiosForI2VModel, getDurationsForI2VModel, getResolutionsForI2VModel } from '../lib/models.js';
import { AuthModal } from './AuthModal.js';
import { createUploadPicker } from './UploadPicker.js';
export function VideoStudio() {
const container = document.createElement('div');
@ -14,6 +15,13 @@ export function VideoStudio() {
let selectedDuration = defaultModel.inputs?.duration?.default || 5;
let selectedResolution = defaultModel.inputs?.resolution?.default || '';
let dropdownOpen = null;
let uploadedImageUrl = null;
let imageMode = false; // false = t2v models, true = i2v models
const getCurrentModels = () => imageMode ? i2vModels : t2vModels;
const getCurrentAspectRatios = (id) => imageMode ? getAspectRatiosForI2VModel(id) : getAspectRatiosForVideoModel(id);
const getCurrentDurations = (id) => imageMode ? getDurationsForI2VModel(id) : getDurationsForModel(id);
const getCurrentResolutions = (id) => imageMode ? getResolutionsForI2VModel(id) : getResolutionsForVideoModel(id);
// ==========================================
// 1. HERO SECTION
@ -38,7 +46,7 @@ export function VideoStudio() {
</div>
</div>
<h1 class="text-2xl sm:text-4xl md:text-7xl font-black text-white tracking-widest uppercase mb-4 selection:bg-primary selection:text-black text-center px-4">Video Studio</h1>
<p class="text-secondary text-sm font-medium tracking-wide opacity-60">Create stunning AI videos from text in seconds</p>
<p class="text-secondary text-sm font-medium tracking-wide opacity-60">Animate images into stunning AI videos with motion effects</p>
`;
container.appendChild(hero);
@ -55,8 +63,35 @@ export function VideoStudio() {
const topRow = document.createElement('div');
topRow.className = 'flex items-start gap-5 px-2';
// --- Image Upload Picker (Image-to-Video) ---
const picker = createUploadPicker({
anchorContainer: container,
onSelect: ({ url }) => {
uploadedImageUrl = url;
if (!imageMode) {
imageMode = true;
selectedModel = i2vModels[0].id;
selectedModelName = i2vModels[0].name;
document.getElementById('v-model-btn-label').textContent = selectedModelName;
updateControlsForModel(selectedModel);
}
textarea.placeholder = 'Describe the motion or effect (optional)';
},
onClear: () => {
uploadedImageUrl = null;
imageMode = false;
selectedModel = t2vModels[0].id;
selectedModelName = t2vModels[0].name;
document.getElementById('v-model-btn-label').textContent = selectedModelName;
updateControlsForModel(selectedModel);
textarea.placeholder = 'Describe the video you want to create';
}
});
topRow.appendChild(picker.trigger);
container.appendChild(picker.panel);
const textarea = document.createElement('textarea');
textarea.placeholder = 'Describe the video you imagine';
textarea.placeholder = 'Describe the video you want to create';
textarea.className = 'flex-1 bg-transparent border-none text-white text-base md:text-xl placeholder:text-muted focus:outline-none resize-none pt-2.5 leading-relaxed min-h-[40px] max-h-[150px] md:max-h-[250px] overflow-y-auto custom-scrollbar';
textarea.rows = 1;
textarea.oninput = () => {
@ -110,7 +145,7 @@ export function VideoStudio() {
controlsLeft.appendChild(durationBtn);
controlsLeft.appendChild(resolutionBtn);
// Initial visibility
// Initial visibility (t2v mode)
const initDurations = getDurationsForModel(defaultModel.id);
durationBtn.style.display = initDurations.length > 0 ? 'flex' : 'none';
const initResolutions = getResolutionsForVideoModel(defaultModel.id);
@ -133,11 +168,11 @@ export function VideoStudio() {
dropdown.className = 'absolute bottom-[102%] left-2 z-50 transition-all opacity-0 pointer-events-none scale-95 origin-bottom-left glass rounded-3xl p-3 translate-y-2 w-[calc(100vw-3rem)] max-w-xs shadow-4xl border border-white/10 flex flex-col';
const updateControlsForModel = (modelId) => {
const availableArs = getAspectRatiosForVideoModel(modelId);
const availableArs = getCurrentAspectRatios(modelId);
selectedAr = availableArs[0];
document.getElementById('v-ar-btn-label').textContent = selectedAr;
const durations = getDurationsForModel(modelId);
const durations = getCurrentDurations(modelId);
if (durations.length > 0) {
selectedDuration = durations[0];
document.getElementById('v-duration-btn-label').textContent = `${selectedDuration}s`;
@ -146,7 +181,7 @@ export function VideoStudio() {
durationBtn.style.display = 'none';
}
const resolutions = getResolutionsForVideoModel(modelId);
const resolutions = getCurrentResolutions(modelId);
if (resolutions.length > 0) {
selectedResolution = resolutions[0];
document.getElementById('v-resolution-btn-label').textContent = selectedResolution;
@ -180,7 +215,7 @@ export function VideoStudio() {
const renderModels = (filter = '') => {
list.innerHTML = '';
const filtered = t2vModels.filter(m => m.name.toLowerCase().includes(filter.toLowerCase()) || m.id.toLowerCase().includes(filter.toLowerCase()));
const filtered = getCurrentModels().filter(m => m.name.toLowerCase().includes(filter.toLowerCase()) || m.id.toLowerCase().includes(filter.toLowerCase()));
filtered.forEach(m => {
const item = document.createElement('div');
item.className = `flex items-center justify-between p-3.5 hover:bg-white/5 rounded-2xl cursor-pointer transition-all border border-transparent hover:border-white/5 ${selectedModel === m.id ? 'bg-white/5 border-white/5' : ''}`;
@ -215,7 +250,7 @@ export function VideoStudio() {
dropdown.innerHTML = `<div class="text-[10px] font-bold text-muted uppercase tracking-widest px-3 py-2 border-b border-white/5 mb-2">Aspect Ratio</div>`;
const list = document.createElement('div');
list.className = 'flex flex-col gap-1';
const availableArs = getAspectRatiosForVideoModel(selectedModel);
const availableArs = getCurrentAspectRatios(selectedModel);
availableArs.forEach(r => {
const item = document.createElement('div');
item.className = 'flex items-center justify-between p-3.5 hover:bg-white/5 rounded-2xl cursor-pointer transition-all group';
@ -243,7 +278,7 @@ export function VideoStudio() {
dropdown.innerHTML = `<div class="text-[10px] font-bold text-secondary uppercase tracking-widest px-3 py-2 border-b border-white/5 mb-2">Duration</div>`;
const list = document.createElement('div');
list.className = 'flex flex-col gap-1';
const durations = getDurationsForModel(selectedModel);
const durations = getCurrentDurations(selectedModel);
durations.forEach(d => {
const item = document.createElement('div');
item.className = 'flex items-center justify-between p-3.5 hover:bg-white/5 rounded-2xl cursor-pointer transition-all group';
@ -266,7 +301,7 @@ export function VideoStudio() {
dropdown.innerHTML = `<div class="text-[10px] font-bold text-secondary uppercase tracking-widest px-3 py-2 border-b border-white/5 mb-2">Resolution</div>`;
const list = document.createElement('div');
list.className = 'flex flex-col gap-1';
const resolutions = getResolutionsForVideoModel(selectedModel);
const resolutions = getCurrentResolutions(selectedModel);
resolutions.forEach(r => {
const item = document.createElement('div');
item.className = 'flex items-center justify-between p-3.5 hover:bg-white/5 rounded-2xl cursor-pointer transition-all group';
@ -481,6 +516,15 @@ export function VideoStudio() {
hero.classList.remove('hidden', 'opacity-0', 'scale-95', '-translate-y-10', 'pointer-events-none');
promptWrapper.classList.remove('hidden', 'opacity-40');
textarea.value = '';
picker.reset();
uploadedImageUrl = null;
// Reset to t2v mode
imageMode = false;
selectedModel = t2vModels[0].id;
selectedModelName = t2vModels[0].name;
document.getElementById('v-model-btn-label').textContent = selectedModelName;
updateControlsForModel(selectedModel);
textarea.placeholder = 'Describe the video you want to create';
textarea.focus();
};
@ -489,7 +533,17 @@ export function VideoStudio() {
// ==========================================
generateBtn.onclick = async () => {
const prompt = textarea.value.trim();
if (!prompt) return;
if (imageMode) {
if (!uploadedImageUrl) {
alert('Please upload a start frame image first.');
return;
}
} else {
if (!prompt) {
alert('Please enter a prompt to generate a video.');
return;
}
}
const apiKey = localStorage.getItem('muapi_key');
if (!apiKey) {
@ -498,27 +552,28 @@ export function VideoStudio() {
}
hero.classList.add('opacity-0', 'scale-95', '-translate-y-10', 'pointer-events-none');
generateBtn.disabled = true;
generateBtn.innerHTML = `<span class="animate-spin inline-block mr-2 text-black">◌</span> Generating...`;
try {
const params = {
prompt,
model: selectedModel,
aspect_ratio: selectedAr,
};
const durations = getDurationsForModel(selectedModel);
if (prompt) params.prompt = prompt;
if (imageMode && uploadedImageUrl) params.image_url = uploadedImageUrl;
const durations = getCurrentDurations(selectedModel);
if (durations.length > 0) params.duration = selectedDuration;
const resolutions = getResolutionsForVideoModel(selectedModel);
const resolutions = getCurrentResolutions(selectedModel);
if (resolutions.length > 0) params.resolution = selectedResolution;
const model = t2vModels.find(m => m.id === selectedModel);
const model = getCurrentModels().find(m => m.id === selectedModel);
if (model?.inputs?.quality) params.quality = model.inputs.quality.default;
const res = await muapi.generateVideo(params);
const res = imageMode ? await muapi.generateI2V(params) : await muapi.generateVideo(params);
console.log('[VideoStudio] Full response:', res);

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,4 @@
import { getModelById, getVideoModelById } from './models.js';
import { getModelById, getVideoModelById, getI2IModelById, getI2VModelById } from './models.js';
export class MuapiClient {
constructor() {
@ -173,6 +173,7 @@ export class MuapiClient {
if (params.duration) finalPayload.duration = params.duration;
if (params.resolution) finalPayload.resolution = params.resolution;
if (params.quality) finalPayload.quality = params.quality;
if (params.image_url) finalPayload.image_url = params.image_url;
console.log('[Muapi] Video Request:', url);
console.log('[Muapi] Video Payload:', finalPayload);
@ -212,6 +213,171 @@ export class MuapiClient {
}
}
/**
* Generates an image using an Image-to-Image model.
* The model's imageField determines which payload key receives the uploaded image URL.
* @param {Object} params
* @param {string} params.model - i2iModel id
* @param {string} params.image_url - The uploaded reference image URL
* @param {string} [params.prompt] - Optional text prompt
* @param {string} [params.aspect_ratio]
* @param {string} [params.resolution]
*/
async generateI2I(params) {
const key = this.getKey();
const modelInfo = getI2IModelById(params.model);
const endpoint = modelInfo?.endpoint || params.model;
const url = `${this.baseUrl}/api/v1/${endpoint}`;
const finalPayload = {};
// 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
const imageField = modelInfo?.imageField || 'image_url';
if (params.image_url) {
if (imageField === 'images_list') {
finalPayload.images_list = [params.image_url];
} else {
finalPayload[imageField] = params.image_url;
}
}
if (params.aspect_ratio) finalPayload.aspect_ratio = params.aspect_ratio;
if (params.resolution) finalPayload.resolution = params.resolution;
console.log('[Muapi] I2I Request:', url);
console.log('[Muapi] I2I Payload:', finalPayload);
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': key },
body: JSON.stringify(finalPayload)
});
if (!response.ok) {
const errText = await response.text();
throw new Error(`API Request Failed: ${response.status} ${response.statusText} - ${errText.slice(0, 100)}`);
}
const submitData = await response.json();
console.log('[Muapi] I2I Submit Response:', submitData);
const requestId = submitData.request_id || submitData.id;
if (!requestId) return submitData;
const result = await this.pollForResult(requestId, key);
const imageUrl = result.outputs?.[0] || result.url || result.output?.url;
console.log('[Muapi] I2I Result URL:', imageUrl);
return { ...result, url: imageUrl };
} catch (error) {
console.error('Muapi I2I Error:', error);
throw error;
}
}
/**
* Generates a video using an Image-to-Video model.
* @param {Object} params
* @param {string} params.model - i2vModel id
* @param {string} params.image_url - The uploaded start frame image URL
* @param {string} [params.prompt]
* @param {string} [params.aspect_ratio]
* @param {string} [params.resolution]
* @param {number} [params.duration]
* @param {string} [params.quality]
*/
async generateI2V(params) {
const key = this.getKey();
const modelInfo = getI2VModelById(params.model);
const endpoint = modelInfo?.endpoint || params.model;
const url = `${this.baseUrl}/api/v1/${endpoint}`;
const finalPayload = {};
if (params.prompt) finalPayload.prompt = params.prompt;
// Place image in the correct field for this model
const imageField = modelInfo?.imageField || 'image_url';
if (params.image_url) {
if (imageField === 'images_list') {
finalPayload.images_list = [params.image_url];
} else {
finalPayload[imageField] = params.image_url;
}
}
if (params.aspect_ratio) finalPayload.aspect_ratio = params.aspect_ratio;
if (params.duration) finalPayload.duration = params.duration;
if (params.resolution) finalPayload.resolution = params.resolution;
if (params.quality) finalPayload.quality = params.quality;
console.log('[Muapi] I2V Request:', url);
console.log('[Muapi] I2V Payload:', finalPayload);
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': key },
body: JSON.stringify(finalPayload)
});
if (!response.ok) {
const errText = await response.text();
throw new Error(`API Request Failed: ${response.status} ${response.statusText} - ${errText.slice(0, 100)}`);
}
const submitData = await response.json();
console.log('[Muapi] I2V Submit Response:', submitData);
const requestId = submitData.request_id || submitData.id;
if (!requestId) return submitData;
const result = await this.pollForResult(requestId, key, 120, 2000);
const videoUrl = result.outputs?.[0] || result.url || result.output?.url;
console.log('[Muapi] I2V Result URL:', videoUrl);
return { ...result, url: videoUrl };
} catch (error) {
console.error('Muapi I2V Error:', error);
throw error;
}
}
/**
* Uploads a file to muapi and returns the hosted URL.
* @param {File} file - The image file to upload
* @returns {Promise<string>} The hosted URL of the uploaded file
*/
async uploadFile(file) {
const key = this.getKey();
const url = `${this.baseUrl}/api/v1/upload_file`;
const formData = new FormData();
formData.append('file', file);
console.log('[Muapi] Uploading file:', file.name);
const response = await fetch(url, {
method: 'POST',
headers: { 'x-api-key': key },
body: formData
});
if (!response.ok) {
const errText = await response.text();
throw new Error(`File upload failed: ${response.status} - ${errText.slice(0, 100)}`);
}
const data = await response.json();
console.log('[Muapi] Upload response:', data);
const fileUrl = data.url || data.file_url || data.data?.url;
if (!fileUrl) throw new Error('No URL returned from file upload');
return fileUrl;
}
getDimensionsFromAR(ar) {
// Base unit 1024 (Flux standard)
switch (ar) {

52
src/lib/uploadHistory.js Normal file
View file

@ -0,0 +1,52 @@
const STORAGE_KEY = 'muapi_uploads';
const MAX_UPLOADS = 20;
export function getUploadHistory() {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
} catch {
return [];
}
}
export function saveUpload({ id, name, uploadedUrl, thumbnail, timestamp }) {
const history = getUploadHistory();
history.unshift({ id, name, uploadedUrl, thumbnail, timestamp });
localStorage.setItem(STORAGE_KEY, JSON.stringify(history.slice(0, MAX_UPLOADS)));
}
export function removeUpload(id) {
const history = getUploadHistory().filter(e => e.id !== id);
localStorage.setItem(STORAGE_KEY, JSON.stringify(history));
}
/**
* Generates a square 80×80 base64 JPEG thumbnail from a File.
* @param {File} file
* @returns {Promise<string|null>}
*/
export async function generateThumbnail(file) {
return new Promise((resolve) => {
const objectUrl = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
const SIZE = 80;
const canvas = document.createElement('canvas');
canvas.width = SIZE;
canvas.height = SIZE;
const ctx = canvas.getContext('2d');
// Center-crop to square
const size = Math.min(img.width, img.height);
const sx = (img.width - size) / 2;
const sy = (img.height - size) / 2;
ctx.drawImage(img, sx, sy, size, size, 0, 0, SIZE, SIZE);
URL.revokeObjectURL(objectUrl);
resolve(canvas.toDataURL('image/jpeg', 0.6));
};
img.onerror = () => {
URL.revokeObjectURL(objectUrl);
resolve(null);
};
img.src = objectUrl;
});
}