Feat: Core Alpine app with OpenRouter GIF gen

This commit is contained in:
2026-03-20 21:54:18 -07:00
parent 477c80d57a
commit 0d1cb6d3cf

223
app.js Normal file
View File

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