Add image upload history, i2i/i2v model support with dynamic mode switching
- Add uploadHistory.js: localStorage CRUD + canvas thumbnail generation - Add UploadPicker.js: reusable upload button + history panel component - Add i2i/i2v models from schema_data.json to models.js (115 models) - Add generateI2I, generateI2V, uploadFile methods to muapi.js - ImageStudio: dynamically switches between t2i and i2i models based on image presence - VideoStudio: dynamically switches between t2v and i2v models based on image presence - Fix i2i/i2v model display names derived from slugs instead of generic variant labels
This commit is contained in:
parent
a205e303a0
commit
996571828c
6 changed files with 5921 additions and 85 deletions
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
250
src/components/UploadPicker.js
Normal file
250
src/components/UploadPicker.js
Normal 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 };
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
5285
src/lib/models.js
5285
src/lib/models.js
File diff suppressed because it is too large
Load diff
168
src/lib/muapi.js
168
src/lib/muapi.js
|
|
@ -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
52
src/lib/uploadHistory.js
Normal 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;
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue