Files
vibegif.lol/assets/js/app.js

338 lines
10 KiB
JavaScript

(function () {
"use strict";
/* ── Config ── */
const STORAGE_KEY = "vibegif_openrouter_api_key";
const MASTER_PROMPT =
"minimal black and white line doodle, single stroke, white background, kawaii style";
const MODELS = [
{
id: "google/gemini-3.1-flash-image-preview",
label: "google/gemini-3.1-flash-image-preview",
supportsHalfK: true,
},
{
id: "bytedance-seed/seedream-4.5",
label: "bytedance-seed/seedream-4.5",
supportsHalfK: false,
},
];
const ASPECT_RATIOS = [
"1:1", "2:3", "3:2", "3:4", "4:3",
"4:5", "5:4", "9:16", "16:9", "21:9",
];
const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions";
const GIF_WORKER_URL =
"https://cdn.jsdelivr.net/npm/gif.js@0.2.0/dist/gif.worker.js";
/* ── Helpers ── */
const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, Number(n) || lo));
const modelById = (id) => MODELS.find((m) => m.id === id) || MODELS[0];
function refreshIcons() {
requestAnimationFrame(() => {
if (window.lucide) window.lucide.createIcons();
});
}
/* ── OpenRouter ── */
const SYSTEM_PROMPT = [
"You generate a single image per request for an animation pipeline.",
"Keep style consistency across frames.",
"No text overlays, no labels, no typography.",
"Output only the image.",
].join(" ");
function buildPromptText(userPrompt, masterPrompt, frameCount, frameIndex) {
const lines = [
"Create frame " + frameIndex + " of " + frameCount + " for an animated GIF.",
"Concept: " + userPrompt + ".",
"Master style lock: " + masterPrompt + ".",
"Use a white background and clean black line doodle style.",
"Keep the subject identity and scene composition coherent between frames.",
];
if (frameIndex === 1) {
lines.push("This is the first frame. Establish a clear starting pose.");
} else {
lines.push(
"Imagine we are trying to create a " + frameCount +
" frame gif. Generate the next meaningful frame."
);
lines.push(
"This is frame " + frameIndex +
". Advance the motion naturally from the prior frame references."
);
}
lines.push("Return one meaningful image only.");
return lines.join("\n");
}
function buildUserContent(text, prevFrames) {
var parts = [{ type: "text", text: text }];
var tail = prevFrames.slice(-2);
for (var i = 0; i < tail.length; i++) {
parts.push({
type: "image_url",
image_url: { url: tail[i] },
});
}
return parts;
}
function extractImageUrl(json) {
var msg = json && json.choices && json.choices[0] && json.choices[0].message;
if (!msg) return "";
// OpenRouter image gen returns base64 URL inside content array
if (Array.isArray(msg.content)) {
for (var i = 0; i < msg.content.length; i++) {
var part = msg.content[i];
if (part.type === "image_url") {
var url = part.image_url && (part.image_url.url || part.image_url);
if (url) return url;
}
}
}
// fallback: images array
if (Array.isArray(msg.images) && msg.images.length) {
var img = msg.images[0];
return img.image_url?.url || img.imageUrl?.url || img.url || "";
}
return "";
}
async function requestFrame(opts) {
var body = {
model: opts.model,
stream: false,
modalities: ["image"],
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{
role: "user",
content: buildUserContent(
buildPromptText(opts.userPrompt, opts.masterPrompt, opts.frameCount, opts.frameIndex),
opts.previousFrames
),
},
],
image_config: {
image_size: opts.imageSize,
aspect_ratio: opts.aspectRatio,
},
};
var headers = {
Authorization: "Bearer " + opts.apiKey,
"Content-Type": "application/json",
"HTTP-Referer": window.location.origin || "https://vibegif.lol",
"X-OpenRouter-Title": "vibegif.lol",
};
var res = await fetch(OPENROUTER_URL, {
method: "POST",
headers: headers,
body: JSON.stringify(body),
});
if (!res.ok) {
var raw = await res.text();
var errMsg;
try {
var parsed = JSON.parse(raw);
errMsg = (parsed.error && parsed.error.message) || parsed.message || raw;
} catch (_) {
errMsg = raw || "Request failed (" + res.status + ")";
}
throw new Error(errMsg);
}
var json = await res.json();
var url = extractImageUrl(json);
if (!url) throw new Error("No image returned. Try a simpler prompt or fewer frames.");
return url;
}
/* ── GIF Builder ── */
function loadImage(src) {
return new Promise(function (resolve, reject) {
var img = new Image();
img.crossOrigin = "anonymous";
img.onload = function () { resolve(img); };
img.onerror = function () { reject(new Error("Failed to load frame image.")); };
img.src = src;
});
}
async function createGif(frameUrls, fps) {
if (!frameUrls.length) throw new Error("No frames to encode.");
if (typeof window.GIF !== "function") throw new Error("GIF encoder not loaded.");
var images = await Promise.all(frameUrls.map(loadImage));
var first = images[0];
var delay = Math.max(20, Math.round(1000 / clamp(fps, 1, 24)));
return new Promise(function (resolve, reject) {
var gif = new window.GIF({
workers: 2,
quality: 10,
repeat: 0,
width: first.naturalWidth || first.width,
height: first.naturalHeight || first.height,
workerScript: GIF_WORKER_URL,
});
for (var i = 0; i < images.length; i++) {
gif.addFrame(images[i], { delay: delay });
}
gif.on("finished", function (blob) { resolve(blob); });
gif.on("abort", function () { reject(new Error("GIF rendering aborted.")); });
try { gif.render(); } catch (e) { reject(e); }
});
}
/* ── Alpine Component ── */
document.addEventListener("alpine:init", function () {
Alpine.data("vibeGifApp", function () {
return {
apiKey: "",
apiKeyInput: "",
showSettings: false,
model: MODELS[0].id,
imageSize: "1K",
aspectRatio: "1:1",
frameCount: 4,
fps: 4,
userPrompt: "rolling cat",
masterPrompt: MASTER_PROMPT,
frames: [],
gifUrl: "",
gifBlob: null,
isGenerating: false,
progressText: "",
errorText: "",
init() {
var saved = localStorage.getItem(STORAGE_KEY) || "";
this.apiKey = saved;
this.apiKeyInput = saved;
if (!saved) this.showSettings = true;
this.normalizeSelections();
refreshIcons();
},
get modelOptions() { return MODELS; },
get selectedModel() { return modelById(this.model); },
get imageSizeOptions() {
return this.selectedModel.supportsHalfK ? ["1K", "0.5K"] : ["1K"];
},
get aspectRatioOptions() { return ASPECT_RATIOS; },
get hasApiKey() { return !!this.apiKey; },
get canGenerate() {
return this.hasApiKey && !this.isGenerating && this.userPrompt.trim().length > 0;
},
normalizeSelections() {
if (this.imageSizeOptions.indexOf(this.imageSize) === -1) {
this.imageSize = "1K";
}
},
openSettings() {
this.apiKeyInput = this.apiKey;
this.showSettings = true;
this.$nextTick(refreshIcons);
},
closeSettings() { this.showSettings = false; },
saveApiKey() {
var key = (this.apiKeyInput || "").trim();
this.apiKey = key;
if (key) localStorage.setItem(STORAGE_KEY, key);
else localStorage.removeItem(STORAGE_KEY);
this.showSettings = false;
},
clearApiKey() {
this.apiKey = "";
this.apiKeyInput = "";
localStorage.removeItem(STORAGE_KEY);
},
resetOutput() {
this.frames = [];
this.errorText = "";
this.progressText = "";
this.gifBlob = null;
if (this.gifUrl) {
URL.revokeObjectURL(this.gifUrl);
this.gifUrl = "";
}
},
async generateGif() {
if (!this.canGenerate) {
if (!this.hasApiKey) this.openSettings();
return;
}
var fc = clamp(this.frameCount, 2, 24);
var fpsVal = clamp(this.fps, 1, 24);
var prompt = this.userPrompt.trim();
this.frameCount = fc;
this.fps = fpsVal;
this.resetOutput();
this.isGenerating = true;
try {
for (var i = 1; i <= fc; i++) {
this.progressText = "Generating frame " + i + "/" + fc + "…";
var imgUrl = await requestFrame({
apiKey: this.apiKey,
model: this.model,
userPrompt: prompt,
masterPrompt: this.masterPrompt,
frameCount: fc,
frameIndex: i,
previousFrames: this.frames.slice(-2),
imageSize: this.imageSize,
aspectRatio: this.aspectRatio,
});
this.frames.push(imgUrl);
}
this.progressText = "Rendering GIF…";
this.gifBlob = await createGif(this.frames, fpsVal);
this.gifUrl = URL.createObjectURL(this.gifBlob);
this.progressText = "Done ✨";
this.$nextTick(refreshIcons);
} catch (err) {
this.errorText = err.message || "Generation failed.";
} finally {
this.isGenerating = false;
}
},
downloadGif() {
if (!this.gifUrl) return;
var a = document.createElement("a");
a.href = this.gifUrl;
a.download = "vibegif-" + Date.now() + ".gif";
document.body.appendChild(a);
a.click();
a.remove();
},
};
});
});
})();