diff --git a/app.js b/app.js deleted file mode 100644 index 1065aae..0000000 --- a/app.js +++ /dev/null @@ -1,362 +0,0 @@ -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);