mirror of
https://github.com/vibegif/vibegif.lol.git
synced 2026-04-07 10:12:13 +00:00
Delete assets directory
This commit is contained in:
@@ -1,12 +0,0 @@
|
|||||||
[x-cloak] { display: none !important; }
|
|
||||||
|
|
||||||
@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 {
|
|
||||||
font-family: "Stain", sans-serif;
|
|
||||||
}
|
|
||||||
272
assets/js/app.js
272
assets/js/app.js
@@ -1,272 +0,0 @@
|
|||||||
import { generateImageFrame } from "./openrouter.js";
|
|
||||||
import { buildGifFromFrames } from "./gif.js";
|
|
||||||
import { firstPrompt, nextFramePrompt } from "./ui.js";
|
|
||||||
|
|
||||||
const GEMINI_MODEL = "google/gemini-3.1-flash-image-preview";
|
|
||||||
|
|
||||||
// Wrap in try-catch to prevent fatal crashes in strict iframes/sandboxes
|
|
||||||
let savedApiKey = "";
|
|
||||||
try {
|
|
||||||
savedApiKey = localStorage.getItem("openrouter_api_key") || "";
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("localStorage is blocked in this environment.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const state = {
|
|
||||||
apiKey: savedApiKey,
|
|
||||||
frames: [],
|
|
||||||
gifUrl: "",
|
|
||||||
loading: false
|
|
||||||
};
|
|
||||||
|
|
||||||
const el = {};
|
|
||||||
const q = (id) => document.getElementById(id);
|
|
||||||
|
|
||||||
function clamp(n, min, max, fallback) {
|
|
||||||
n = Number(n);
|
|
||||||
if (Number.isNaN(n)) return fallback;
|
|
||||||
return Math.min(max, Math.max(min, n));
|
|
||||||
}
|
|
||||||
|
|
||||||
function show(node) {
|
|
||||||
node.classList.remove("hidden");
|
|
||||||
if (node.id === "settings-modal") node.classList.add("flex");
|
|
||||||
}
|
|
||||||
|
|
||||||
function hide(node) {
|
|
||||||
node.classList.add("hidden");
|
|
||||||
if (node.id === "settings-modal") node.classList.remove("flex");
|
|
||||||
}
|
|
||||||
|
|
||||||
function setError(msg = "") {
|
|
||||||
if (!msg) {
|
|
||||||
el.error.textContent = "";
|
|
||||||
hide(el.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
el.error.textContent = msg;
|
|
||||||
show(el.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setProgress(label = "", pct = 0, visible = false) {
|
|
||||||
el.progressLabel.textContent = label;
|
|
||||||
el.progressBar.style.width = `${Math.max(0, Math.min(100, pct))}%`;
|
|
||||||
visible ? show(el.progressWrap) : hide(el.progressWrap);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setLoading(v) {
|
|
||||||
state.loading = !!v;
|
|
||||||
el.generateBtn.disabled = state.loading;
|
|
||||||
el.generateBtn.textContent = state.loading ? "Generating…" : "Generate GIF";
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateImageSizeOptions() {
|
|
||||||
const model = el.model.value;
|
|
||||||
const current = el.imageSize.value;
|
|
||||||
const options = model === GEMINI_MODEL
|
|
||||||
? [
|
|
||||||
{ value: "1K", label: "1K" },
|
|
||||||
{ value: "0.5K", label: "0.5K" }
|
|
||||||
]
|
|
||||||
: [{ value: "1K", label: "1K" }];
|
|
||||||
|
|
||||||
el.imageSize.innerHTML = "";
|
|
||||||
for (const opt of options) {
|
|
||||||
const o = document.createElement("option");
|
|
||||||
o.value = opt.value;
|
|
||||||
o.textContent = opt.label;
|
|
||||||
el.imageSize.appendChild(o);
|
|
||||||
}
|
|
||||||
|
|
||||||
const allowed = options.map((o) => o.value);
|
|
||||||
el.imageSize.value = allowed.includes(current) ? current : "1K";
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderFrames() {
|
|
||||||
el.framesGrid.innerHTML = "";
|
|
||||||
for (let i = 0; i < state.frames.length; i++) {
|
|
||||||
const card = document.createElement("div");
|
|
||||||
card.className = "rounded-xl border border-ui-border p-2 bg-white";
|
|
||||||
|
|
||||||
const img = document.createElement("img");
|
|
||||||
img.src = state.frames[i];
|
|
||||||
img.alt = `frame ${i + 1}`;
|
|
||||||
img.className = "w-full rounded-lg";
|
|
||||||
|
|
||||||
const p = document.createElement("p");
|
|
||||||
p.className = "mt-1 text-xs text-ui-sub";
|
|
||||||
p.textContent = `Frame ${i + 1}`;
|
|
||||||
|
|
||||||
card.appendChild(img);
|
|
||||||
card.appendChild(p);
|
|
||||||
el.framesGrid.appendChild(card);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderGif() {
|
|
||||||
if (!state.gifUrl) {
|
|
||||||
hide(el.gifWrap);
|
|
||||||
show(el.emptyOutput);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
el.gifImg.src = state.gifUrl;
|
|
||||||
el.gifDownload.href = state.gifUrl;
|
|
||||||
show(el.gifWrap);
|
|
||||||
hide(el.emptyOutput);
|
|
||||||
}
|
|
||||||
|
|
||||||
function openSettings() {
|
|
||||||
el.apiKeyInput.value = state.apiKey;
|
|
||||||
show(el.settingsModal);
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeSettings() {
|
|
||||||
hide(el.settingsModal);
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveApiKey() {
|
|
||||||
state.apiKey = el.apiKeyInput.value.trim();
|
|
||||||
try {
|
|
||||||
localStorage.setItem("openrouter_api_key", state.apiKey);
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("Could not save to localStorage.");
|
|
||||||
}
|
|
||||||
closeSettings();
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearApiKey() {
|
|
||||||
state.apiKey = "";
|
|
||||||
el.apiKeyInput.value = "";
|
|
||||||
try {
|
|
||||||
localStorage.removeItem("openrouter_api_key");
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("Could not modify localStorage.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generate() {
|
|
||||||
setError("");
|
|
||||||
setLoading(true);
|
|
||||||
setProgress("", 0, true);
|
|
||||||
|
|
||||||
if (state.gifUrl) URL.revokeObjectURL(state.gifUrl);
|
|
||||||
state.gifUrl = "";
|
|
||||||
state.frames = [];
|
|
||||||
renderGif();
|
|
||||||
renderFrames();
|
|
||||||
|
|
||||||
const model = el.model.value;
|
|
||||||
const userPrompt = el.userPrompt.value.trim();
|
|
||||||
const frameCount = clamp(el.frameCount.value, 2, 24, 4);
|
|
||||||
const fps = clamp(el.fps.value, 1, 24, 6);
|
|
||||||
const imageSize = el.imageSize.value;
|
|
||||||
const aspectRatio = el.aspectRatio.value;
|
|
||||||
|
|
||||||
if (!state.apiKey) {
|
|
||||||
setError("Add your OpenRouter API key first (☰ button).");
|
|
||||||
openSettings();
|
|
||||||
setLoading(false);
|
|
||||||
setProgress("", 0, false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!userPrompt) {
|
|
||||||
setError("Please enter a simple prompt (e.g. rolling cat).");
|
|
||||||
setLoading(false);
|
|
||||||
setProgress("", 0, false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const p1 = firstPrompt(userPrompt);
|
|
||||||
setProgress(`Generating frame 1/${frameCount}...`, Math.round(100 / frameCount), true);
|
|
||||||
|
|
||||||
const frame1 = await generateImageFrame({
|
|
||||||
apiKey: state.apiKey,
|
|
||||||
model,
|
|
||||||
textPrompt: p1,
|
|
||||||
previousFrames: [],
|
|
||||||
imageSize,
|
|
||||||
aspectRatio
|
|
||||||
});
|
|
||||||
|
|
||||||
state.frames.push(frame1);
|
|
||||||
renderFrames();
|
|
||||||
|
|
||||||
for (let i = 2; i <= frameCount; i++) {
|
|
||||||
setProgress(`Generating frame ${i}/${frameCount}...`, Math.round((i / frameCount) * 100), true);
|
|
||||||
|
|
||||||
const next = await generateImageFrame({
|
|
||||||
apiKey: state.apiKey,
|
|
||||||
model,
|
|
||||||
textPrompt: nextFramePrompt(frameCount),
|
|
||||||
previousFrames: state.frames.slice(-2),
|
|
||||||
imageSize,
|
|
||||||
aspectRatio
|
|
||||||
});
|
|
||||||
|
|
||||||
state.frames.push(next);
|
|
||||||
renderFrames();
|
|
||||||
}
|
|
||||||
|
|
||||||
setProgress("Building GIF...", 100, true);
|
|
||||||
state.gifUrl = await buildGifFromFrames(state.frames, fps);
|
|
||||||
renderGif();
|
|
||||||
setProgress("Done.", 100, true);
|
|
||||||
} catch (e) {
|
|
||||||
setError(e?.message || "Failed to generate.");
|
|
||||||
setProgress("", 0, false);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function bind() {
|
|
||||||
el.openSettingsBtn.addEventListener("click", openSettings);
|
|
||||||
el.closeSettingsBtn.addEventListener("click", closeSettings);
|
|
||||||
el.saveApiKeyBtn.addEventListener("click", saveApiKey);
|
|
||||||
el.clearApiKeyBtn.addEventListener("click", clearApiKey);
|
|
||||||
el.generateBtn.addEventListener("click", generate);
|
|
||||||
el.model.addEventListener("change", updateImageSizeOptions);
|
|
||||||
|
|
||||||
el.settingsModal.addEventListener("click", (e) => {
|
|
||||||
if (e.target === el.settingsModal) closeSettings();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener("keydown", (e) => {
|
|
||||||
if (e.key === "Escape") closeSettings();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function init() {
|
|
||||||
el.model = q("model");
|
|
||||||
el.userPrompt = q("user-prompt");
|
|
||||||
el.frameCount = q("frame-count");
|
|
||||||
el.fps = q("fps");
|
|
||||||
el.imageSize = q("image-size");
|
|
||||||
el.aspectRatio = q("aspect-ratio");
|
|
||||||
el.generateBtn = q("generate-btn");
|
|
||||||
el.error = q("error");
|
|
||||||
el.progressWrap = q("progress-wrap");
|
|
||||||
el.progressLabel = q("progress-label");
|
|
||||||
el.progressBar = q("progress-bar");
|
|
||||||
el.gifWrap = q("gif-wrap");
|
|
||||||
el.gifImg = q("gif-img");
|
|
||||||
el.gifDownload = q("gif-download");
|
|
||||||
el.emptyOutput = q("empty-output");
|
|
||||||
el.framesGrid = q("frames-grid");
|
|
||||||
|
|
||||||
el.settingsModal = q("settings-modal");
|
|
||||||
el.openSettingsBtn = q("open-settings-btn");
|
|
||||||
el.closeSettingsBtn = q("close-settings-btn");
|
|
||||||
el.apiKeyInput = q("api-key-input");
|
|
||||||
el.saveApiKeyBtn = q("save-api-key-btn");
|
|
||||||
el.clearApiKeyBtn = q("clear-api-key-btn");
|
|
||||||
|
|
||||||
updateImageSizeOptions();
|
|
||||||
renderGif();
|
|
||||||
bind();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Since module scripts are natively deferred, the DOM is guaranteed to be fully parsed.
|
|
||||||
init();
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
function loadImage(src) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const img = new Image();
|
|
||||||
img.crossOrigin = "anonymous";
|
|
||||||
img.onload = () => resolve(img);
|
|
||||||
img.onerror = reject;
|
|
||||||
img.src = src;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function buildGifFromFrames(frames, fps = 6) {
|
|
||||||
if (!frames?.length) throw new Error("No frames to build GIF.");
|
|
||||||
|
|
||||||
const delay = Math.max(40, Math.floor(1000 / Math.max(1, fps)));
|
|
||||||
const images = await Promise.all(frames.map(loadImage));
|
|
||||||
|
|
||||||
const gif = new GIF({
|
|
||||||
workers: 2,
|
|
||||||
quality: 10,
|
|
||||||
workerScript: "https://cdn.jsdelivr.net/npm/gif.js.optimized/dist/gif.worker.js",
|
|
||||||
width: images[0].naturalWidth,
|
|
||||||
height: images[0].naturalHeight
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const img of images) gif.addFrame(img, { delay });
|
|
||||||
|
|
||||||
const blob = await new Promise((resolve) => {
|
|
||||||
gif.on("finished", resolve);
|
|
||||||
gif.render();
|
|
||||||
});
|
|
||||||
|
|
||||||
return URL.createObjectURL(blob);
|
|
||||||
}
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions";
|
|
||||||
|
|
||||||
function toDataUrlMaybe(item) {
|
|
||||||
if (!item) return null;
|
|
||||||
if (typeof item === "string") {
|
|
||||||
if (item.startsWith("http://") || item.startsWith("https://") || item.startsWith("data:image/")) return item;
|
|
||||||
return `data:image/png;base64,${item}`;
|
|
||||||
}
|
|
||||||
if (item.url) return item.url;
|
|
||||||
if (item.image_url?.url) return item.image_url.url;
|
|
||||||
if (item.imageUrl?.url) return item.imageUrl.url;
|
|
||||||
if (item.b64_json) return `data:image/png;base64,${item.b64_json}`;
|
|
||||||
if (item.data) return `data:image/png;base64,${item.data}`;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractImageFromResponse(json) {
|
|
||||||
const msg = json?.choices?.[0]?.message;
|
|
||||||
if (!msg) return null;
|
|
||||||
|
|
||||||
// Primary docs format: message.images[].image_url.url
|
|
||||||
if (Array.isArray(msg.images) && msg.images.length) {
|
|
||||||
for (const image of msg.images) {
|
|
||||||
const url = image?.image_url?.url || image?.imageUrl?.url || image?.url;
|
|
||||||
if (url) return url;
|
|
||||||
const maybe = toDataUrlMaybe(image);
|
|
||||||
if (maybe) return maybe;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: content parts
|
|
||||||
if (Array.isArray(msg.content)) {
|
|
||||||
for (const part of msg.content) {
|
|
||||||
if (part?.type === "image_url") {
|
|
||||||
const u = part?.image_url?.url || part?.imageUrl?.url || part?.url;
|
|
||||||
if (u) return u;
|
|
||||||
}
|
|
||||||
if (part?.type === "output_image" && part?.image_url?.url) return part.image_url.url;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extra fallback pools
|
|
||||||
const fallbackPools = [json?.images, json?.data, msg?.data, json?.choices?.[0]?.images];
|
|
||||||
for (const pool of fallbackPools) {
|
|
||||||
if (Array.isArray(pool) && pool.length) {
|
|
||||||
const maybe = toDataUrlMaybe(pool[0]);
|
|
||||||
if (maybe) return maybe;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateImageFrame({
|
|
||||||
apiKey,
|
|
||||||
model,
|
|
||||||
textPrompt,
|
|
||||||
previousFrames = [],
|
|
||||||
imageSize = "1K",
|
|
||||||
aspectRatio = "1:1"
|
|
||||||
}) {
|
|
||||||
// Docs recommend text first, then images in content array
|
|
||||||
const content = [{ type: "text", text: textPrompt }];
|
|
||||||
for (const frame of previousFrames.slice(-2)) {
|
|
||||||
content.push({
|
|
||||||
type: "image_url",
|
|
||||||
image_url: { url: frame }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = {
|
|
||||||
model,
|
|
||||||
modalities: ["image"], // As requested: image only
|
|
||||||
messages: [{ role: "user", content }],
|
|
||||||
image_config: {
|
|
||||||
image_size: imageSize,
|
|
||||||
aspect_ratio: aspectRatio
|
|
||||||
},
|
|
||||||
stream: false
|
|
||||||
};
|
|
||||||
|
|
||||||
const res = await fetch(OPENROUTER_URL, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${apiKey}`,
|
|
||||||
"HTTP-Referer": window.location.origin,
|
|
||||||
"X-Title": "vibegif.lol"
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body)
|
|
||||||
});
|
|
||||||
|
|
||||||
const json = await res.json().catch(() => ({}));
|
|
||||||
if (!res.ok) {
|
|
||||||
const msg = json?.error?.message || `OpenRouter error (${res.status})`;
|
|
||||||
throw new Error(msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
const image = extractImageFromResponse(json);
|
|
||||||
if (!image) throw new Error("No image found in model response.");
|
|
||||||
return image;
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
export const FIRST_PROMPT_TEMPLATE = "minimal black and white line doodle, single stroke, white background, kawaii style, {userPrompt}";
|
|
||||||
|
|
||||||
export function firstPrompt(userPrompt) {
|
|
||||||
return FIRST_PROMPT_TEMPLATE.replace("{userPrompt}", userPrompt);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function nextFramePrompt(frameCount) {
|
|
||||||
return `imagine we are trying to create a ${frameCount} frame gif. generate the next meaningful frame`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clampForm(form) {
|
|
||||||
form.frameCount = Math.min(24, Math.max(2, Number(form.frameCount || 4)));
|
|
||||||
form.fps = Math.min(24, Math.max(1, Number(form.fps || 6)));
|
|
||||||
|
|
||||||
if (form.model !== "google/gemini-3.1-flash-image-preview" && form.imageSize === "0.5K") {
|
|
||||||
form.imageSize = "1K";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user