mirror of
https://github.com/vibegif/vibegif.lol.git
synced 2026-04-07 02:12:12 +00:00
Refactor: Move inline JS to modules
This commit is contained in:
406
index.html
406
index.html
@@ -4,17 +4,6 @@
|
||||
<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">
|
||||
@@ -121,399 +110,6 @@
|
||||
|
||||
<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>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user