Files
vibegif.lol/index.html
2026-03-20 22:52:47 -07:00

520 lines
20 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>vibegif.lol — AI Generated Gifs</title>
<style>
@font-face {
font-family: "Stain";
src: url("https://cdn.jsdelivr.net/gh/multipleof4/stain.otf@master/dist/Stain.otf") format("opentype");
font-weight: normal;
font-style: normal;
}
body, input, select, button, a {
font-family: "Stain", sans-serif;
}
</style>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-white text-neutral-800 min-h-screen flex flex-col items-center">
<div id="app" class="w-full max-w-xl mx-auto px-4 py-8 flex flex-col items-center gap-6">
<header class="w-full flex items-center justify-between">
<h1 class="text-4xl tracking-tight">vibegif<span class="text-neutral-400">.lol</span></h1>
<button id="btn-settings" class="p-2 rounded-lg hover:bg-neutral-100 transition" title="Settings">
<i data-lucide="panel-left" class="w-5 h-5 text-neutral-500"></i>
</button>
</header>
<div id="setup-screen" class="w-full flex flex-col items-center gap-4 mt-12">
<p class="text-neutral-500 text-center text-lg">enter your <a href="https://openrouter.ai/keys" target="_blank" class="underline hover:text-neutral-800">OpenRouter</a> API key to start</p>
<input id="setup-key" type="password" placeholder="sk-or-..." class="w-full border border-neutral-300 rounded-lg px-4 py-3 text-center focus:outline-none focus:ring-2 focus:ring-neutral-400">
<button id="setup-save" class="bg-neutral-800 text-white rounded-lg px-6 py-3 hover:bg-neutral-700 transition">save & start vibing</button>
</div>
<div id="main-screen" class="w-full flex flex-col gap-5 hidden">
<div class="flex flex-col gap-2">
<label class="text-xs text-neutral-400 uppercase tracking-wider">model</label>
<select id="sel-model" class="border border-neutral-300 rounded-lg px-3 py-2 bg-white focus:outline-none focus:ring-2 focus:ring-neutral-400">
<option value="google/gemini-3.1-flash-image-preview">Gemini 3.1 Flash Image</option>
<option value="bytedance-seed/seedream-4.5">Seedream 4.5</option>
</select>
</div>
<div class="flex flex-col gap-2">
<label class="text-xs text-neutral-400 uppercase tracking-wider">prompt</label>
<input id="inp-prompt" type="text" placeholder="rolling cat" class="border border-neutral-300 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-neutral-400">
<p class="text-xs text-neutral-400">keep it simple — e.g. "rolling cat", "bouncing ball", "waving hand"</p>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="flex flex-col gap-2">
<label class="text-xs text-neutral-400 uppercase tracking-wider">frames</label>
<input id="inp-frames" type="number" value="4" min="2" max="24" class="border border-neutral-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-neutral-400">
</div>
<div class="flex flex-col gap-2">
<label class="text-xs text-neutral-400 uppercase tracking-wider">fps</label>
<input id="inp-fps" type="number" value="4" min="1" max="30" class="border border-neutral-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-neutral-400">
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="flex flex-col gap-2">
<label class="text-xs text-neutral-400 uppercase tracking-wider">size</label>
<select id="sel-size" class="border border-neutral-300 rounded-lg px-3 py-2 bg-white focus:outline-none focus:ring-2 focus:ring-neutral-400">
<option value="1K">1K</option>
<option value="0.5K">0.5K (Gemini only)</option>
</select>
</div>
<div class="flex flex-col gap-2">
<label class="text-xs text-neutral-400 uppercase tracking-wider">aspect ratio</label>
<select id="sel-ratio" class="border border-neutral-300 rounded-lg px-3 py-2 bg-white focus:outline-none focus:ring-2 focus:ring-neutral-400">
<option value="1:1">1:1</option>
<option value="16:9">16:9</option>
<option value="9:16">9:16</option>
<option value="4:3">4:3</option>
<option value="3:2">3:2</option>
</select>
</div>
</div>
<button id="btn-generate" class="bg-neutral-800 text-white rounded-lg px-6 py-3 hover:bg-neutral-700 transition flex items-center justify-center gap-2 text-lg">
<i data-lucide="sparkles" class="w-5 h-5"></i>
generate gif
</button>
<div id="progress-area" class="w-full hidden flex-col items-center gap-3">
<div class="w-full bg-neutral-100 rounded-full h-2 overflow-hidden">
<div id="progress-bar" class="bg-neutral-800 h-2 rounded-full transition-all duration-300" style="width:0%"></div>
</div>
<p id="progress-text" class="text-sm text-neutral-400"></p>
<div id="frames-preview" class="flex gap-2 flex-wrap justify-center"></div>
</div>
<div id="result-area" class="w-full hidden flex-col items-center gap-4">
<img id="result-gif" class="rounded-xl border border-neutral-200 max-w-full">
<a id="btn-download" download="vibegif.gif" class="bg-neutral-800 text-white rounded-lg px-6 py-3 hover:bg-neutral-700 transition flex items-center gap-2 cursor-pointer no-underline">
<i data-lucide="download" class="w-5 h-5"></i>
download gif
</a>
</div>
</div>
</div>
<div id="modal-settings" class="fixed inset-0 bg-black/30 flex items-center justify-center z-50 hidden">
<div class="bg-white rounded-2xl p-6 w-full max-w-sm mx-4 flex flex-col gap-4 shadow-xl">
<div class="flex items-center justify-between">
<h2 class="text-xl">account settings</h2>
<button id="btn-close-modal" class="p-1 hover:bg-neutral-100 rounded-lg transition">
<i data-lucide="x" class="w-5 h-5 text-neutral-500"></i>
</button>
</div>
<div class="flex flex-col gap-2">
<label class="text-xs text-neutral-400 uppercase tracking-wider">OpenRouter API Key</label>
<input id="modal-key" type="password" placeholder="sk-or-..." class="border border-neutral-300 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-neutral-400">
</div>
<button id="modal-save" class="bg-neutral-800 text-white rounded-lg px-6 py-3 hover:bg-neutral-700 transition">save</button>
</div>
</div>
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gif.js/0.2.0/gif.js"></script>
<script>
(function() {
'use strict';
var STORAGE_KEY = 'vibegif_api_key';
function getApiKey() { return localStorage.getItem(STORAGE_KEY) || ''; }
function setApiKey(k) { localStorage.setItem(STORAGE_KEY, (k || '').trim()); }
function hasApiKey() { return !!getApiKey(); }
var el = {};
function grabElements() {
var ids = [
'setup-screen','main-screen','setup-key','setup-save',
'btn-settings','modal-settings','modal-key','modal-save','btn-close-modal',
'sel-model','inp-prompt','inp-frames','inp-fps','sel-size','sel-ratio',
'btn-generate','progress-area','progress-bar','progress-text','frames-preview',
'result-area','result-gif','btn-download'
];
for (var i = 0; i < ids.length; i++) {
var camel = ids[i].replace(/-([a-z])/g, function(_, c) { return c.toUpperCase(); });
el[camel] = document.getElementById(ids[i]);
if (!el[camel]) console.warn('vibegif: missing element #' + ids[i]);
}
}
function show(node) { if (node) { node.classList.remove('hidden'); node.style.display = ''; } }
function hide(node) { if (node) node.classList.add('hidden'); }
function showSetup() { show(el.setupScreen); hide(el.mainScreen); }
function showMain() { hide(el.setupScreen); show(el.mainScreen); }
function showModal() {
if (el.modalKey) el.modalKey.value = getApiKey();
show(el.modalSettings);
}
function hideModal() { hide(el.modalSettings); }
function setProgress(pct, text) {
show(el.progressArea);
if (el.progressBar) el.progressBar.style.width = pct + '%';
if (el.progressText) el.progressText.textContent = text;
}
function addFramePreview(base64, index) {
if (!el.framesPreview) return;
var img = document.createElement('img');
img.src = base64;
img.className = 'w-16 h-16 rounded-lg border border-neutral-200 object-cover';
img.title = 'frame ' + (index + 1);
el.framesPreview.appendChild(img);
}
function resetProgress() {
hide(el.progressArea);
if (el.progressBar) el.progressBar.style.width = '0%';
if (el.progressText) el.progressText.textContent = '';
if (el.framesPreview) el.framesPreview.innerHTML = '';
}
function showResult(blobUrl) {
if (el.resultArea) { el.resultArea.classList.remove('hidden'); el.resultArea.style.display = 'flex'; }
if (el.resultGif) el.resultGif.src = blobUrl;
if (el.btnDownload) el.btnDownload.href = blobUrl;
}
function hideResult() {
if (el.resultArea) { el.resultArea.classList.add('hidden'); el.resultArea.style.display = ''; }
}
function setGenerating(active) {
if (!el.btnGenerate) return;
el.btnGenerate.disabled = active;
el.btnGenerate.style.opacity = active ? '0.5' : '1';
el.btnGenerate.style.cursor = active ? 'not-allowed' : 'pointer';
}
var API_URL = 'https://openrouter.ai/api/v1/chat/completions';
var MASTER_PROMPT = 'minimal black and white line doodle, single stroke, white background, kawaii style';
function buildFirstMessage(userPrompt) {
return { role: 'user', content: MASTER_PROMPT + ', ' + userPrompt };
}
function buildNextMessage(frameIndex, frameCount) {
return {
role: 'user',
content: 'imagine we are trying to create a ' + frameCount + ' frame gif. generate the next meaningful frame (frame ' + frameIndex + ' of ' + frameCount + ')'
};
}
function safeJsonStringify(v) {
try { return JSON.stringify(v); } catch (_) { return String(v); }
}
function extractApiErrorDetails(payload) {
if (!payload) return '';
var err = payload.error || payload;
var parts = [];
if (typeof err === 'string') parts.push(err);
if (err && typeof err.message === 'string') parts.push(err.message);
if (err && typeof err.code === 'string') parts.push('code: ' + err.code);
if (err && typeof err.type === 'string') parts.push('type: ' + err.type);
if (err && err.metadata) {
var md = err.metadata;
if (md.provider_name) parts.push('provider: ' + md.provider_name);
if (md.raw && typeof md.raw === 'string') parts.push(md.raw);
if (md.reason) parts.push(md.reason);
}
if (err && err.details) {
if (typeof err.details === 'string') parts.push(err.details);
else parts.push(safeJsonStringify(err.details));
}
if (!parts.length) return '';
var seen = {};
var deduped = [];
for (var i = 0; i < parts.length; i++) {
var p = (parts[i] || '').trim();
if (!p || seen[p]) continue;
seen[p] = true;
deduped.push(p);
}
return deduped.join(' | ');
}
function parseErrorResponse(res) {
return res.text().then(function(raw) {
var json = null;
if (raw) {
try { json = JSON.parse(raw); } catch (_) {}
}
var detail = extractApiErrorDetails(json);
if (!detail && raw) detail = raw.trim();
if (!detail) detail = 'Unknown API error';
var statusPart = 'HTTP ' + res.status + (res.statusText ? ' ' + res.statusText : '');
return statusPart + ' — ' + detail;
});
}
function generateFrame(opts) {
var model = opts.model;
var messages = opts.messages;
var imageSize = opts.imageSize;
var aspectRatio = opts.aspectRatio;
var isGemini = model.indexOf('google/') === 0;
var body = {
model: model,
messages: messages,
modalities: ['image'],
image_config: {
aspect_ratio: aspectRatio,
image_size: imageSize
}
};
if (!isGemini && imageSize === '0.5K') {
body.image_config.image_size = '1K';
}
return fetch(API_URL, {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + getApiKey(),
'Content-Type': 'application/json',
'HTTP-Referer': 'https://vibegif.lol',
'X-Title': 'vibegif.lol'
},
body: JSON.stringify(body)
})
.then(function(res) {
if (!res.ok) {
return parseErrorResponse(res).then(function(msg) { throw new Error(msg); });
}
return res.json();
})
.then(function(data) {
var choice = data.choices && data.choices[0];
if (!choice) throw new Error('No response from model');
var b64 = null;
var images = choice.message && choice.message.images;
if (images && images.length) {
var imgObj = images[0];
b64 = (imgObj.image_url && imgObj.image_url.url) || imgObj.url || imgObj;
}
if (!b64 && choice.message && Array.isArray(choice.message.content)) {
for (var i = 0; i < choice.message.content.length; i++) {
var part = choice.message.content[i];
if (part.type === 'image_url' && part.image_url && part.image_url.url) {
b64 = part.image_url.url;
break;
}
}
}
if (!b64) throw new Error('No image in response. Model may have refused or returned text only.');
if (typeof b64 === 'string' && b64.indexOf('data:') !== 0) {
b64 = 'data:image/png;base64,' + b64;
}
var assistantMsg = {
role: 'assistant',
content: [{ type: 'image_url', image_url: { url: b64 } }]
};
return { base64: b64, assistantMsg: assistantMsg };
});
}
var WORKER_CDN = 'https://cdnjs.cloudflare.com/ajax/libs/gif.js/0.2.0/gif.worker.js';
var workerBlobUrl = null;
function getWorkerUrl() {
if (workerBlobUrl) return Promise.resolve(workerBlobUrl);
return fetch(WORKER_CDN).then(function(r) { return r.blob(); }).then(function(blob) {
workerBlobUrl = URL.createObjectURL(blob);
return workerBlobUrl;
});
}
function loadImage(src) {
return new Promise(function(resolve, reject) {
var img = new Image();
img.onload = function() { resolve(img); };
img.onerror = function() { reject(new Error('Failed to load frame image')); };
img.src = src;
});
}
function assembleGif(base64Frames, fps) {
return getWorkerUrl().then(function(workerScript) {
return Promise.all(base64Frames.map(loadImage)).then(function(images) {
var w = images[0].naturalWidth;
var h = images[0].naturalHeight;
var delay = Math.round(1000 / fps);
var canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
var ctx = canvas.getContext('2d');
return new Promise(function(resolve, reject) {
var gif = new GIF({
workers: 2,
quality: 10,
width: w,
height: h,
workerScript: workerScript,
repeat: 0
});
for (var i = 0; i < images.length; i++) {
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, w, h);
ctx.drawImage(images[i], 0, 0, w, h);
gif.addFrame(ctx, { copy: true, delay: delay });
}
gif.on('finished', function(blob) { resolve(blob); });
gif.on('error', function(err) { reject(err); });
gif.render();
});
});
});
}
function handleGenerate() {
var prompt = (el.inpPrompt.value || '').trim();
if (!prompt) { el.inpPrompt.focus(); return; }
var model = el.selModel.value;
var frameCount = Math.max(2, Math.min(24, parseInt(el.inpFrames.value) || 4));
var fps = Math.max(1, Math.min(30, parseInt(el.inpFps.value) || 4));
var imageSize = el.selSize.value;
var aspectRatio = el.selRatio.value;
var WINDOW = 1;
setGenerating(true);
resetProgress();
hideResult();
var allBase64 = [];
var fullHistory = [];
var firstMsg = buildFirstMessage(prompt);
setProgress(0, 'generating frame 1 of ' + frameCount + '...');
generateFrame({ model: model, messages: [firstMsg], imageSize: imageSize, aspectRatio: aspectRatio })
.then(function(r1) {
allBase64.push(r1.base64);
fullHistory.push(firstMsg, r1.assistantMsg);
addFramePreview(r1.base64, 0);
setProgress(Math.round(100 / frameCount), 'frame 1 of ' + frameCount + ' done');
var chain = Promise.resolve();
for (var i = 2; i <= frameCount; i++) {
(function(idx) {
chain = chain.then(function() {
setProgress(Math.round(((idx - 1) / frameCount) * 100), 'generating frame ' + idx + ' of ' + frameCount + '...');
var nextUserMsg = buildNextMessage(idx, frameCount);
var windowMessages = [firstMsg];
var startIdx = Math.max(1, fullHistory.length - WINDOW * 2);
for (var j = startIdx; j < fullHistory.length; j++) windowMessages.push(fullHistory[j]);
windowMessages.push(nextUserMsg);
return generateFrame({ model: model, messages: windowMessages, imageSize: imageSize, aspectRatio: aspectRatio })
.then(function(ri) {
allBase64.push(ri.base64);
fullHistory.push(nextUserMsg, ri.assistantMsg);
addFramePreview(ri.base64, idx - 1);
setProgress(Math.round((idx / frameCount) * 100), 'frame ' + idx + ' of ' + frameCount + ' done');
});
});
})(i);
}
return chain;
})
.then(function() {
setProgress(100, 'assembling gif...');
return assembleGif(allBase64, fps);
})
.then(function(blob) {
var url = URL.createObjectURL(blob);
showResult(url);
setProgress(100, 'done! 🎉');
})
.catch(function(err) {
var msg = (err && err.message) ? err.message : String(err);
setProgress(0, 'error: ' + msg);
console.error('vibegif error:', err);
})
.then(function() {
setGenerating(false);
});
}
function boot() {
grabElements();
if (window.lucide && window.lucide.createIcons) {
try { window.lucide.createIcons(); } catch(e) { console.warn('lucide init error:', e); }
}
if (hasApiKey()) showMain();
else showSetup();
if (el.setupSave) el.setupSave.addEventListener('click', function() {
var k = (el.setupKey.value || '').trim();
if (!k) return;
setApiKey(k);
showMain();
});
if (el.btnSettings) el.btnSettings.addEventListener('click', showModal);
if (el.btnCloseModal) el.btnCloseModal.addEventListener('click', hideModal);
if (el.modalSettings) el.modalSettings.addEventListener('click', function(e) {
if (e.target === el.modalSettings) hideModal();
});
if (el.modalSave) el.modalSave.addEventListener('click', function() {
var k = (el.modalKey.value || '').trim();
if (!k) return;
setApiKey(k);
hideModal();
});
if (el.selModel) el.selModel.addEventListener('change', function() {
var isGemini = el.selModel.value.indexOf('google/') === 0;
var opt05 = el.selSize.querySelector('option[value="0.5K"]');
if (opt05) opt05.disabled = !isGemini;
if (!isGemini && el.selSize.value === '0.5K') el.selSize.value = '1K';
});
if (el.btnGenerate) el.btnGenerate.addEventListener('click', handleGenerate);
console.log('vibegif.lol booted ✓');
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
else boot();
})();
</script>
</body>
</html>