From 7cdff0ecbc9bfdadd89239efea926fa7fbafc09c Mon Sep 17 00:00:00 2001 From: multipleof4 Date: Fri, 20 Mar 2026 22:01:45 -0700 Subject: [PATCH] =?UTF-8?q?Feat:=20Vanilla=20JS=20app=20=E2=80=94=20OpenRo?= =?UTF-8?q?uter=20image=20gen=20+=20GIF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.js | 531 ++++++++++++++++++++++++++++++++++++--------------------- 1 file changed, 335 insertions(+), 196 deletions(-) diff --git a/app.js b/app.js index c25194c..1065aae 100644 --- a/app.js +++ b/app.js @@ -1,223 +1,362 @@ -function app() { - return { - apiKey: '', - showSettings: false, - userPrompt: '', - model: 'google/gemini-3.1-flash-image-preview', - frameCount: 4, - fps: 6, - imageSize: '1K', - aspectRatio: '1:1', - generating: false, - statusText: 'generating...', - frames: [], - gifUrl: null, - errorMsg: '', - workerBlob: null, +const $ = (s) => document.querySelector(s); +const $$ = (s) => document.querySelectorAll(s); - init() { - this.apiKey = localStorage.getItem('vibegif_apikey') || ''; - this.preloadWorker(); - this.$nextTick(() => lucide.createIcons()); - this.$watch('showSettings', () => this.$nextTick(() => lucide.createIcons())); - this.$watch('apiKey', () => this.$nextTick(() => lucide.createIcons())); - this.$watch('gifUrl', () => this.$nextTick(() => lucide.createIcons())); - this.$watch('model', () => { - if (this.model !== 'google/gemini-3.1-flash-image-preview' && this.imageSize === '0.5K') { - this.imageSize = '1K'; - } - }); - }, +const state = { + apiKey: localStorage.getItem('vibegif_apikey') || '', + generating: false, + frames: [], + gifUrl: null, + aspectRatio: '1:1', + workerBlob: null +}; - saveApiKey() { - localStorage.setItem('vibegif_apikey', this.apiKey); - }, +// --- 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'); - async preloadWorker() { - try { - 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()); - } catch (e) { - console.error('Failed to preload gif worker:', e); - } - }, +// --- Init --- +function init() { + updateView(); + preloadWorker(); + lucide.createIcons(); + bindEvents(); +} - getModalities() { - // Seedream is image-only; Gemini supports both but we only want image output - return ['image']; - }, +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; - buildImageConfig() { - const cfg = {}; - if (this.imageSize) cfg.image_size = this.imageSize; - if (this.aspectRatio) cfg.aspect_ratio = this.aspectRatio; - return cfg; - }, + // 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'; +} - async callOpenRouter(messages) { - const body = { - model: this.model, - messages, - modalities: this.getModalities(), - image_config: this.buildImageConfig() - }; +function saveApiKey(val) { + state.apiKey = val; + localStorage.setItem('vibegif_apikey', val); + updateView(); +} - const res = await fetch('https://openrouter.ai/api/v1/chat/completions', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${this.apiKey}`, - 'Content-Type': 'application/json', - 'HTTP-Referer': 'https://vibegif.lol', - 'X-Title': 'vibegif.lol' - }, - body: JSON.stringify(body) - }); +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); + } +} - if (!res.ok) { - const err = await res.json().catch(() => ({})); - throw new Error(err?.error?.message || `API error ${res.status}`); - } +// --- Events --- +function bindEvents() { + elBtnSettings.addEventListener('click', () => { + elModal.classList.remove('hidden'); + elModal.classList.add('flex'); + }); - return await res.json(); - }, + elBtnCloseSettings.addEventListener('click', closeModal); + elModal.addEventListener('click', (e) => { + if (e.target === elModal) closeModal(); + }); - extractImage(response) { - const msg = response?.choices?.[0]?.message; - if (!msg) return null; - // Images in message.images array - if (msg.images && msg.images.length > 0) { - return msg.images[0]?.image_url?.url || null; - } - // Fallback: check content array for inline_data - if (Array.isArray(msg.content)) { - for (const part of msg.content) { - if (part.type === 'image_url') return part.image_url?.url || null; - } - } - return null; - }, + elApiKeyOnboard.addEventListener('input', (e) => saveApiKey(e.target.value)); + elApiKeyModal.addEventListener('input', (e) => saveApiKey(e.target.value)); - buildRollingMessages(chatHistory, windowSize = 2) { - // Always keep first message (user prompt) + last N assistant/user pairs - if (chatHistory.length <= 1 + windowSize * 2) return [...chatHistory]; - const first = chatHistory[0]; - const recent = chatHistory.slice(-(windowSize * 2)); - return [first, ...recent]; - }, + elModel.addEventListener('change', updateView); - async generate() { - if (!this.userPrompt.trim() || this.generating) return; - this.generating = true; - this.frames = []; - this.gifUrl = null; - this.errorMsg = ''; + $$('.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; + }); + }); - const masterPrompt = `minimal black and white line doodle, single stroke, white background, kawaii style, ${this.userPrompt.trim()}`; + elBtnGenerate.addEventListener('click', generate); +} - try { - // -- Frame 1 -- - this.statusText = `generating frame 1/${this.frameCount}...`; - const chatHistory = [ - { role: 'user', content: masterPrompt } - ]; +function closeModal() { + elModal.classList.add('hidden'); + elModal.classList.remove('flex'); +} - const res1 = await this.callOpenRouter(chatHistory); - const img1 = this.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.'); +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' : ''); - this.frames.push(img1); + 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'; + } +} - // Add assistant response to history - chatHistory.push({ - role: 'assistant', - content: [ - { type: 'image_url', image_url: { url: img1 } } - ] - }); +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; + } +} - // -- Subsequent frames -- - for (let i = 2; i <= this.frameCount; i++) { - this.statusText = `generating frame ${i}/${this.frameCount}...`; +function showError(msg) { + elSectionError.textContent = msg; + elSectionError.classList.remove('hidden'); +} - 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.`; +function hideError() { + elSectionError.classList.add('hidden'); + elSectionError.textContent = ''; +} - chatHistory.push({ role: 'user', content: nextPrompt }); +// --- Frame Grid --- +function renderFrameGrid() { + const frameCount = parseInt(elFrames.value); + elFramesGrid.innerHTML = ''; + elFrameCounter.textContent = `${state.frames.length}/${frameCount}`; + elSectionFrames.classList.remove('hidden'); - const rollingMessages = this.buildRollingMessages(chatHistory, 2); - const resN = await this.callOpenRouter(rollingMessages); - const imgN = this.extractImage(resN); - if (!imgN) throw new Error(`No image returned for frame ${i}. Try reducing frame count or changing model.`); + 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'; - this.frames.push(imgN); + 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'); + } - chatHistory.push({ - role: 'assistant', - content: [ - { type: 'image_url', image_url: { url: imgN } } - ] - }); - } + elFramesGrid.appendChild(div); + } +} - // -- Build GIF -- - this.statusText = 'assembling gif...'; - await this.buildGif(); - - } catch (e) { - this.errorMsg = e.message || 'Something went wrong'; - console.error(e); - } finally { - this.generating = false; - } - }, - - async buildGif() { - const delay = Math.round(1000 / this.fps); - - // Load all frame images as Image elements - const images = await Promise.all( - this.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; - - // Draw each onto a canvas for gif.js - 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: this.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) => { - this.gifUrl = URL.createObjectURL(blob); - resolve(); - }); - - gif.on('error', reject); - gif.render(); - }); +// --- 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);