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