Files
vibegif.lol/app.js

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);