mirror of
https://github.com/vibegif/vibegif.lol.git
synced 2026-04-07 10:12:13 +00:00
363 lines
11 KiB
JavaScript
363 lines
11 KiB
JavaScript
const $ = (s) => document.querySelector(s);
|
|
const $$ = (s) => document.querySelectorAll(s);
|
|
|
|
const state = {
|
|
apiKey: localStorage.getItem('vibegif_apikey') || '',
|
|
generating: false,
|
|
frames: [],
|
|
gifUrl: null,
|
|
aspectRatio: '1:1',
|
|
workerBlob: null
|
|
};
|
|
|
|
// --- Elements ---
|
|
const elOnboarding = $('#section-onboarding');
|
|
const elGenerator = $('#section-generator');
|
|
const elModal = $('#modal-settings');
|
|
const elBtnSettings = $('#btn-settings');
|
|
const elBtnCloseSettings = $('#btn-close-settings');
|
|
const elApiKeyOnboard = $('#input-apikey-onboard');
|
|
const elApiKeyModal = $('#input-apikey-modal');
|
|
const elPrompt = $('#input-prompt');
|
|
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;
|
|
|
|
// 0.5K visibility
|
|
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';
|
|
}
|
|
|
|
function saveApiKey(val) {
|
|
state.apiKey = val;
|
|
localStorage.setItem('vibegif_apikey', val);
|
|
updateView();
|
|
}
|
|
|
|
async function preloadWorker() {
|
|
try {
|
|
const r = await fetch('https://cdn.jsdelivr.net/npm/gif.js@0.2.0/dist/gif.worker.js');
|
|
state.workerBlob = URL.createObjectURL(await r.blob());
|
|
} catch (e) {
|
|
console.error('Worker preload failed:', e);
|
|
}
|
|
}
|
|
|
|
// --- Events ---
|
|
function bindEvents() {
|
|
elBtnSettings.addEventListener('click', () => {
|
|
elModal.classList.remove('hidden');
|
|
elModal.classList.add('flex');
|
|
});
|
|
|
|
elBtnCloseSettings.addEventListener('click', closeModal);
|
|
elModal.addEventListener('click', (e) => {
|
|
if (e.target === elModal) closeModal();
|
|
});
|
|
|
|
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 = {
|
|
model: elModel.value,
|
|
messages,
|
|
modalities: ['image'],
|
|
image_config: {
|
|
image_size: elSize.value,
|
|
aspect_ratio: state.aspectRatio
|
|
}
|
|
};
|
|
|
|
const res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${state.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();
|
|
}
|
|
|
|
function extractImage(response) {
|
|
const msg = response?.choices?.[0]?.message;
|
|
if (!msg) return null;
|
|
|
|
// OpenRouter images array
|
|
if (msg.images && msg.images.length > 0) {
|
|
return msg.images[0]?.image_url?.url || null;
|
|
}
|
|
|
|
// Fallback: content array with image parts
|
|
if (Array.isArray(msg.content)) {
|
|
for (const part of msg.content) {
|
|
if (part.type === 'image_url') return part.image_url?.url || null;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function buildRollingMessages(chatHistory, windowSize) {
|
|
if (chatHistory.length <= 1 + windowSize * 2) return [...chatHistory];
|
|
const first = chatHistory[0];
|
|
const recent = chatHistory.slice(-(windowSize * 2));
|
|
return [first, ...recent];
|
|
}
|
|
|
|
// --- Generate ---
|
|
async function generate() {
|
|
const prompt = elPrompt.value.trim();
|
|
const frameCount = parseInt(elFrames.value);
|
|
if (!prompt || state.generating) return;
|
|
|
|
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 {
|
|
// Frame 1
|
|
renderFrameGrid();
|
|
const chatHistory = [
|
|
{ role: 'user', content: masterPrompt }
|
|
];
|
|
|
|
const res1 = await callOpenRouter(chatHistory);
|
|
const img1 = extractImage(res1);
|
|
if (!img1) throw new Error('No image returned for frame 1. Try a different prompt or model.');
|
|
|
|
state.frames.push(img1);
|
|
renderFrameGrid();
|
|
|
|
chatHistory.push({
|
|
role: 'assistant',
|
|
content: [{ type: 'image_url', image_url: { url: img1 } }]
|
|
});
|
|
|
|
// Subsequent frames
|
|
for (let i = 2; i <= frameCount; i++) {
|
|
updateStatus(`generating frame ${i}/${frameCount}...`);
|
|
setGenerating(true, `generating frame ${i}/${frameCount}...`);
|
|
|
|
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 });
|
|
|
|
const rollingMessages = buildRollingMessages(chatHistory, 2);
|
|
const resN = await callOpenRouter(rollingMessages);
|
|
const imgN = extractImage(resN);
|
|
if (!imgN) throw new Error(`No image returned for frame ${i}. Try reducing frame count or changing model.`);
|
|
|
|
state.frames.push(imgN);
|
|
renderFrameGrid();
|
|
|
|
chatHistory.push({
|
|
role: 'assistant',
|
|
content: [{ type: 'image_url', image_url: { url: imgN } }]
|
|
});
|
|
}
|
|
|
|
// Build GIF
|
|
setGenerating(true, 'assembling gif...');
|
|
await buildGif();
|
|
|
|
} catch (e) {
|
|
showError(e.message || 'Something went wrong');
|
|
console.error(e);
|
|
} finally {
|
|
setGenerating(false);
|
|
}
|
|
}
|
|
|
|
// --- GIF Builder ---
|
|
async function buildGif() {
|
|
const fps = parseInt(elFps.value);
|
|
const delay = Math.round(1000 / fps);
|
|
|
|
const images = await Promise.all(
|
|
state.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;
|
|
|
|
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: state.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) => {
|
|
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();
|
|
});
|
|
|
|
gif.on('error', reject);
|
|
gif.render();
|
|
});
|
|
}
|
|
|
|
// --- Boot ---
|
|
document.addEventListener('DOMContentLoaded', init);
|