mirror of
https://github.com/vibegif/vibegif.lol.git
synced 2026-04-07 18:22:13 +00:00
Delete app.js
This commit is contained in:
362
app.js
362
app.js
@@ -1,362 +0,0 @@
|
|||||||
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);
|
|
||||||
Reference in New Issue
Block a user