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(); }); } }; }