mirror of
https://github.com/vibegif/vibegif.lol.git
synced 2026-04-07 10:12:13 +00:00
Feat: Vanilla JS app — OpenRouter image gen + GIF
This commit is contained in:
337
app.js
337
app.js
@@ -1,71 +1,199 @@
|
|||||||
function app() {
|
const $ = (s) => document.querySelector(s);
|
||||||
return {
|
const $$ = (s) => document.querySelectorAll(s);
|
||||||
apiKey: '',
|
|
||||||
showSettings: false,
|
const state = {
|
||||||
userPrompt: '',
|
apiKey: localStorage.getItem('vibegif_apikey') || '',
|
||||||
model: 'google/gemini-3.1-flash-image-preview',
|
|
||||||
frameCount: 4,
|
|
||||||
fps: 6,
|
|
||||||
imageSize: '1K',
|
|
||||||
aspectRatio: '1:1',
|
|
||||||
generating: false,
|
generating: false,
|
||||||
statusText: 'generating...',
|
|
||||||
frames: [],
|
frames: [],
|
||||||
gifUrl: null,
|
gifUrl: null,
|
||||||
errorMsg: '',
|
aspectRatio: '1:1',
|
||||||
workerBlob: null,
|
workerBlob: null
|
||||||
|
};
|
||||||
|
|
||||||
init() {
|
// --- Elements ---
|
||||||
this.apiKey = localStorage.getItem('vibegif_apikey') || '';
|
const elOnboarding = $('#section-onboarding');
|
||||||
this.preloadWorker();
|
const elGenerator = $('#section-generator');
|
||||||
this.$nextTick(() => lucide.createIcons());
|
const elModal = $('#modal-settings');
|
||||||
this.$watch('showSettings', () => this.$nextTick(() => lucide.createIcons()));
|
const elBtnSettings = $('#btn-settings');
|
||||||
this.$watch('apiKey', () => this.$nextTick(() => lucide.createIcons()));
|
const elBtnCloseSettings = $('#btn-close-settings');
|
||||||
this.$watch('gifUrl', () => this.$nextTick(() => lucide.createIcons()));
|
const elApiKeyOnboard = $('#input-apikey-onboard');
|
||||||
this.$watch('model', () => {
|
const elApiKeyModal = $('#input-apikey-modal');
|
||||||
if (this.model !== 'google/gemini-3.1-flash-image-preview' && this.imageSize === '0.5K') {
|
const elPrompt = $('#input-prompt');
|
||||||
this.imageSize = '1K';
|
const elModel = $('#select-model');
|
||||||
|
const elFrames = $('#select-frames');
|
||||||
|
const elFps = $('#select-fps');
|
||||||
|
const elSize = $('#select-size');
|
||||||
|
const elOptionHalfK = $('#option-half-k');
|
||||||
|
const elBtnGenerate = $('#btn-generate');
|
||||||
|
const elSectionFrames = $('#section-frames');
|
||||||
|
const elFrameCounter = $('#frame-counter');
|
||||||
|
const elFramesGrid = $('#frames-grid');
|
||||||
|
const elSectionResult = $('#section-result');
|
||||||
|
const elGifPreview = $('#gif-preview');
|
||||||
|
const elBtnDownload = $('#btn-download');
|
||||||
|
const elSectionError = $('#section-error');
|
||||||
|
const elAspectButtons = $('#aspect-buttons');
|
||||||
|
|
||||||
|
// --- Init ---
|
||||||
|
function init() {
|
||||||
|
updateView();
|
||||||
|
preloadWorker();
|
||||||
|
lucide.createIcons();
|
||||||
|
bindEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateView() {
|
||||||
|
if (state.apiKey) {
|
||||||
|
elOnboarding.classList.add('hidden');
|
||||||
|
elGenerator.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
elOnboarding.classList.remove('hidden');
|
||||||
|
elGenerator.classList.add('hidden');
|
||||||
}
|
}
|
||||||
});
|
elApiKeyModal.value = state.apiKey;
|
||||||
},
|
elApiKeyOnboard.value = state.apiKey;
|
||||||
|
|
||||||
saveApiKey() {
|
// 0.5K visibility
|
||||||
localStorage.setItem('vibegif_apikey', this.apiKey);
|
const isGemini = elModel.value === 'google/gemini-3.1-flash-image-preview';
|
||||||
},
|
elOptionHalfK.style.display = isGemini ? '' : 'none';
|
||||||
|
if (!isGemini && elSize.value === '0.5K') elSize.value = '1K';
|
||||||
|
}
|
||||||
|
|
||||||
async preloadWorker() {
|
function saveApiKey(val) {
|
||||||
|
state.apiKey = val;
|
||||||
|
localStorage.setItem('vibegif_apikey', val);
|
||||||
|
updateView();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function preloadWorker() {
|
||||||
try {
|
try {
|
||||||
const r = await fetch('https://cdn.jsdelivr.net/npm/gif.js@0.2.0/dist/gif.worker.js');
|
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());
|
state.workerBlob = URL.createObjectURL(await r.blob());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to preload gif worker:', e);
|
console.error('Worker preload failed:', e);
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
|
||||||
getModalities() {
|
// --- Events ---
|
||||||
// Seedream is image-only; Gemini supports both but we only want image output
|
function bindEvents() {
|
||||||
return ['image'];
|
elBtnSettings.addEventListener('click', () => {
|
||||||
},
|
elModal.classList.remove('hidden');
|
||||||
|
elModal.classList.add('flex');
|
||||||
|
});
|
||||||
|
|
||||||
buildImageConfig() {
|
elBtnCloseSettings.addEventListener('click', closeModal);
|
||||||
const cfg = {};
|
elModal.addEventListener('click', (e) => {
|
||||||
if (this.imageSize) cfg.image_size = this.imageSize;
|
if (e.target === elModal) closeModal();
|
||||||
if (this.aspectRatio) cfg.aspect_ratio = this.aspectRatio;
|
});
|
||||||
return cfg;
|
|
||||||
},
|
|
||||||
|
|
||||||
async callOpenRouter(messages) {
|
elApiKeyOnboard.addEventListener('input', (e) => saveApiKey(e.target.value));
|
||||||
|
elApiKeyModal.addEventListener('input', (e) => saveApiKey(e.target.value));
|
||||||
|
|
||||||
|
elModel.addEventListener('change', updateView);
|
||||||
|
|
||||||
|
$$('.ar-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
if (state.generating) return;
|
||||||
|
$$('.ar-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
state.aspectRatio = btn.dataset.ar;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
elBtnGenerate.addEventListener('click', generate);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
elModal.classList.add('hidden');
|
||||||
|
elModal.classList.remove('flex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setGenerating(val, statusText) {
|
||||||
|
state.generating = val;
|
||||||
|
const inputs = [elPrompt, elModel, elFrames, elFps, elSize];
|
||||||
|
inputs.forEach(el => el.disabled = val);
|
||||||
|
$$('.ar-btn').forEach(b => b.style.pointerEvents = val ? 'none' : '');
|
||||||
|
|
||||||
|
if (val) {
|
||||||
|
elBtnGenerate.disabled = true;
|
||||||
|
elBtnGenerate.classList.remove('bg-neutral-800', 'hover:bg-neutral-700');
|
||||||
|
elBtnGenerate.classList.add('bg-neutral-100', 'text-neutral-400', 'cursor-not-allowed');
|
||||||
|
elBtnGenerate.innerHTML = `
|
||||||
|
<span class="flex items-center justify-center gap-2">
|
||||||
|
<svg class="animate-spin h-4 w-4" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
|
||||||
|
${statusText || 'generating...'}
|
||||||
|
</span>`;
|
||||||
|
} else {
|
||||||
|
elBtnGenerate.disabled = false;
|
||||||
|
elBtnGenerate.classList.add('bg-neutral-800', 'hover:bg-neutral-700');
|
||||||
|
elBtnGenerate.classList.remove('bg-neutral-100', 'text-neutral-400', 'cursor-not-allowed');
|
||||||
|
elBtnGenerate.innerHTML = 'generate gif';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStatus(text) {
|
||||||
|
if (!state.generating) return;
|
||||||
|
const span = elBtnGenerate.querySelector('span span') || elBtnGenerate.querySelector('span');
|
||||||
|
if (span) {
|
||||||
|
// Update just the text node
|
||||||
|
const textNode = [...elBtnGenerate.querySelectorAll('span')].pop();
|
||||||
|
if (textNode) textNode.textContent = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
elSectionError.textContent = msg;
|
||||||
|
elSectionError.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideError() {
|
||||||
|
elSectionError.classList.add('hidden');
|
||||||
|
elSectionError.textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Frame Grid ---
|
||||||
|
function renderFrameGrid() {
|
||||||
|
const frameCount = parseInt(elFrames.value);
|
||||||
|
elFramesGrid.innerHTML = '';
|
||||||
|
elFrameCounter.textContent = `${state.frames.length}/${frameCount}`;
|
||||||
|
elSectionFrames.classList.remove('hidden');
|
||||||
|
|
||||||
|
for (let i = 0; i < frameCount; i++) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'aspect-square rounded-lg overflow-hidden border border-neutral-200 bg-neutral-50 flex items-center justify-center';
|
||||||
|
|
||||||
|
if (i < state.frames.length) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = state.frames[i];
|
||||||
|
img.className = 'w-full h-full object-cover';
|
||||||
|
div.appendChild(img);
|
||||||
|
} else if (i === state.frames.length && state.generating) {
|
||||||
|
div.innerHTML = '<div class="w-5 h-5 border-2 border-neutral-300 border-t-neutral-600 rounded-full animate-spin"></div>';
|
||||||
|
div.classList.add('border-dashed');
|
||||||
|
} else {
|
||||||
|
div.classList.add('border-dashed');
|
||||||
|
}
|
||||||
|
|
||||||
|
elFramesGrid.appendChild(div);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- API ---
|
||||||
|
async function callOpenRouter(messages) {
|
||||||
const body = {
|
const body = {
|
||||||
model: this.model,
|
model: elModel.value,
|
||||||
messages,
|
messages,
|
||||||
modalities: this.getModalities(),
|
modalities: ['image'],
|
||||||
image_config: this.buildImageConfig()
|
image_config: {
|
||||||
|
image_size: elSize.value,
|
||||||
|
aspect_ratio: state.aspectRatio
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
const res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${this.apiKey}`,
|
'Authorization': `Bearer ${state.apiKey}`,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'HTTP-Referer': 'https://vibegif.lol',
|
'HTTP-Referer': 'https://vibegif.lol',
|
||||||
'X-Title': 'vibegif.lol'
|
'X-Title': 'vibegif.lol'
|
||||||
@@ -79,103 +207,109 @@ function app() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return await res.json();
|
return await res.json();
|
||||||
},
|
}
|
||||||
|
|
||||||
extractImage(response) {
|
function extractImage(response) {
|
||||||
const msg = response?.choices?.[0]?.message;
|
const msg = response?.choices?.[0]?.message;
|
||||||
if (!msg) return null;
|
if (!msg) return null;
|
||||||
// Images in message.images array
|
|
||||||
|
// OpenRouter images array
|
||||||
if (msg.images && msg.images.length > 0) {
|
if (msg.images && msg.images.length > 0) {
|
||||||
return msg.images[0]?.image_url?.url || null;
|
return msg.images[0]?.image_url?.url || null;
|
||||||
}
|
}
|
||||||
// Fallback: check content array for inline_data
|
|
||||||
|
// Fallback: content array with image parts
|
||||||
if (Array.isArray(msg.content)) {
|
if (Array.isArray(msg.content)) {
|
||||||
for (const part of msg.content) {
|
for (const part of msg.content) {
|
||||||
if (part.type === 'image_url') return part.image_url?.url || null;
|
if (part.type === 'image_url') return part.image_url?.url || null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
},
|
|
||||||
|
|
||||||
buildRollingMessages(chatHistory, windowSize = 2) {
|
return null;
|
||||||
// Always keep first message (user prompt) + last N assistant/user pairs
|
}
|
||||||
|
|
||||||
|
function buildRollingMessages(chatHistory, windowSize) {
|
||||||
if (chatHistory.length <= 1 + windowSize * 2) return [...chatHistory];
|
if (chatHistory.length <= 1 + windowSize * 2) return [...chatHistory];
|
||||||
const first = chatHistory[0];
|
const first = chatHistory[0];
|
||||||
const recent = chatHistory.slice(-(windowSize * 2));
|
const recent = chatHistory.slice(-(windowSize * 2));
|
||||||
return [first, ...recent];
|
return [first, ...recent];
|
||||||
},
|
}
|
||||||
|
|
||||||
async generate() {
|
// --- Generate ---
|
||||||
if (!this.userPrompt.trim() || this.generating) return;
|
async function generate() {
|
||||||
this.generating = true;
|
const prompt = elPrompt.value.trim();
|
||||||
this.frames = [];
|
const frameCount = parseInt(elFrames.value);
|
||||||
this.gifUrl = null;
|
if (!prompt || state.generating) return;
|
||||||
this.errorMsg = '';
|
|
||||||
|
|
||||||
const masterPrompt = `minimal black and white line doodle, single stroke, white background, kawaii style, ${this.userPrompt.trim()}`;
|
setGenerating(true, `generating frame 1/${frameCount}...`);
|
||||||
|
state.frames = [];
|
||||||
|
state.gifUrl = null;
|
||||||
|
hideError();
|
||||||
|
elSectionResult.classList.add('hidden');
|
||||||
|
|
||||||
|
const masterPrompt = `minimal black and white line doodle, single stroke, white background, kawaii style, ${prompt}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// -- Frame 1 --
|
// Frame 1
|
||||||
this.statusText = `generating frame 1/${this.frameCount}...`;
|
renderFrameGrid();
|
||||||
const chatHistory = [
|
const chatHistory = [
|
||||||
{ role: 'user', content: masterPrompt }
|
{ role: 'user', content: masterPrompt }
|
||||||
];
|
];
|
||||||
|
|
||||||
const res1 = await this.callOpenRouter(chatHistory);
|
const res1 = await callOpenRouter(chatHistory);
|
||||||
const img1 = this.extractImage(res1);
|
const img1 = 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.');
|
if (!img1) throw new Error('No image returned for frame 1. Try a different prompt or model.');
|
||||||
|
|
||||||
this.frames.push(img1);
|
state.frames.push(img1);
|
||||||
|
renderFrameGrid();
|
||||||
|
|
||||||
// Add assistant response to history
|
|
||||||
chatHistory.push({
|
chatHistory.push({
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: [
|
content: [{ type: 'image_url', image_url: { url: img1 } }]
|
||||||
{ type: 'image_url', image_url: { url: img1 } }
|
|
||||||
]
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// -- Subsequent frames --
|
// Subsequent frames
|
||||||
for (let i = 2; i <= this.frameCount; i++) {
|
for (let i = 2; i <= frameCount; i++) {
|
||||||
this.statusText = `generating frame ${i}/${this.frameCount}...`;
|
updateStatus(`generating frame ${i}/${frameCount}...`);
|
||||||
|
setGenerating(true, `generating frame ${i}/${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.`;
|
const nextPrompt = `imagine we are creating a ${frameCount} frame gif of "${prompt}". generate the next meaningful frame (frame ${i} of ${frameCount}). maintain the same minimal black and white line doodle, single stroke, white background, kawaii style.`;
|
||||||
|
|
||||||
chatHistory.push({ role: 'user', content: nextPrompt });
|
chatHistory.push({ role: 'user', content: nextPrompt });
|
||||||
|
|
||||||
const rollingMessages = this.buildRollingMessages(chatHistory, 2);
|
const rollingMessages = buildRollingMessages(chatHistory, 2);
|
||||||
const resN = await this.callOpenRouter(rollingMessages);
|
const resN = await callOpenRouter(rollingMessages);
|
||||||
const imgN = this.extractImage(resN);
|
const imgN = extractImage(resN);
|
||||||
if (!imgN) throw new Error(`No image returned for frame ${i}. Try reducing frame count or changing model.`);
|
if (!imgN) throw new Error(`No image returned for frame ${i}. Try reducing frame count or changing model.`);
|
||||||
|
|
||||||
this.frames.push(imgN);
|
state.frames.push(imgN);
|
||||||
|
renderFrameGrid();
|
||||||
|
|
||||||
chatHistory.push({
|
chatHistory.push({
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: [
|
content: [{ type: 'image_url', image_url: { url: imgN } }]
|
||||||
{ type: 'image_url', image_url: { url: imgN } }
|
|
||||||
]
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Build GIF --
|
// Build GIF
|
||||||
this.statusText = 'assembling gif...';
|
setGenerating(true, 'assembling gif...');
|
||||||
await this.buildGif();
|
await buildGif();
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.errorMsg = e.message || 'Something went wrong';
|
showError(e.message || 'Something went wrong');
|
||||||
console.error(e);
|
console.error(e);
|
||||||
} finally {
|
} finally {
|
||||||
this.generating = false;
|
setGenerating(false);
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
|
||||||
async buildGif() {
|
// --- GIF Builder ---
|
||||||
const delay = Math.round(1000 / this.fps);
|
async function buildGif() {
|
||||||
|
const fps = parseInt(elFps.value);
|
||||||
|
const delay = Math.round(1000 / fps);
|
||||||
|
|
||||||
// Load all frame images as Image elements
|
|
||||||
const images = await Promise.all(
|
const images = await Promise.all(
|
||||||
this.frames.map(src => new Promise((resolve, reject) => {
|
state.frames.map(src => new Promise((resolve, reject) => {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.onload = () => resolve(img);
|
img.onload = () => resolve(img);
|
||||||
img.onerror = reject;
|
img.onerror = reject;
|
||||||
@@ -186,7 +320,6 @@ function app() {
|
|||||||
const w = images[0].naturalWidth;
|
const w = images[0].naturalWidth;
|
||||||
const h = images[0].naturalHeight;
|
const h = images[0].naturalHeight;
|
||||||
|
|
||||||
// Draw each onto a canvas for gif.js
|
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
canvas.width = w;
|
canvas.width = w;
|
||||||
canvas.height = h;
|
canvas.height = h;
|
||||||
@@ -198,7 +331,7 @@ function app() {
|
|||||||
quality: 10,
|
quality: 10,
|
||||||
width: w,
|
width: w,
|
||||||
height: h,
|
height: h,
|
||||||
workerScript: this.workerBlob || 'https://cdn.jsdelivr.net/npm/gif.js@0.2.0/dist/gif.worker.js',
|
workerScript: state.workerBlob || 'https://cdn.jsdelivr.net/npm/gif.js@0.2.0/dist/gif.worker.js',
|
||||||
repeat: 0
|
repeat: 0
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -211,13 +344,19 @@ function app() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
gif.on('finished', (blob) => {
|
gif.on('finished', (blob) => {
|
||||||
this.gifUrl = URL.createObjectURL(blob);
|
state.gifUrl = URL.createObjectURL(blob);
|
||||||
|
elGifPreview.src = state.gifUrl;
|
||||||
|
elBtnDownload.href = state.gifUrl;
|
||||||
|
elBtnDownload.download = `vibegif-${Date.now()}.gif`;
|
||||||
|
elSectionResult.classList.remove('hidden');
|
||||||
|
lucide.createIcons();
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
gif.on('error', reject);
|
gif.on('error', reject);
|
||||||
gif.render();
|
gif.render();
|
||||||
});
|
});
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Boot ---
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
|||||||
Reference in New Issue
Block a user