const $ = (s) => document.querySelector(s); const $$ = (s) => document.querySelectorAll(s); const state = { apiKey: localStorage.getItem('vibegif_apikey') || '', generating: false, frames: [], gifUrl: null, aspectRatio: '1:1', workerBlob: null }; // --- Elements --- const elOnboarding = $('#section-onboarding'); const elGenerator = $('#section-generator'); const elModal = $('#modal-settings'); const elBtnSettings = $('#btn-settings'); const elBtnCloseSettings = $('#btn-close-settings'); const elApiKeyOnboard = $('#input-apikey-onboard'); const elApiKeyModal = $('#input-apikey-modal'); const elPrompt = $('#input-prompt'); 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; // 0.5K visibility 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'; } function saveApiKey(val) { state.apiKey = val; localStorage.setItem('vibegif_apikey', val); updateView(); } async function preloadWorker() { try { const r = await fetch('https://cdn.jsdelivr.net/npm/gif.js@0.2.0/dist/gif.worker.js'); state.workerBlob = URL.createObjectURL(await r.blob()); } catch (e) { console.error('Worker preload failed:', e); } } // --- Events --- function bindEvents() { elBtnSettings.addEventListener('click', () => { elModal.classList.remove('hidden'); elModal.classList.add('flex'); }); elBtnCloseSettings.addEventListener('click', closeModal); elModal.addEventListener('click', (e) => { if (e.target === elModal) closeModal(); }); 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 = ` ${statusText || 'generating...'} `; } 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.classList.add('border-dashed'); } else { div.classList.add('border-dashed'); } elFramesGrid.appendChild(div); } } // --- API --- async function callOpenRouter(messages) { const body = { model: elModel.value, messages, modalities: ['image'], image_config: { image_size: elSize.value, aspect_ratio: state.aspectRatio } }; const res = await fetch('https://openrouter.ai/api/v1/chat/completions', { method: 'POST', headers: { 'Authorization': `Bearer ${state.apiKey}`, 'Content-Type': 'application/json', 'HTTP-Referer': 'https://vibegif.lol', 'X-Title': 'vibegif.lol' }, body: JSON.stringify(body) }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err?.error?.message || `API error ${res.status}`); } return await res.json(); } function extractImage(response) { const msg = response?.choices?.[0]?.message; if (!msg) return null; // OpenRouter images array if (msg.images && msg.images.length > 0) { return msg.images[0]?.image_url?.url || null; } // Fallback: content array with image parts if (Array.isArray(msg.content)) { for (const part of msg.content) { if (part.type === 'image_url') return part.image_url?.url || null; } } return null; } function buildRollingMessages(chatHistory, windowSize) { if (chatHistory.length <= 1 + windowSize * 2) return [...chatHistory]; const first = chatHistory[0]; const recent = chatHistory.slice(-(windowSize * 2)); return [first, ...recent]; } // --- Generate --- async function generate() { const prompt = elPrompt.value.trim(); const frameCount = parseInt(elFrames.value); if (!prompt || state.generating) return; 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 { // Frame 1 renderFrameGrid(); const chatHistory = [ { role: 'user', content: masterPrompt } ]; const res1 = await callOpenRouter(chatHistory); const img1 = extractImage(res1); if (!img1) throw new Error('No image returned for frame 1. Try a different prompt or model.'); state.frames.push(img1); renderFrameGrid(); chatHistory.push({ role: 'assistant', content: [{ type: 'image_url', image_url: { url: img1 } }] }); // Subsequent frames for (let i = 2; i <= frameCount; i++) { updateStatus(`generating frame ${i}/${frameCount}...`); setGenerating(true, `generating frame ${i}/${frameCount}...`); 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 }); const rollingMessages = buildRollingMessages(chatHistory, 2); const resN = await callOpenRouter(rollingMessages); const imgN = extractImage(resN); if (!imgN) throw new Error(`No image returned for frame ${i}. Try reducing frame count or changing model.`); state.frames.push(imgN); renderFrameGrid(); chatHistory.push({ role: 'assistant', content: [{ type: 'image_url', image_url: { url: imgN } }] }); } // Build GIF setGenerating(true, 'assembling gif...'); await buildGif(); } catch (e) { showError(e.message || 'Something went wrong'); console.error(e); } finally { setGenerating(false); } } // --- GIF Builder --- async function buildGif() { const fps = parseInt(elFps.value); const delay = Math.round(1000 / fps); const images = await Promise.all( state.frames.map(src => new Promise((resolve, reject) => { const img = new Image(); img.onload = () => resolve(img); img.onerror = reject; img.src = src; })) ); const w = images[0].naturalWidth; const h = images[0].naturalHeight; const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; const ctx = canvas.getContext('2d'); return new Promise((resolve, reject) => { const gif = new GIF({ workers: 2, quality: 10, width: w, height: h, workerScript: state.workerBlob || 'https://cdn.jsdelivr.net/npm/gif.js@0.2.0/dist/gif.worker.js', repeat: 0 }); for (const img of images) { ctx.clearRect(0, 0, w, h); ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, w, h); ctx.drawImage(img, 0, 0, w, h); gif.addFrame(ctx, { copy: true, delay }); } gif.on('finished', (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(); }); gif.on('error', reject); gif.render(); }); } // --- Boot --- document.addEventListener('DOMContentLoaded', init);