mirror of
https://github.com/vibegif/vibegif.lol.git
synced 2026-04-07 10:12:13 +00:00
Feat: Core Alpine app with OpenRouter GIF gen
This commit is contained in:
223
app.js
Normal file
223
app.js
Normal 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user