mirror of
https://github.com/vibegif/vibegif.lol.git
synced 2026-04-07 02:12:12 +00:00
Refactor: full vanilla app state + DOM rendering
This commit is contained in:
389
assets/js/app.js
389
assets/js/app.js
@@ -1,146 +1,255 @@
|
|||||||
import { generateImageFrame } from "./openrouter.js";
|
import { generateImageFrame } from "./openrouter.js";
|
||||||
import { buildGifFromFrames } from "./gif.js";
|
import { buildGifFromFrames } from "./gif.js";
|
||||||
import { firstPrompt, nextFramePrompt, clampForm } from "./ui.js";
|
import { firstPrompt, nextFramePrompt } from "./ui.js";
|
||||||
|
|
||||||
const GEMINI_MODEL = "google/gemini-3.1-flash-image-preview";
|
const GEMINI_MODEL = "google/gemini-3.1-flash-image-preview";
|
||||||
|
|
||||||
window.vibeGifApp = function () {
|
const state = {
|
||||||
return {
|
apiKey: localStorage.getItem("openrouter_api_key") || "",
|
||||||
settingsOpen: false,
|
frames: [],
|
||||||
loading: false,
|
gifUrl: "",
|
||||||
error: "",
|
loading: false
|
||||||
apiKeyInput: "",
|
|
||||||
apiKey: "",
|
|
||||||
progressLabel: "",
|
|
||||||
progressPct: 0,
|
|
||||||
frames: [],
|
|
||||||
gifUrl: "",
|
|
||||||
|
|
||||||
form: {
|
|
||||||
model: GEMINI_MODEL,
|
|
||||||
userPrompt: "",
|
|
||||||
frameCount: 4,
|
|
||||||
fps: 6,
|
|
||||||
imageSize: "1K",
|
|
||||||
aspectRatio: "1:1"
|
|
||||||
},
|
|
||||||
|
|
||||||
isGeminiModel(model) {
|
|
||||||
return model === GEMINI_MODEL;
|
|
||||||
},
|
|
||||||
|
|
||||||
refreshIcons() {
|
|
||||||
const L = window.lucide;
|
|
||||||
if (!L?.createIcons) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Newer lucide builds can require icons object
|
|
||||||
L.createIcons({ icons: L.icons });
|
|
||||||
} catch (_) {
|
|
||||||
// Backward compatibility fallback
|
|
||||||
try { L.createIcons(); } catch {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
init() {
|
|
||||||
this.apiKey = localStorage.getItem("openrouter_api_key") || "";
|
|
||||||
this.apiKeyInput = this.apiKey || "";
|
|
||||||
|
|
||||||
this.$nextTick(() => this.refreshIcons());
|
|
||||||
window.addEventListener("load", () => this.refreshIcons());
|
|
||||||
document.addEventListener("alpine:initialized", () => this.refreshIcons());
|
|
||||||
|
|
||||||
this.$watch("settingsOpen", () => {
|
|
||||||
this.$nextTick(() => this.refreshIcons());
|
|
||||||
});
|
|
||||||
|
|
||||||
this.$watch("form.model", (model) => {
|
|
||||||
if (!this.isGeminiModel(model) && this.form.imageSize !== "1K") {
|
|
||||||
this.form.imageSize = "1K";
|
|
||||||
}
|
|
||||||
if (this.isGeminiModel(model) && !["1K", "0.5K"].includes(this.form.imageSize)) {
|
|
||||||
this.form.imageSize = "1K";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
saveApiKey() {
|
|
||||||
this.apiKey = this.apiKeyInput.trim();
|
|
||||||
localStorage.setItem("openrouter_api_key", this.apiKey);
|
|
||||||
this.settingsOpen = false;
|
|
||||||
},
|
|
||||||
|
|
||||||
clearApiKey() {
|
|
||||||
this.apiKey = "";
|
|
||||||
this.apiKeyInput = "";
|
|
||||||
localStorage.removeItem("openrouter_api_key");
|
|
||||||
},
|
|
||||||
|
|
||||||
async generate() {
|
|
||||||
this.error = "";
|
|
||||||
this.gifUrl = "";
|
|
||||||
this.frames = [];
|
|
||||||
this.progressLabel = "";
|
|
||||||
this.progressPct = 0;
|
|
||||||
|
|
||||||
clampForm(this.form);
|
|
||||||
|
|
||||||
if (!this.apiKey) {
|
|
||||||
this.error = "Add your OpenRouter API key first (panel-left icon).";
|
|
||||||
this.settingsOpen = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.form.userPrompt) {
|
|
||||||
this.error = "Please enter a simple prompt (e.g. rolling cat).";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.loading = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const total = this.form.frameCount;
|
|
||||||
|
|
||||||
const p1 = firstPrompt(this.form.userPrompt);
|
|
||||||
this.progressLabel = `Generating frame 1/${total}...`;
|
|
||||||
|
|
||||||
const frame1 = await generateImageFrame({
|
|
||||||
apiKey: this.apiKey,
|
|
||||||
model: this.form.model,
|
|
||||||
textPrompt: p1,
|
|
||||||
previousFrames: [],
|
|
||||||
imageSize: this.form.imageSize,
|
|
||||||
aspectRatio: this.form.aspectRatio
|
|
||||||
});
|
|
||||||
|
|
||||||
this.frames.push(frame1);
|
|
||||||
this.progressPct = Math.round((1 / total) * 100);
|
|
||||||
|
|
||||||
for (let i = 2; i <= total; i++) {
|
|
||||||
this.progressLabel = `Generating frame ${i}/${total}...`;
|
|
||||||
|
|
||||||
const next = await generateImageFrame({
|
|
||||||
apiKey: this.apiKey,
|
|
||||||
model: this.form.model,
|
|
||||||
textPrompt: nextFramePrompt(total),
|
|
||||||
previousFrames: this.frames.slice(-2),
|
|
||||||
imageSize: this.form.imageSize,
|
|
||||||
aspectRatio: this.form.aspectRatio
|
|
||||||
});
|
|
||||||
|
|
||||||
this.frames.push(next);
|
|
||||||
this.progressPct = Math.round((i / total) * 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.progressLabel = "Building GIF...";
|
|
||||||
this.gifUrl = await buildGifFromFrames(this.frames, this.form.fps);
|
|
||||||
this.progressLabel = "Done.";
|
|
||||||
this.progressPct = 100;
|
|
||||||
} catch (e) {
|
|
||||||
this.error = e?.message || "Failed to generate.";
|
|
||||||
} finally {
|
|
||||||
this.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();
|
||||||
|
localStorage.setItem("openrouter_api_key", state.apiKey);
|
||||||
|
closeSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearApiKey() {
|
||||||
|
state.apiKey = "";
|
||||||
|
el.apiKeyInput.value = "";
|
||||||
|
localStorage.removeItem("openrouter_api_key");
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", init);
|
||||||
|
|||||||
Reference in New Issue
Block a user