mirror of
https://github.com/yusufipk/imagen-openrouter.git
synced 2026-09-11 10:36:07 +00:00
1187 lines
42 KiB
JavaScript
1187 lines
42 KiB
JavaScript
/**
|
|
* Imagen - Internal AI Image Generation Tool
|
|
* Supports multiple models via OpenRouter API
|
|
*/
|
|
|
|
// ===== IndexedDB Storage =====
|
|
const ImagenDB = {
|
|
dbName: 'ImagenDB',
|
|
storeName: 'images',
|
|
db: null,
|
|
|
|
async open() {
|
|
return new Promise((resolve, reject) => {
|
|
const request = indexedDB.open(this.dbName, 1);
|
|
|
|
request.onerror = () => reject(request.error);
|
|
request.onsuccess = () => {
|
|
this.db = request.result;
|
|
resolve(this.db);
|
|
};
|
|
|
|
request.onupgradeneeded = (event) => {
|
|
const db = event.target.result;
|
|
if (!db.objectStoreNames.contains(this.storeName)) {
|
|
const store = db.createObjectStore(this.storeName, { keyPath: 'id' });
|
|
store.createIndex('createdAt', 'createdAt', { unique: false });
|
|
}
|
|
};
|
|
});
|
|
},
|
|
|
|
async saveImage(imageData) {
|
|
await this.ensureOpen();
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = this.db.transaction([this.storeName], 'readwrite');
|
|
const store = transaction.objectStore(this.storeName);
|
|
const request = store.put(imageData);
|
|
request.onsuccess = () => resolve(request.result);
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
},
|
|
|
|
async getAllImages() {
|
|
await this.ensureOpen();
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = this.db.transaction([this.storeName], 'readonly');
|
|
const store = transaction.objectStore(this.storeName);
|
|
const request = store.getAll();
|
|
request.onsuccess = () => {
|
|
// Sort by createdAt descending (newest first)
|
|
const images = request.result.sort((a, b) =>
|
|
new Date(b.createdAt) - new Date(a.createdAt)
|
|
);
|
|
resolve(images);
|
|
};
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
},
|
|
|
|
async deleteImage(id) {
|
|
await this.ensureOpen();
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = this.db.transaction([this.storeName], 'readwrite');
|
|
const store = transaction.objectStore(this.storeName);
|
|
const request = store.delete(id);
|
|
request.onsuccess = () => resolve();
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
},
|
|
|
|
async clearAll() {
|
|
await this.ensureOpen();
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = this.db.transaction([this.storeName], 'readwrite');
|
|
const store = transaction.objectStore(this.storeName);
|
|
const request = store.clear();
|
|
request.onsuccess = () => resolve();
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
},
|
|
|
|
async ensureOpen() {
|
|
if (!this.db) {
|
|
await this.open();
|
|
}
|
|
}
|
|
};
|
|
|
|
// ===== State Management =====
|
|
const state = {
|
|
apiKey: localStorage.getItem('imagen_api_key') || '',
|
|
selectedModel: localStorage.getItem('imagen_model') || 'google/gemini-2.5-flash-image',
|
|
imageSize: localStorage.getItem('imagen_size') || '1024x1024',
|
|
imageQuality: localStorage.getItem('imagen_quality') || '1K',
|
|
aspectRatio: localStorage.getItem('imagen_aspect_ratio') || '1:1',
|
|
imageCount: parseInt(localStorage.getItem('imagen_count')) || 1,
|
|
references: [], // Dynamic array - unlimited references
|
|
images: [], // Will be loaded from IndexedDB
|
|
currentImage: null,
|
|
pendingBatches: [] // Track pending generation batches { id, prompt, count, completed, failed }
|
|
};
|
|
|
|
// ===== Model Configurations =====
|
|
const MODEL_CONFIGS = {
|
|
'google/gemini-2.5-flash-image': {
|
|
name: 'Gemini 2.5 Flash Image',
|
|
supportsImageSize: true,
|
|
supportsAspectRatio: true,
|
|
supportsImageInput: true,
|
|
maxReferences: 3
|
|
},
|
|
'google/gemini-2.5-flash-image-preview': {
|
|
name: 'Gemini 2.5 Flash Image (Preview)',
|
|
supportsImageSize: true,
|
|
supportsAspectRatio: true,
|
|
supportsImageInput: true,
|
|
maxReferences: 3
|
|
},
|
|
'google/gemini-3-pro-image-preview': {
|
|
name: 'Gemini 3 Pro Image (Preview)',
|
|
supportsImageSize: true,
|
|
supportsAspectRatio: true,
|
|
supportsImageInput: true,
|
|
maxReferences: 14
|
|
},
|
|
'openai/gpt-5-image': {
|
|
name: 'GPT-5 Image',
|
|
supportsImageSize: false,
|
|
supportsAspectRatio: true,
|
|
supportsImageInput: true,
|
|
maxReferences: 1
|
|
},
|
|
'openai/gpt-5-image-mini': {
|
|
name: 'GPT-5 Image Mini',
|
|
supportsImageSize: false,
|
|
supportsAspectRatio: true,
|
|
supportsImageInput: true,
|
|
maxReferences: 1
|
|
},
|
|
'black-forest-labs/flux.2-pro': {
|
|
name: 'Flux 2 Pro',
|
|
supportsImageSize: false,
|
|
supportsAspectRatio: true,
|
|
supportsImageInput: false,
|
|
maxReferences: 0
|
|
},
|
|
'black-forest-labs/flux.2-max': {
|
|
name: 'Flux 2 Max',
|
|
supportsImageSize: false,
|
|
supportsAspectRatio: true,
|
|
supportsImageInput: false,
|
|
maxReferences: 0
|
|
},
|
|
'black-forest-labs/flux.2-flex': {
|
|
name: 'Flux 2 Flex',
|
|
supportsImageSize: false,
|
|
supportsAspectRatio: true,
|
|
supportsImageInput: false,
|
|
maxReferences: 0
|
|
},
|
|
'black-forest-labs/flux.2-klein-4b': {
|
|
name: 'Flux 2 Klein 4B',
|
|
supportsImageSize: false,
|
|
supportsAspectRatio: true,
|
|
supportsImageInput: false,
|
|
maxReferences: 0
|
|
},
|
|
'bytedance-seed/seedream-4.5': {
|
|
name: 'Seedream 4.5',
|
|
supportsImageSize: false,
|
|
supportsAspectRatio: true,
|
|
supportsImageInput: false,
|
|
maxReferences: 0
|
|
},
|
|
'sourceful/riverflow-v2-fast-preview': {
|
|
name: 'Riverflow V2 Fast',
|
|
supportsImageSize: false,
|
|
supportsAspectRatio: true,
|
|
supportsImageInput: false,
|
|
maxReferences: 0
|
|
},
|
|
'sourceful/riverflow-v2-standard-preview': {
|
|
name: 'Riverflow V2 Standard',
|
|
supportsImageSize: false,
|
|
supportsAspectRatio: true,
|
|
supportsImageInput: false,
|
|
maxReferences: 0
|
|
},
|
|
'sourceful/riverflow-v2-max-preview': {
|
|
name: 'Riverflow V2 Max',
|
|
supportsImageSize: false,
|
|
supportsAspectRatio: true,
|
|
supportsImageInput: false,
|
|
maxReferences: 0
|
|
}
|
|
};
|
|
|
|
// ===== DOM Elements =====
|
|
const elements = {
|
|
// Sidebar
|
|
modelSelectContainer: document.getElementById('modelSelectContainer'),
|
|
modelSelectTrigger: document.getElementById('modelSelectTrigger'),
|
|
modelSelectValue: document.getElementById('modelSelectValue'),
|
|
modelSelectOptions: document.getElementById('modelSelectOptions'),
|
|
geminiOptions: document.getElementById('geminiOptions'),
|
|
apiKey: document.getElementById('apiKey'),
|
|
saveApiKey: document.getElementById('saveApiKey'),
|
|
imageCount: document.getElementById('imageCount'),
|
|
decreaseCount: document.getElementById('decreaseCount'),
|
|
increaseCount: document.getElementById('increaseCount'),
|
|
clearReferences: document.getElementById('clearReferences'),
|
|
referenceSlots: document.getElementById('referenceSlots'),
|
|
|
|
// Main Content
|
|
promptInput: document.getElementById('promptInput'),
|
|
charCount: document.getElementById('charCount'),
|
|
generateBtn: document.getElementById('generateBtn'),
|
|
gallery: document.getElementById('gallery'),
|
|
galleryEmpty: document.getElementById('galleryEmpty'),
|
|
clearGallery: document.getElementById('clearGallery'),
|
|
|
|
// Modal
|
|
imageModal: document.getElementById('imageModal'),
|
|
modalOverlay: document.getElementById('modalOverlay'),
|
|
modalClose: document.getElementById('modalClose'),
|
|
modalImage: document.getElementById('modalImage'),
|
|
modalMetadata: document.getElementById('modalMetadata'),
|
|
useAsReference: document.getElementById('useAsReference'),
|
|
recreateImage: document.getElementById('recreateImage'),
|
|
downloadImage: document.getElementById('downloadImage')
|
|
};
|
|
|
|
// ===== Initialization =====
|
|
async function init() {
|
|
// Load saved API key
|
|
if (state.apiKey) {
|
|
elements.apiKey.value = state.apiKey;
|
|
}
|
|
|
|
// Render reference slots
|
|
renderReferenceSlots();
|
|
|
|
// Restore saved model selection
|
|
if (state.selectedModel) {
|
|
const savedOption = document.querySelector(`.custom-select-option[data-value="${state.selectedModel}"]`);
|
|
if (savedOption) {
|
|
document.querySelectorAll('.custom-select-option').forEach(o => o.classList.remove('selected'));
|
|
savedOption.classList.add('selected');
|
|
elements.modelSelectValue.textContent = savedOption.textContent;
|
|
}
|
|
}
|
|
|
|
// Restore saved image quality/size
|
|
document.querySelectorAll('.btn-toggle').forEach(btn => {
|
|
btn.classList.remove('active');
|
|
if (btn.dataset.quality === state.imageQuality) {
|
|
btn.classList.add('active');
|
|
}
|
|
});
|
|
|
|
// Restore saved aspect ratio
|
|
document.querySelectorAll('.btn-aspect').forEach(btn => {
|
|
btn.classList.remove('active');
|
|
if (btn.dataset.ratio === state.aspectRatio) {
|
|
btn.classList.add('active');
|
|
}
|
|
});
|
|
|
|
// Restore saved image count
|
|
if (elements.imageCount) {
|
|
elements.imageCount.value = state.imageCount;
|
|
}
|
|
|
|
// Load images from IndexedDB
|
|
try {
|
|
state.images = await ImagenDB.getAllImages();
|
|
} catch (error) {
|
|
console.error('Failed to load images from IndexedDB:', error);
|
|
state.images = [];
|
|
}
|
|
|
|
// Render gallery
|
|
renderGallery();
|
|
|
|
// Set up event listeners
|
|
setupEventListeners();
|
|
|
|
// Initialize UI state
|
|
updateGeminiOptionsVisibility();
|
|
}
|
|
|
|
// ===== Event Listeners =====
|
|
function setupEventListeners() {
|
|
// Custom dropdown - toggle
|
|
elements.modelSelectTrigger.addEventListener('click', () => {
|
|
elements.modelSelectContainer.classList.toggle('open');
|
|
});
|
|
|
|
// Custom dropdown - option selection
|
|
document.querySelectorAll('.custom-select-option').forEach(option => {
|
|
option.addEventListener('click', () => {
|
|
state.selectedModel = option.dataset.value;
|
|
localStorage.setItem('imagen_model', state.selectedModel);
|
|
elements.modelSelectValue.textContent = option.textContent;
|
|
document.querySelectorAll('.custom-select-option').forEach(o => o.classList.remove('selected'));
|
|
option.classList.add('selected');
|
|
elements.modelSelectContainer.classList.remove('open');
|
|
updateGeminiOptionsVisibility();
|
|
});
|
|
});
|
|
|
|
// Close dropdown when clicking outside
|
|
document.addEventListener('click', (e) => {
|
|
if (!elements.modelSelectContainer.contains(e.target)) {
|
|
elements.modelSelectContainer.classList.remove('open');
|
|
}
|
|
});
|
|
|
|
// Size toggle buttons
|
|
document.querySelectorAll('.btn-toggle').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
document.querySelectorAll('.btn-toggle').forEach(b => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
state.imageSize = btn.dataset.size;
|
|
state.imageQuality = btn.dataset.quality;
|
|
localStorage.setItem('imagen_size', state.imageSize);
|
|
localStorage.setItem('imagen_quality', state.imageQuality);
|
|
});
|
|
});
|
|
|
|
// Aspect ratio buttons
|
|
document.querySelectorAll('.btn-aspect').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
document.querySelectorAll('.btn-aspect').forEach(b => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
state.aspectRatio = btn.dataset.ratio;
|
|
localStorage.setItem('imagen_aspect_ratio', state.aspectRatio);
|
|
});
|
|
});
|
|
|
|
// Image count
|
|
if (elements.decreaseCount) {
|
|
elements.decreaseCount.addEventListener('click', () => {
|
|
if (state.imageCount > 1) {
|
|
state.imageCount--;
|
|
elements.imageCount.value = state.imageCount;
|
|
localStorage.setItem('imagen_count', state.imageCount);
|
|
}
|
|
});
|
|
}
|
|
|
|
if (elements.increaseCount) {
|
|
elements.increaseCount.addEventListener('click', () => {
|
|
if (state.imageCount < 8) {
|
|
state.imageCount++;
|
|
elements.imageCount.value = state.imageCount;
|
|
localStorage.setItem('imagen_count', state.imageCount);
|
|
}
|
|
});
|
|
}
|
|
|
|
if (elements.imageCount) {
|
|
elements.imageCount.addEventListener('change', (e) => {
|
|
let val = parseInt(e.target.value);
|
|
if (isNaN(val) || val < 1) val = 1;
|
|
if (val > 8) val = 8;
|
|
state.imageCount = val;
|
|
elements.imageCount.value = val;
|
|
localStorage.setItem('imagen_count', state.imageCount);
|
|
});
|
|
}
|
|
|
|
// API Key
|
|
elements.saveApiKey.addEventListener('click', () => {
|
|
state.apiKey = elements.apiKey.value.trim();
|
|
localStorage.setItem('imagen_api_key', state.apiKey);
|
|
showToast('API key saved!', 'success');
|
|
});
|
|
|
|
// Reference images are handled by renderReferenceSlots()
|
|
elements.clearReferences.addEventListener('click', clearAllReferences);
|
|
|
|
// Drag & Drop for reference images
|
|
setupDragAndDrop();
|
|
|
|
// Prompt input
|
|
elements.promptInput.addEventListener('input', () => {
|
|
elements.charCount.textContent = `${elements.promptInput.value.length} chars`;
|
|
});
|
|
|
|
// Generate button
|
|
elements.generateBtn.addEventListener('click', generateImages);
|
|
|
|
// Clear gallery
|
|
elements.clearGallery.addEventListener('click', async () => {
|
|
if (confirm('Are you sure you want to clear all generated images?')) {
|
|
state.images = [];
|
|
try {
|
|
await ImagenDB.clearAll();
|
|
} catch (e) {
|
|
console.warn('Could not clear IndexedDB:', e);
|
|
}
|
|
renderGallery();
|
|
showToast('Gallery cleared', 'success');
|
|
}
|
|
});
|
|
|
|
// Modal
|
|
elements.modalOverlay.addEventListener('click', closeModal);
|
|
elements.modalClose.addEventListener('click', closeModal);
|
|
elements.useAsReference.addEventListener('click', useImageAsReference);
|
|
elements.recreateImage.addEventListener('click', recreateImage);
|
|
elements.downloadImage.addEventListener('click', downloadCurrentImage);
|
|
|
|
// Keyboard shortcuts
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') closeModal();
|
|
if (e.key === 'Enter' && e.ctrlKey) generateImages();
|
|
});
|
|
|
|
// Paste images from clipboard
|
|
document.addEventListener('paste', handlePaste);
|
|
|
|
// Warn user before leaving if there are pending generations
|
|
window.addEventListener('beforeunload', (e) => {
|
|
if (state.pendingBatches.length > 0) {
|
|
const pendingCount = state.pendingBatches.reduce((sum, batch) => {
|
|
return sum + (batch.count - batch.completed - batch.failed);
|
|
}, 0);
|
|
if (pendingCount > 0) {
|
|
e.preventDefault();
|
|
// Modern browsers ignore custom messages, but we need to return something
|
|
e.returnValue = `You have ${pendingCount} image(s) still generating. If you leave, they will be lost.`;
|
|
return e.returnValue;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// ===== Paste Handler =====
|
|
function handlePaste(e) {
|
|
// Don't intercept paste if user is typing in an input field (except prompt)
|
|
const activeEl = document.activeElement;
|
|
if (activeEl && activeEl.tagName === 'INPUT' && activeEl.type !== 'text') {
|
|
return;
|
|
}
|
|
|
|
const items = e.clipboardData?.items;
|
|
if (!items) return;
|
|
|
|
let imageCount = 0;
|
|
for (const item of items) {
|
|
if (item.type.startsWith('image/')) {
|
|
e.preventDefault();
|
|
const file = item.getAsFile();
|
|
if (file) {
|
|
const reader = new FileReader();
|
|
reader.onload = (event) => {
|
|
state.references.push(event.target.result);
|
|
renderReferenceSlots();
|
|
};
|
|
reader.readAsDataURL(file);
|
|
imageCount++;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (imageCount > 0) {
|
|
showToast(`${imageCount} image(s) pasted as reference`, 'success');
|
|
}
|
|
}
|
|
|
|
// ===== Drag & Drop =====
|
|
function setupDragAndDrop() {
|
|
const dropZone = elements.referenceSlots;
|
|
|
|
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
|
dropZone.addEventListener(eventName, preventDefaults, false);
|
|
document.body.addEventListener(eventName, preventDefaults, false);
|
|
});
|
|
|
|
function preventDefaults(e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}
|
|
|
|
['dragenter', 'dragover'].forEach(eventName => {
|
|
dropZone.addEventListener(eventName, () => {
|
|
dropZone.classList.add('drag-over');
|
|
}, false);
|
|
});
|
|
|
|
['dragleave', 'drop'].forEach(eventName => {
|
|
dropZone.addEventListener(eventName, () => {
|
|
dropZone.classList.remove('drag-over');
|
|
}, false);
|
|
});
|
|
|
|
dropZone.addEventListener('drop', handleDrop, false);
|
|
}
|
|
|
|
function handleDrop(e) {
|
|
const dt = e.dataTransfer;
|
|
const files = dt.files;
|
|
|
|
[...files].forEach(file => {
|
|
if (file.type.startsWith('image/')) {
|
|
const reader = new FileReader();
|
|
reader.onload = (event) => {
|
|
state.references.push(event.target.result);
|
|
renderReferenceSlots();
|
|
};
|
|
reader.readAsDataURL(file);
|
|
}
|
|
});
|
|
|
|
if (files.length > 0) {
|
|
showToast(`${files.length} image(s) added as reference`, 'success');
|
|
}
|
|
}
|
|
|
|
// ===== Reference Image Handling =====
|
|
function handleReferenceUpload(e) {
|
|
const file = e.target.files[0];
|
|
if (!file) return;
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = (event) => {
|
|
state.references.push(event.target.result);
|
|
renderReferenceSlots();
|
|
};
|
|
reader.readAsDataURL(file);
|
|
|
|
// Reset the input so the same file can be selected again
|
|
e.target.value = '';
|
|
}
|
|
|
|
function renderReferenceSlots() {
|
|
const container = document.getElementById('referenceSlots');
|
|
container.innerHTML = '';
|
|
|
|
// Render existing references
|
|
state.references.forEach((ref, index) => {
|
|
const slot = document.createElement('div');
|
|
slot.className = 'reference-slot filled';
|
|
slot.dataset.slot = index;
|
|
slot.innerHTML = `
|
|
<img src="${ref}" alt="Reference ${index + 1}">
|
|
<button class="remove-ref" data-index="${index}">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
|
</svg>
|
|
</button>
|
|
`;
|
|
container.appendChild(slot);
|
|
});
|
|
|
|
// Add "Add new" slot
|
|
const addSlot = document.createElement('div');
|
|
addSlot.className = 'reference-slot empty add-new';
|
|
addSlot.innerHTML = `
|
|
<span class="slot-label">+ Add</span>
|
|
<input type="file" accept="image/*" class="reference-input" id="addReferenceInput">
|
|
`;
|
|
container.appendChild(addSlot);
|
|
|
|
// Attach event listeners
|
|
container.querySelectorAll('.remove-ref').forEach(btn => {
|
|
btn.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const index = parseInt(btn.dataset.index);
|
|
removeReference(index);
|
|
});
|
|
});
|
|
|
|
const addInput = container.querySelector('#addReferenceInput');
|
|
if (addInput) {
|
|
addInput.addEventListener('change', handleReferenceUpload);
|
|
}
|
|
}
|
|
|
|
function removeReference(index) {
|
|
state.references.splice(index, 1);
|
|
renderReferenceSlots();
|
|
}
|
|
|
|
function clearAllReferences() {
|
|
state.references = [];
|
|
renderReferenceSlots();
|
|
showToast('References cleared', 'success');
|
|
}
|
|
|
|
// ===== Image Generation =====
|
|
async function generateImages() {
|
|
const prompt = elements.promptInput.value.trim();
|
|
|
|
if (!prompt) {
|
|
showToast('Please enter a prompt', 'warning');
|
|
return;
|
|
}
|
|
|
|
if (!state.apiKey) {
|
|
showToast('Please enter your OpenRouter API key', 'error');
|
|
return;
|
|
}
|
|
|
|
const modelConfig = MODEL_CONFIGS[state.selectedModel];
|
|
const currentReferences = state.references.length > 0 ? [...state.references] : [];
|
|
const currentModel = state.selectedModel;
|
|
const currentSize = state.imageSize;
|
|
const currentQuality = state.imageQuality;
|
|
const currentAspectRatio = state.aspectRatio;
|
|
const imageCount = state.imageCount;
|
|
|
|
// Create a batch to track this generation request
|
|
const batchId = Date.now() + Math.random();
|
|
const batch = {
|
|
id: batchId,
|
|
prompt: prompt,
|
|
model: currentModel,
|
|
modelName: modelConfig.name,
|
|
count: imageCount,
|
|
completed: 0,
|
|
failed: 0
|
|
};
|
|
state.pendingBatches.push(batch);
|
|
renderGallery(); // Show loading placeholders
|
|
|
|
showToast(`Queued ${imageCount} image(s) for generation`, 'success');
|
|
|
|
// Generate images and display each one as it completes
|
|
const generateAndDisplay = async (index) => {
|
|
try {
|
|
const result = await generateSingleImage(prompt, modelConfig);
|
|
if (result) {
|
|
const imageData = {
|
|
id: Date.now() + index + Math.random(),
|
|
url: result,
|
|
prompt: prompt,
|
|
model: currentModel,
|
|
modelName: modelConfig.name,
|
|
size: currentSize,
|
|
quality: currentQuality,
|
|
aspectRatio: currentAspectRatio,
|
|
references: currentReferences,
|
|
createdAt: new Date().toISOString()
|
|
};
|
|
state.images.unshift(imageData);
|
|
batch.completed++;
|
|
renderGallery();
|
|
|
|
// Save to IndexedDB in background
|
|
ImagenDB.saveImage(imageData).catch(e => console.error('Failed to save to IndexedDB:', e));
|
|
} else {
|
|
batch.failed++;
|
|
renderGallery();
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to generate image:', error);
|
|
batch.failed++;
|
|
renderGallery();
|
|
}
|
|
};
|
|
|
|
// Start all generations in parallel, each will render when done
|
|
const promises = [];
|
|
for (let i = 0; i < imageCount; i++) {
|
|
promises.push(generateAndDisplay(i));
|
|
}
|
|
|
|
// Wait for all to complete to update final UI state
|
|
await Promise.allSettled(promises);
|
|
|
|
// Remove this batch from pending
|
|
const batchIndex = state.pendingBatches.findIndex(b => b.id === batchId);
|
|
if (batchIndex !== -1) {
|
|
state.pendingBatches.splice(batchIndex, 1);
|
|
}
|
|
renderGallery();
|
|
|
|
if (batch.completed > 0) {
|
|
showToast(`${batch.completed} image(s) generated!`, 'success');
|
|
} else {
|
|
showToast('Failed to generate images. Check console for details.', 'error');
|
|
}
|
|
}
|
|
|
|
async function generateSingleImage(prompt, modelConfig) {
|
|
// Build message content
|
|
const content = [];
|
|
|
|
// Add reference images if supported
|
|
if (modelConfig.supportsImageInput) {
|
|
state.references.forEach((ref, index) => {
|
|
if (ref) {
|
|
content.push({
|
|
type: 'image_url',
|
|
image_url: {
|
|
url: ref,
|
|
detail: 'high'
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// Add text prompt
|
|
content.push({
|
|
type: 'text',
|
|
text: prompt
|
|
});
|
|
|
|
// Build request body
|
|
const requestBody = {
|
|
model: state.selectedModel,
|
|
messages: [
|
|
{
|
|
role: 'user',
|
|
content: content.length === 1 ? prompt : content
|
|
}
|
|
],
|
|
modalities: modelConfig.modalities
|
|
};
|
|
|
|
// Add Gemini-specific options
|
|
if (modelConfig.supportsImageSize && state.selectedModel.includes('gemini')) {
|
|
requestBody.image_config = {
|
|
image_size: state.imageQuality.toLowerCase(),
|
|
aspect_ratio: state.aspectRatio
|
|
};
|
|
}
|
|
|
|
// Add aspect ratio for other models
|
|
if (modelConfig.supportsAspectRatio && !state.selectedModel.includes('gemini')) {
|
|
requestBody.aspect_ratio = state.aspectRatio;
|
|
}
|
|
|
|
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${state.apiKey}`,
|
|
'Content-Type': 'application/json',
|
|
'HTTP-Referer': window.location.origin,
|
|
'X-Title': 'Imagen Internal Tool'
|
|
},
|
|
body: JSON.stringify(requestBody)
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json().catch(() => ({}));
|
|
throw new Error(errorData.error?.message || `API error: ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
// Extract image from response
|
|
// OpenRouter returns images in different formats depending on the model
|
|
const message = data.choices?.[0]?.message;
|
|
|
|
if (!message) {
|
|
throw new Error('No response from model');
|
|
}
|
|
|
|
// Log full response for debugging
|
|
console.log('API Response:', JSON.stringify(data, null, 2));
|
|
|
|
// Check for images array in message (OpenRouter SDK format)
|
|
// According to OpenRouter docs: message.images[].image_url.url
|
|
if (message.images && message.images.length > 0) {
|
|
const img = message.images[0];
|
|
// OpenRouter SDK format: { image_url: { url: "data:image/..." } }
|
|
if (img.image_url?.url) {
|
|
return img.image_url.url;
|
|
}
|
|
// Alternative formats
|
|
if (typeof img === 'string') {
|
|
if (img.startsWith('data:') || img.startsWith('http')) {
|
|
return img;
|
|
}
|
|
return `data:image/png;base64,${img}`;
|
|
}
|
|
if (img.url) return img.url;
|
|
if (img.b64_json) return `data:image/png;base64,${img.b64_json}`;
|
|
}
|
|
|
|
// Check for image in content parts (different models may use this format)
|
|
if (Array.isArray(message.content)) {
|
|
for (const part of message.content) {
|
|
// OpenAI-style image_url part
|
|
if (part.type === 'image_url' && part.image_url?.url) {
|
|
return part.image_url.url;
|
|
}
|
|
// Gemini-style inlineData part
|
|
if (part.inlineData?.data) {
|
|
const mimeType = part.inlineData.mimeType || 'image/png';
|
|
return `data:${mimeType};base64,${part.inlineData.data}`;
|
|
}
|
|
// Generic image part
|
|
if (part.type === 'image' && part.image) {
|
|
if (part.image.startsWith('data:')) {
|
|
return part.image;
|
|
}
|
|
return `data:image/png;base64,${part.image}`;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check if content itself is the image data (some models return this way)
|
|
if (typeof message.content === 'string' && message.content.startsWith('data:image')) {
|
|
return message.content;
|
|
}
|
|
|
|
throw new Error('No image in response. Check console for full API response.');
|
|
}
|
|
|
|
// ===== Gallery =====
|
|
function renderGallery() {
|
|
const hasPending = state.pendingBatches.length > 0;
|
|
const hasImages = state.images.length > 0;
|
|
|
|
if (!hasImages && !hasPending) {
|
|
elements.galleryEmpty.style.display = 'flex';
|
|
elements.gallery.innerHTML = '';
|
|
elements.gallery.appendChild(elements.galleryEmpty);
|
|
return;
|
|
}
|
|
|
|
elements.gallery.innerHTML = '';
|
|
|
|
// Render loading placeholders for pending batches at the top
|
|
state.pendingBatches.forEach((batch) => {
|
|
const pendingCount = batch.count - batch.completed - batch.failed;
|
|
for (let i = 0; i < pendingCount; i++) {
|
|
const placeholder = document.createElement('div');
|
|
placeholder.className = 'image-card loading-placeholder';
|
|
const safePrompt = escapeHtml(batch.prompt);
|
|
const truncatedPrompt = batch.prompt.length > 60 ? batch.prompt.substring(0, 60) + '...' : batch.prompt;
|
|
placeholder.innerHTML = `
|
|
<div class="loading-placeholder-content">
|
|
<div class="loading-spinner"></div>
|
|
<span class="loading-placeholder-text">Generating...</span>
|
|
</div>
|
|
<div class="image-card-overlay" style="opacity: 1;">
|
|
<p class="image-card-prompt">${escapeHtml(truncatedPrompt)}</p>
|
|
<div class="image-card-meta">
|
|
<span class="meta-tag">${escapeHtml(batch.modelName)}</span>
|
|
<span class="meta-tag loading-tag">
|
|
<svg class="spin-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<line x1="12" y1="2" x2="12" y2="6"></line>
|
|
<line x1="12" y1="18" x2="12" y2="22"></line>
|
|
<line x1="4.93" y1="4.93" x2="7.76" y2="7.76"></line>
|
|
<line x1="16.24" y1="16.24" x2="19.07" y2="19.07"></line>
|
|
<line x1="2" y1="12" x2="6" y2="12"></line>
|
|
<line x1="18" y1="12" x2="22" y2="12"></line>
|
|
<line x1="4.93" y1="19.07" x2="7.76" y2="16.24"></line>
|
|
<line x1="16.24" y1="7.76" x2="19.07" y2="4.93"></line>
|
|
</svg>
|
|
Pending
|
|
</span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
elements.gallery.appendChild(placeholder);
|
|
}
|
|
});
|
|
|
|
// Render existing images
|
|
state.images.forEach((image, index) => {
|
|
const card = document.createElement('div');
|
|
card.className = 'image-card';
|
|
|
|
// Sanitize URL - only allow data URIs and https URLs
|
|
const safeUrl = sanitizeImageUrl(image.url);
|
|
const safePrompt = escapeHtml(image.prompt);
|
|
|
|
card.innerHTML = `
|
|
<div class="image-card-actions image-card-actions-top">
|
|
<button class="image-card-btn image-card-download" data-index="${index}" title="Download image">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
|
<polyline points="7 10 12 15 17 10"></polyline>
|
|
<line x1="12" y1="15" x2="12" y2="3"></line>
|
|
</svg>
|
|
</button>
|
|
<button class="image-card-btn image-card-delete" data-index="${index}" title="Delete image">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<polyline points="3 6 5 6 21 6"></polyline>
|
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
|
<line x1="10" y1="11" x2="10" y2="17"></line>
|
|
<line x1="14" y1="11" x2="14" y2="17"></line>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div class="image-card-actions image-card-actions-bottom">
|
|
<button class="image-card-btn image-card-reference" data-index="${index}" title="Use as reference">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path>
|
|
<circle cx="12" cy="10" r="3"></circle>
|
|
</svg>
|
|
</button>
|
|
<button class="image-card-btn image-card-recreate" data-index="${index}" title="Recreate with same settings">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<polyline points="23 4 23 10 17 10"></polyline>
|
|
<polyline points="1 20 1 14 7 14"></polyline>
|
|
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<img src="${safeUrl}" alt="${safePrompt}" loading="lazy">
|
|
<div class="image-card-overlay">
|
|
<p class="image-card-prompt">${safePrompt}</p>
|
|
<div class="image-card-meta">
|
|
<span class="meta-tag">${escapeHtml(image.modelName || image.model)}</span>
|
|
<span class="meta-tag">${escapeHtml(image.quality || image.size)}</span>
|
|
<span class="meta-tag">${escapeHtml(image.aspectRatio)}</span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Download button handler
|
|
const downloadBtn = card.querySelector('.image-card-download');
|
|
downloadBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
downloadImageByIndex(index);
|
|
});
|
|
|
|
// Delete button handler
|
|
const deleteBtn = card.querySelector('.image-card-delete');
|
|
deleteBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
deleteImage(index);
|
|
});
|
|
|
|
// Reference button handler
|
|
const referenceBtn = card.querySelector('.image-card-reference');
|
|
referenceBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
addImageAsReference(index);
|
|
});
|
|
|
|
// Recreate button handler
|
|
const recreateBtn = card.querySelector('.image-card-recreate');
|
|
recreateBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
recreateImageByIndex(index);
|
|
});
|
|
|
|
// Open modal on card click
|
|
card.addEventListener('click', () => openModal(image));
|
|
elements.gallery.appendChild(card);
|
|
});
|
|
}
|
|
|
|
async function deleteImage(index) {
|
|
const imageToDelete = state.images[index];
|
|
state.images.splice(index, 1);
|
|
|
|
try {
|
|
await ImagenDB.deleteImage(imageToDelete.id);
|
|
} catch (e) {
|
|
console.warn('Could not delete from IndexedDB:', e);
|
|
}
|
|
|
|
renderGallery();
|
|
showToast('Image deleted', 'success');
|
|
}
|
|
|
|
function downloadImageByIndex(index) {
|
|
const image = state.images[index];
|
|
if (!image) return;
|
|
|
|
const link = document.createElement('a');
|
|
link.href = image.url;
|
|
const timestamp = new Date(image.createdAt).toISOString().replace(/[:.]/g, '-');
|
|
link.download = `imagen-${timestamp}.png`;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
showToast('Image downloaded', 'success');
|
|
}
|
|
|
|
function addImageAsReference(index) {
|
|
const image = state.images[index];
|
|
if (!image) return;
|
|
|
|
state.references.push(image.url);
|
|
renderReferenceSlots();
|
|
showToast('Image added as reference', 'success');
|
|
}
|
|
|
|
function recreateImageByIndex(index) {
|
|
const image = state.images[index];
|
|
if (!image) return;
|
|
|
|
// Restore prompt
|
|
elements.promptInput.value = image.prompt;
|
|
elements.charCount.textContent = `${image.prompt.length} chars`;
|
|
|
|
// Restore model using custom select
|
|
state.selectedModel = image.model;
|
|
localStorage.setItem('imagen_model', state.selectedModel);
|
|
const modelOption = document.querySelector(`.custom-select-option[data-value="${image.model}"]`);
|
|
if (modelOption) {
|
|
document.querySelectorAll('.custom-select-option').forEach(o => o.classList.remove('selected'));
|
|
modelOption.classList.add('selected');
|
|
elements.modelSelectValue.textContent = modelOption.textContent;
|
|
}
|
|
updateGeminiOptionsVisibility();
|
|
|
|
// Restore quality/size
|
|
document.querySelectorAll('.btn-toggle').forEach(btn => {
|
|
btn.classList.remove('active');
|
|
if (btn.dataset.quality === image.quality) {
|
|
btn.classList.add('active');
|
|
}
|
|
});
|
|
|
|
// Restore aspect ratio
|
|
document.querySelectorAll('.btn-aspect').forEach(btn => {
|
|
btn.classList.remove('active');
|
|
if (btn.dataset.ratio === image.aspectRatio) {
|
|
btn.classList.add('active');
|
|
}
|
|
});
|
|
|
|
// Restore references
|
|
if (image.references && image.references.length > 0) {
|
|
state.references = [...image.references];
|
|
} else {
|
|
state.references = [];
|
|
}
|
|
renderReferenceSlots();
|
|
|
|
showToast('Settings restored. Click Generate to recreate.', 'success');
|
|
|
|
// Scroll to top
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
}
|
|
|
|
// ===== Modal =====
|
|
function openModal(image) {
|
|
state.currentImage = image;
|
|
elements.modalImage.src = sanitizeImageUrl(image.url);
|
|
elements.modalMetadata.innerHTML = `
|
|
<p><strong>Prompt:</strong> ${escapeHtml(image.prompt)}</p>
|
|
<p><strong>Model:</strong> ${escapeHtml(image.modelName || image.model)}</p>
|
|
<p><strong>Size/Quality:</strong> ${escapeHtml(image.quality || image.size)}</p>
|
|
<p><strong>Aspect Ratio:</strong> ${escapeHtml(image.aspectRatio)}</p>
|
|
<p><strong>Created:</strong> ${escapeHtml(new Date(image.createdAt).toLocaleString())}</p>
|
|
${image.references?.length > 0 ? `<p><strong>References Used:</strong> ${escapeHtml(image.references.length)}</p>` : ''}
|
|
`;
|
|
elements.imageModal.classList.add('active');
|
|
}
|
|
|
|
function closeModal() {
|
|
elements.imageModal.classList.remove('active');
|
|
state.currentImage = null;
|
|
}
|
|
|
|
function useImageAsReference() {
|
|
if (!state.currentImage) return;
|
|
|
|
state.references.push(state.currentImage.url);
|
|
renderReferenceSlots();
|
|
closeModal();
|
|
showToast('Image added as reference', 'success');
|
|
}
|
|
|
|
function recreateImage() {
|
|
if (!state.currentImage) return;
|
|
|
|
// Restore prompt
|
|
elements.promptInput.value = state.currentImage.prompt;
|
|
elements.charCount.textContent = `${state.currentImage.prompt.length} chars`;
|
|
|
|
// Restore model using custom select
|
|
state.selectedModel = state.currentImage.model;
|
|
localStorage.setItem('imagen_model', state.selectedModel);
|
|
const modelOption = document.querySelector(`.custom-select-option[data-value="${state.currentImage.model}"]`);
|
|
if (modelOption) {
|
|
document.querySelectorAll('.custom-select-option').forEach(o => o.classList.remove('selected'));
|
|
modelOption.classList.add('selected');
|
|
elements.modelSelectValue.textContent = modelOption.textContent;
|
|
}
|
|
updateGeminiOptionsVisibility();
|
|
|
|
// Restore quality/size
|
|
document.querySelectorAll('.btn-toggle').forEach(btn => {
|
|
btn.classList.remove('active');
|
|
if (btn.dataset.quality === state.currentImage.quality) {
|
|
btn.classList.add('active');
|
|
state.imageSize = btn.dataset.size;
|
|
state.imageQuality = btn.dataset.quality;
|
|
}
|
|
});
|
|
|
|
// Restore aspect ratio
|
|
document.querySelectorAll('.btn-aspect').forEach(btn => {
|
|
btn.classList.remove('active');
|
|
if (btn.dataset.ratio === state.currentImage.aspectRatio) {
|
|
btn.classList.add('active');
|
|
state.aspectRatio = state.currentImage.aspectRatio;
|
|
}
|
|
});
|
|
|
|
// Restore references (always update the UI, even if empty to clear previous refs)
|
|
if (state.currentImage.references && state.currentImage.references.length > 0) {
|
|
state.references = [...state.currentImage.references];
|
|
} else {
|
|
state.references = [];
|
|
}
|
|
renderReferenceSlots();
|
|
|
|
closeModal();
|
|
showToast('Settings restored. Click Generate to recreate.', 'success');
|
|
|
|
// Scroll to top
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
}
|
|
|
|
function downloadCurrentImage() {
|
|
if (!state.currentImage) return;
|
|
|
|
const link = document.createElement('a');
|
|
link.href = state.currentImage.url;
|
|
link.download = `imagen_${state.currentImage.id}.png`;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
showToast('Download started', 'success');
|
|
}
|
|
|
|
// ===== UI Helpers =====
|
|
function updateGeminiOptionsVisibility() {
|
|
const isGemini = state.selectedModel.includes('gemini');
|
|
elements.geminiOptions.style.display = isGemini ? 'flex' : 'none';
|
|
}
|
|
|
|
function showToast(message, type = 'info') {
|
|
let container = document.querySelector('.toast-container');
|
|
if (!container) {
|
|
container = document.createElement('div');
|
|
container.className = 'toast-container';
|
|
document.body.appendChild(container);
|
|
}
|
|
|
|
const toast = document.createElement('div');
|
|
toast.className = `toast ${type}`;
|
|
toast.textContent = message;
|
|
container.appendChild(toast);
|
|
|
|
setTimeout(() => {
|
|
toast.style.animation = 'slideIn 0.3s ease reverse';
|
|
setTimeout(() => toast.remove(), 300);
|
|
}, 3000);
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
if (text == null) return '';
|
|
const div = document.createElement('div');
|
|
div.textContent = String(text);
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function sanitizeImageUrl(url) {
|
|
if (!url) return '';
|
|
// Only allow data URIs and HTTPS URLs
|
|
if (url.startsWith('data:image/')) {
|
|
return url;
|
|
}
|
|
if (url.startsWith('https://')) {
|
|
// Escape any potential attribute-breaking characters
|
|
return url.replace(/"/g, '%22').replace(/'/g, '%27');
|
|
}
|
|
// Block everything else (http, javascript:, etc.)
|
|
console.warn('Blocked unsafe image URL:', url);
|
|
return '';
|
|
}
|
|
|
|
// ===== Global functions for inline handlers =====
|
|
window.removeReference = removeReference;
|
|
|
|
// ===== Initialize =====
|
|
document.addEventListener('DOMContentLoaded', init);
|