Feat: Vanilla JS app — OpenRouter image gen + GIF

This commit is contained in:
2026-03-20 22:01:45 -07:00
parent 2ead48f7b4
commit 7cdff0ecbc

337
app.js
View File

@@ -1,71 +1,199 @@
function app() { const $ = (s) => document.querySelector(s);
return { const $$ = (s) => document.querySelectorAll(s);
apiKey: '',
showSettings: false, const state = {
userPrompt: '', apiKey: localStorage.getItem('vibegif_apikey') || '',
model: 'google/gemini-3.1-flash-image-preview',
frameCount: 4,
fps: 6,
imageSize: '1K',
aspectRatio: '1:1',
generating: false, generating: false,
statusText: 'generating...',
frames: [], frames: [],
gifUrl: null, gifUrl: null,
errorMsg: '', aspectRatio: '1:1',
workerBlob: null, workerBlob: null
};
init() { // --- Elements ---
this.apiKey = localStorage.getItem('vibegif_apikey') || ''; const elOnboarding = $('#section-onboarding');
this.preloadWorker(); const elGenerator = $('#section-generator');
this.$nextTick(() => lucide.createIcons()); const elModal = $('#modal-settings');
this.$watch('showSettings', () => this.$nextTick(() => lucide.createIcons())); const elBtnSettings = $('#btn-settings');
this.$watch('apiKey', () => this.$nextTick(() => lucide.createIcons())); const elBtnCloseSettings = $('#btn-close-settings');
this.$watch('gifUrl', () => this.$nextTick(() => lucide.createIcons())); const elApiKeyOnboard = $('#input-apikey-onboard');
this.$watch('model', () => { const elApiKeyModal = $('#input-apikey-modal');
if (this.model !== 'google/gemini-3.1-flash-image-preview' && this.imageSize === '0.5K') { const elPrompt = $('#input-prompt');
this.imageSize = '1K'; const elModel = $('#select-model');
const elFrames = $('#select-frames');
const elFps = $('#select-fps');
const elSize = $('#select-size');
const elOptionHalfK = $('#option-half-k');
const elBtnGenerate = $('#btn-generate');
const elSectionFrames = $('#section-frames');
const elFrameCounter = $('#frame-counter');
const elFramesGrid = $('#frames-grid');
const elSectionResult = $('#section-result');
const elGifPreview = $('#gif-preview');
const elBtnDownload = $('#btn-download');
const elSectionError = $('#section-error');
const elAspectButtons = $('#aspect-buttons');
// --- Init ---
function init() {
updateView();
preloadWorker();
lucide.createIcons();
bindEvents();
}
function updateView() {
if (state.apiKey) {
elOnboarding.classList.add('hidden');
elGenerator.classList.remove('hidden');
} else {
elOnboarding.classList.remove('hidden');
elGenerator.classList.add('hidden');
} }
}); elApiKeyModal.value = state.apiKey;
}, elApiKeyOnboard.value = state.apiKey;
saveApiKey() { // 0.5K visibility
localStorage.setItem('vibegif_apikey', this.apiKey); const isGemini = elModel.value === 'google/gemini-3.1-flash-image-preview';
}, elOptionHalfK.style.display = isGemini ? '' : 'none';
if (!isGemini && elSize.value === '0.5K') elSize.value = '1K';
}
async preloadWorker() { function saveApiKey(val) {
state.apiKey = val;
localStorage.setItem('vibegif_apikey', val);
updateView();
}
async function preloadWorker() {
try { try {
const r = await fetch('https://cdn.jsdelivr.net/npm/gif.js@0.2.0/dist/gif.worker.js'); const r = await fetch('https://cdn.jsdelivr.net/npm/gif.js@0.2.0/dist/gif.worker.js');
this.workerBlob = URL.createObjectURL(await r.blob()); state.workerBlob = URL.createObjectURL(await r.blob());
} catch (e) { } catch (e) {
console.error('Failed to preload gif worker:', e); console.error('Worker preload failed:', e);
} }
}, }
getModalities() { // --- Events ---
// Seedream is image-only; Gemini supports both but we only want image output function bindEvents() {
return ['image']; elBtnSettings.addEventListener('click', () => {
}, elModal.classList.remove('hidden');
elModal.classList.add('flex');
});
buildImageConfig() { elBtnCloseSettings.addEventListener('click', closeModal);
const cfg = {}; elModal.addEventListener('click', (e) => {
if (this.imageSize) cfg.image_size = this.imageSize; if (e.target === elModal) closeModal();
if (this.aspectRatio) cfg.aspect_ratio = this.aspectRatio; });
return cfg;
},
async callOpenRouter(messages) { elApiKeyOnboard.addEventListener('input', (e) => saveApiKey(e.target.value));
elApiKeyModal.addEventListener('input', (e) => saveApiKey(e.target.value));
elModel.addEventListener('change', updateView);
$$('.ar-btn').forEach(btn => {
btn.addEventListener('click', () => {
if (state.generating) return;
$$('.ar-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
state.aspectRatio = btn.dataset.ar;
});
});
elBtnGenerate.addEventListener('click', generate);
}
function closeModal() {
elModal.classList.add('hidden');
elModal.classList.remove('flex');
}
function setGenerating(val, statusText) {
state.generating = val;
const inputs = [elPrompt, elModel, elFrames, elFps, elSize];
inputs.forEach(el => el.disabled = val);
$$('.ar-btn').forEach(b => b.style.pointerEvents = val ? 'none' : '');
if (val) {
elBtnGenerate.disabled = true;
elBtnGenerate.classList.remove('bg-neutral-800', 'hover:bg-neutral-700');
elBtnGenerate.classList.add('bg-neutral-100', 'text-neutral-400', 'cursor-not-allowed');
elBtnGenerate.innerHTML = `
<span class="flex items-center justify-center gap-2">
<svg class="animate-spin h-4 w-4" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
${statusText || 'generating...'}
</span>`;
} else {
elBtnGenerate.disabled = false;
elBtnGenerate.classList.add('bg-neutral-800', 'hover:bg-neutral-700');
elBtnGenerate.classList.remove('bg-neutral-100', 'text-neutral-400', 'cursor-not-allowed');
elBtnGenerate.innerHTML = 'generate gif';
}
}
function updateStatus(text) {
if (!state.generating) return;
const span = elBtnGenerate.querySelector('span span') || elBtnGenerate.querySelector('span');
if (span) {
// Update just the text node
const textNode = [...elBtnGenerate.querySelectorAll('span')].pop();
if (textNode) textNode.textContent = text;
}
}
function showError(msg) {
elSectionError.textContent = msg;
elSectionError.classList.remove('hidden');
}
function hideError() {
elSectionError.classList.add('hidden');
elSectionError.textContent = '';
}
// --- Frame Grid ---
function renderFrameGrid() {
const frameCount = parseInt(elFrames.value);
elFramesGrid.innerHTML = '';
elFrameCounter.textContent = `${state.frames.length}/${frameCount}`;
elSectionFrames.classList.remove('hidden');
for (let i = 0; i < frameCount; i++) {
const div = document.createElement('div');
div.className = 'aspect-square rounded-lg overflow-hidden border border-neutral-200 bg-neutral-50 flex items-center justify-center';
if (i < state.frames.length) {
const img = document.createElement('img');
img.src = state.frames[i];
img.className = 'w-full h-full object-cover';
div.appendChild(img);
} else if (i === state.frames.length && state.generating) {
div.innerHTML = '<div class="w-5 h-5 border-2 border-neutral-300 border-t-neutral-600 rounded-full animate-spin"></div>';
div.classList.add('border-dashed');
} else {
div.classList.add('border-dashed');
}
elFramesGrid.appendChild(div);
}
}
// --- API ---
async function callOpenRouter(messages) {
const body = { const body = {
model: this.model, model: elModel.value,
messages, messages,
modalities: this.getModalities(), modalities: ['image'],
image_config: this.buildImageConfig() image_config: {
image_size: elSize.value,
aspect_ratio: state.aspectRatio
}
}; };
const res = await fetch('https://openrouter.ai/api/v1/chat/completions', { const res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST', method: 'POST',
headers: { headers: {
'Authorization': `Bearer ${this.apiKey}`, 'Authorization': `Bearer ${state.apiKey}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'HTTP-Referer': 'https://vibegif.lol', 'HTTP-Referer': 'https://vibegif.lol',
'X-Title': 'vibegif.lol' 'X-Title': 'vibegif.lol'
@@ -79,103 +207,109 @@ function app() {
} }
return await res.json(); return await res.json();
}, }
extractImage(response) { function extractImage(response) {
const msg = response?.choices?.[0]?.message; const msg = response?.choices?.[0]?.message;
if (!msg) return null; if (!msg) return null;
// Images in message.images array
// OpenRouter images array
if (msg.images && msg.images.length > 0) { if (msg.images && msg.images.length > 0) {
return msg.images[0]?.image_url?.url || null; return msg.images[0]?.image_url?.url || null;
} }
// Fallback: check content array for inline_data
// Fallback: content array with image parts
if (Array.isArray(msg.content)) { if (Array.isArray(msg.content)) {
for (const part of msg.content) { for (const part of msg.content) {
if (part.type === 'image_url') return part.image_url?.url || null; if (part.type === 'image_url') return part.image_url?.url || null;
} }
} }
return null;
},
buildRollingMessages(chatHistory, windowSize = 2) { return null;
// Always keep first message (user prompt) + last N assistant/user pairs }
function buildRollingMessages(chatHistory, windowSize) {
if (chatHistory.length <= 1 + windowSize * 2) return [...chatHistory]; if (chatHistory.length <= 1 + windowSize * 2) return [...chatHistory];
const first = chatHistory[0]; const first = chatHistory[0];
const recent = chatHistory.slice(-(windowSize * 2)); const recent = chatHistory.slice(-(windowSize * 2));
return [first, ...recent]; return [first, ...recent];
}, }
async generate() { // --- Generate ---
if (!this.userPrompt.trim() || this.generating) return; async function generate() {
this.generating = true; const prompt = elPrompt.value.trim();
this.frames = []; const frameCount = parseInt(elFrames.value);
this.gifUrl = null; if (!prompt || state.generating) return;
this.errorMsg = '';
const masterPrompt = `minimal black and white line doodle, single stroke, white background, kawaii style, ${this.userPrompt.trim()}`; setGenerating(true, `generating frame 1/${frameCount}...`);
state.frames = [];
state.gifUrl = null;
hideError();
elSectionResult.classList.add('hidden');
const masterPrompt = `minimal black and white line doodle, single stroke, white background, kawaii style, ${prompt}`;
try { try {
// -- Frame 1 -- // Frame 1
this.statusText = `generating frame 1/${this.frameCount}...`; renderFrameGrid();
const chatHistory = [ const chatHistory = [
{ role: 'user', content: masterPrompt } { role: 'user', content: masterPrompt }
]; ];
const res1 = await this.callOpenRouter(chatHistory); const res1 = await callOpenRouter(chatHistory);
const img1 = this.extractImage(res1); const img1 = extractImage(res1);
if (!img1) throw new Error('No image returned for frame 1. The model may not have generated an image — try a different prompt or model.'); if (!img1) throw new Error('No image returned for frame 1. Try a different prompt or model.');
this.frames.push(img1); state.frames.push(img1);
renderFrameGrid();
// Add assistant response to history
chatHistory.push({ chatHistory.push({
role: 'assistant', role: 'assistant',
content: [ content: [{ type: 'image_url', image_url: { url: img1 } }]
{ type: 'image_url', image_url: { url: img1 } }
]
}); });
// -- Subsequent frames -- // Subsequent frames
for (let i = 2; i <= this.frameCount; i++) { for (let i = 2; i <= frameCount; i++) {
this.statusText = `generating frame ${i}/${this.frameCount}...`; updateStatus(`generating frame ${i}/${frameCount}...`);
setGenerating(true, `generating frame ${i}/${frameCount}...`);
const nextPrompt = `imagine we are creating a ${this.frameCount} frame gif of "${this.userPrompt.trim()}". generate the next meaningful frame (frame ${i} of ${this.frameCount}). maintain the same minimal black and white line doodle, single stroke, white background, kawaii style.`; const nextPrompt = `imagine we are creating a ${frameCount} frame gif of "${prompt}". generate the next meaningful frame (frame ${i} of ${frameCount}). maintain the same minimal black and white line doodle, single stroke, white background, kawaii style.`;
chatHistory.push({ role: 'user', content: nextPrompt }); chatHistory.push({ role: 'user', content: nextPrompt });
const rollingMessages = this.buildRollingMessages(chatHistory, 2); const rollingMessages = buildRollingMessages(chatHistory, 2);
const resN = await this.callOpenRouter(rollingMessages); const resN = await callOpenRouter(rollingMessages);
const imgN = this.extractImage(resN); const imgN = extractImage(resN);
if (!imgN) throw new Error(`No image returned for frame ${i}. Try reducing frame count or changing model.`); if (!imgN) throw new Error(`No image returned for frame ${i}. Try reducing frame count or changing model.`);
this.frames.push(imgN); state.frames.push(imgN);
renderFrameGrid();
chatHistory.push({ chatHistory.push({
role: 'assistant', role: 'assistant',
content: [ content: [{ type: 'image_url', image_url: { url: imgN } }]
{ type: 'image_url', image_url: { url: imgN } }
]
}); });
} }
// -- Build GIF -- // Build GIF
this.statusText = 'assembling gif...'; setGenerating(true, 'assembling gif...');
await this.buildGif(); await buildGif();
} catch (e) { } catch (e) {
this.errorMsg = e.message || 'Something went wrong'; showError(e.message || 'Something went wrong');
console.error(e); console.error(e);
} finally { } finally {
this.generating = false; setGenerating(false);
} }
}, }
async buildGif() { // --- GIF Builder ---
const delay = Math.round(1000 / this.fps); async function buildGif() {
const fps = parseInt(elFps.value);
const delay = Math.round(1000 / fps);
// Load all frame images as Image elements
const images = await Promise.all( const images = await Promise.all(
this.frames.map(src => new Promise((resolve, reject) => { state.frames.map(src => new Promise((resolve, reject) => {
const img = new Image(); const img = new Image();
img.onload = () => resolve(img); img.onload = () => resolve(img);
img.onerror = reject; img.onerror = reject;
@@ -186,7 +320,6 @@ function app() {
const w = images[0].naturalWidth; const w = images[0].naturalWidth;
const h = images[0].naturalHeight; const h = images[0].naturalHeight;
// Draw each onto a canvas for gif.js
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.width = w; canvas.width = w;
canvas.height = h; canvas.height = h;
@@ -198,7 +331,7 @@ function app() {
quality: 10, quality: 10,
width: w, width: w,
height: h, height: h,
workerScript: this.workerBlob || 'https://cdn.jsdelivr.net/npm/gif.js@0.2.0/dist/gif.worker.js', workerScript: state.workerBlob || 'https://cdn.jsdelivr.net/npm/gif.js@0.2.0/dist/gif.worker.js',
repeat: 0 repeat: 0
}); });
@@ -211,13 +344,19 @@ function app() {
} }
gif.on('finished', (blob) => { gif.on('finished', (blob) => {
this.gifUrl = URL.createObjectURL(blob); state.gifUrl = URL.createObjectURL(blob);
elGifPreview.src = state.gifUrl;
elBtnDownload.href = state.gifUrl;
elBtnDownload.download = `vibegif-${Date.now()}.gif`;
elSectionResult.classList.remove('hidden');
lucide.createIcons();
resolve(); resolve();
}); });
gif.on('error', reject); gif.on('error', reject);
gif.render(); gif.render();
}); });
}
};
} }
// --- Boot ---
document.addEventListener('DOMContentLoaded', init);