From 0d1cb6d3cfd5d803bd62b88283088a2c50b42a67 Mon Sep 17 00:00:00 2001 From: multipleof4 Date: Fri, 20 Mar 2026 21:54:18 -0700 Subject: [PATCH] Feat: Core Alpine app with OpenRouter GIF gen --- app.js | 223 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 app.js diff --git a/app.js b/app.js new file mode 100644 index 0000000..c25194c --- /dev/null +++ b/app.js @@ -0,0 +1,223 @@ +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, + + 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'; + } + }); + }, + + saveApiKey() { + localStorage.setItem('vibegif_apikey', this.apiKey); + }, + + 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); + } + }, + + getModalities() { + // Seedream is image-only; Gemini supports both but we only want image output + return ['image']; + }, + + buildImageConfig() { + const cfg = {}; + if (this.imageSize) cfg.image_size = this.imageSize; + if (this.aspectRatio) cfg.aspect_ratio = this.aspectRatio; + return cfg; + }, + + async callOpenRouter(messages) { + const body = { + model: this.model, + messages, + modalities: this.getModalities(), + image_config: this.buildImageConfig() + }; + + 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) + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err?.error?.message || `API error ${res.status}`); + } + + return await res.json(); + }, + + 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; + }, + + 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]; + }, + + async generate() { + if (!this.userPrompt.trim() || this.generating) return; + this.generating = true; + this.frames = []; + this.gifUrl = null; + this.errorMsg = ''; + + const masterPrompt = `minimal black and white line doodle, single stroke, white background, kawaii style, ${this.userPrompt.trim()}`; + + try { + // -- Frame 1 -- + this.statusText = `generating frame 1/${this.frameCount}...`; + const chatHistory = [ + { role: 'user', content: masterPrompt } + ]; + + 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.'); + + this.frames.push(img1); + + // Add assistant response to history + chatHistory.push({ + role: 'assistant', + content: [ + { type: 'image_url', image_url: { url: img1 } } + ] + }); + + // -- Subsequent frames -- + for (let i = 2; i <= this.frameCount; i++) { + this.statusText = `generating frame ${i}/${this.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.`; + + chatHistory.push({ role: 'user', content: nextPrompt }); + + 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.`); + + this.frames.push(imgN); + + chatHistory.push({ + role: 'assistant', + content: [ + { type: 'image_url', image_url: { url: imgN } } + ] + }); + } + + // -- 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(); + }); + } + }; +}