mirror of
https://github.com/vibegif/vibegif.lol.git
synced 2026-04-07 18:22:13 +00:00
Fix: Non-module global registration for Alpine
This commit is contained in:
325
assets/js/app.js
325
assets/js/app.js
@@ -1,115 +1,269 @@
|
|||||||
import {
|
(function () {
|
||||||
STORAGE_KEYS,
|
"use strict";
|
||||||
MASTER_PROMPT,
|
|
||||||
MODELS,
|
|
||||||
BASE_ASPECT_RATIOS,
|
|
||||||
GEMINI_EXTRA_ASPECT_RATIOS,
|
|
||||||
} from "./config.js";
|
|
||||||
import { requestFrameImage } from "./openrouter.js";
|
|
||||||
import { createGifFromFrames } from "./gif-builder.js";
|
|
||||||
|
|
||||||
const clamp = (n, min, max) => Math.max(min, Math.min(max, Number(n) || min));
|
/* ── 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];
|
const modelById = (id) => MODELS.find((m) => m.id === id) || MODELS[0];
|
||||||
|
|
||||||
function vibeGifApp() {
|
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 {
|
return {
|
||||||
apiKey: "",
|
apiKey: "",
|
||||||
apiKeyInput: "",
|
apiKeyInput: "",
|
||||||
showSettings: false,
|
showSettings: false,
|
||||||
|
|
||||||
model: MODELS[0].id,
|
model: MODELS[0].id,
|
||||||
imageSize: "1K",
|
imageSize: "1K",
|
||||||
aspectRatio: "1:1",
|
aspectRatio: "1:1",
|
||||||
frameCount: 4,
|
frameCount: 4,
|
||||||
fps: 4,
|
fps: 4,
|
||||||
userPrompt: "rolling cat",
|
userPrompt: "rolling cat",
|
||||||
|
|
||||||
masterPrompt: MASTER_PROMPT,
|
masterPrompt: MASTER_PROMPT,
|
||||||
|
|
||||||
frames: [],
|
frames: [],
|
||||||
gifUrl: "",
|
gifUrl: "",
|
||||||
gifBlob: null,
|
gifBlob: null,
|
||||||
|
|
||||||
isGenerating: false,
|
isGenerating: false,
|
||||||
progressText: "",
|
progressText: "",
|
||||||
errorText: "",
|
errorText: "",
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
const saved = localStorage.getItem(STORAGE_KEYS.apiKey) || "";
|
var saved = localStorage.getItem(STORAGE_KEY) || "";
|
||||||
this.apiKey = saved;
|
this.apiKey = saved;
|
||||||
this.apiKeyInput = saved;
|
this.apiKeyInput = saved;
|
||||||
this.showSettings = !saved;
|
if (!saved) this.showSettings = true;
|
||||||
|
|
||||||
this.normalizeSelections();
|
this.normalizeSelections();
|
||||||
this.$watch("model", () => this.normalizeSelections());
|
refreshIcons();
|
||||||
|
|
||||||
this.refreshIcons();
|
|
||||||
},
|
},
|
||||||
|
|
||||||
get modelOptions() {
|
get modelOptions() { return MODELS; },
|
||||||
return MODELS;
|
get selectedModel() { return modelById(this.model); },
|
||||||
},
|
|
||||||
|
|
||||||
get selectedModel() {
|
|
||||||
return modelById(this.model);
|
|
||||||
},
|
|
||||||
|
|
||||||
get imageSizeOptions() {
|
get imageSizeOptions() {
|
||||||
const opts = ["1K"];
|
return this.selectedModel.supportsHalfK ? ["1K", "0.5K"] : ["1K"];
|
||||||
if (this.selectedModel.supportsHalfK) opts.push("0.5K");
|
|
||||||
return opts;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
get aspectRatioOptions() {
|
get aspectRatioOptions() { return ASPECT_RATIOS; },
|
||||||
const opts = [...BASE_ASPECT_RATIOS];
|
get hasApiKey() { return !!this.apiKey; },
|
||||||
if (this.selectedModel.supportsExtendedAspectRatios) {
|
|
||||||
opts.push(...GEMINI_EXTRA_ASPECT_RATIOS);
|
|
||||||
}
|
|
||||||
return opts;
|
|
||||||
},
|
|
||||||
|
|
||||||
get hasApiKey() {
|
|
||||||
return !!this.apiKey;
|
|
||||||
},
|
|
||||||
|
|
||||||
get canGenerate() {
|
get canGenerate() {
|
||||||
return this.hasApiKey && !this.isGenerating && this.userPrompt.trim().length > 0;
|
return this.hasApiKey && !this.isGenerating && this.userPrompt.trim().length > 0;
|
||||||
},
|
},
|
||||||
|
|
||||||
normalizeSelections() {
|
normalizeSelections() {
|
||||||
if (!this.imageSizeOptions.includes(this.imageSize)) {
|
if (this.imageSizeOptions.indexOf(this.imageSize) === -1) {
|
||||||
this.imageSize = this.imageSizeOptions[0];
|
this.imageSize = "1K";
|
||||||
}
|
|
||||||
if (!this.aspectRatioOptions.includes(this.aspectRatio)) {
|
|
||||||
this.aspectRatio = "1:1";
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
openSettings() {
|
openSettings() {
|
||||||
this.apiKeyInput = this.apiKey;
|
this.apiKeyInput = this.apiKey;
|
||||||
this.showSettings = true;
|
this.showSettings = true;
|
||||||
this.refreshIcons();
|
this.$nextTick(refreshIcons);
|
||||||
},
|
},
|
||||||
|
|
||||||
closeSettings() {
|
closeSettings() { this.showSettings = false; },
|
||||||
this.showSettings = false;
|
|
||||||
},
|
|
||||||
|
|
||||||
saveApiKey() {
|
saveApiKey() {
|
||||||
const key = (this.apiKeyInput || "").trim();
|
var key = (this.apiKeyInput || "").trim();
|
||||||
this.apiKey = key;
|
this.apiKey = key;
|
||||||
|
if (key) localStorage.setItem(STORAGE_KEY, key);
|
||||||
if (key) localStorage.setItem(STORAGE_KEYS.apiKey, key);
|
else localStorage.removeItem(STORAGE_KEY);
|
||||||
else localStorage.removeItem(STORAGE_KEYS.apiKey);
|
|
||||||
|
|
||||||
this.showSettings = false;
|
this.showSettings = false;
|
||||||
},
|
},
|
||||||
|
|
||||||
clearApiKey() {
|
clearApiKey() {
|
||||||
this.apiKey = "";
|
this.apiKey = "";
|
||||||
this.apiKeyInput = "";
|
this.apiKeyInput = "";
|
||||||
localStorage.removeItem(STORAGE_KEYS.apiKey);
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
this.showSettings = true;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
resetOutput() {
|
resetOutput() {
|
||||||
@@ -117,7 +271,6 @@ function vibeGifApp() {
|
|||||||
this.errorText = "";
|
this.errorText = "";
|
||||||
this.progressText = "";
|
this.progressText = "";
|
||||||
this.gifBlob = null;
|
this.gifBlob = null;
|
||||||
|
|
||||||
if (this.gifUrl) {
|
if (this.gifUrl) {
|
||||||
URL.revokeObjectURL(this.gifUrl);
|
URL.revokeObjectURL(this.gifUrl);
|
||||||
this.gifUrl = "";
|
this.gifUrl = "";
|
||||||
@@ -130,41 +283,40 @@ function vibeGifApp() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const frameCount = clamp(this.frameCount, 2, 24);
|
var fc = clamp(this.frameCount, 2, 24);
|
||||||
const fps = clamp(this.fps, 1, 24);
|
var fpsVal = clamp(this.fps, 1, 24);
|
||||||
const prompt = this.userPrompt.trim();
|
var prompt = this.userPrompt.trim();
|
||||||
|
this.frameCount = fc;
|
||||||
this.frameCount = frameCount;
|
this.fps = fpsVal;
|
||||||
this.fps = fps;
|
|
||||||
|
|
||||||
this.resetOutput();
|
this.resetOutput();
|
||||||
this.isGenerating = true;
|
this.isGenerating = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (let i = 1; i <= frameCount; i++) {
|
for (var i = 1; i <= fc; i++) {
|
||||||
this.progressText = `Generating frame ${i}/${frameCount}...`;
|
this.progressText = "Generating frame " + i + "/" + fc + "…";
|
||||||
|
|
||||||
const image = await requestFrameImage({
|
var imgUrl = await requestFrame({
|
||||||
apiKey: this.apiKey,
|
apiKey: this.apiKey,
|
||||||
model: this.model,
|
model: this.model,
|
||||||
userPrompt: prompt,
|
userPrompt: prompt,
|
||||||
masterPrompt: this.masterPrompt,
|
masterPrompt: this.masterPrompt,
|
||||||
frameCount,
|
frameCount: fc,
|
||||||
frameIndex: i,
|
frameIndex: i,
|
||||||
previousFrames: this.frames.slice(-2), // rolling window = 2
|
previousFrames: this.frames.slice(-2),
|
||||||
imageSize: this.imageSize,
|
imageSize: this.imageSize,
|
||||||
aspectRatio: this.aspectRatio,
|
aspectRatio: this.aspectRatio,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.frames.push(image);
|
this.frames.push(imgUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.progressText = "Rendering GIF...";
|
this.progressText = "Rendering GIF…";
|
||||||
this.gifBlob = await createGifFromFrames(this.frames, { fps });
|
this.gifBlob = await createGif(this.frames, fpsVal);
|
||||||
this.gifUrl = URL.createObjectURL(this.gifBlob);
|
this.gifUrl = URL.createObjectURL(this.gifBlob);
|
||||||
this.progressText = "Done ✨";
|
this.progressText = "Done ✨";
|
||||||
|
this.$nextTick(refreshIcons);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.errorText = err?.message || "Generation failed.";
|
this.errorText = err.message || "Generation failed.";
|
||||||
} finally {
|
} finally {
|
||||||
this.isGenerating = false;
|
this.isGenerating = false;
|
||||||
}
|
}
|
||||||
@@ -172,27 +324,14 @@ function vibeGifApp() {
|
|||||||
|
|
||||||
downloadGif() {
|
downloadGif() {
|
||||||
if (!this.gifUrl) return;
|
if (!this.gifUrl) return;
|
||||||
|
var a = document.createElement("a");
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = this.gifUrl;
|
a.href = this.gifUrl;
|
||||||
a.download = `vibegif-${Date.now()}.gif`;
|
a.download = "vibegif-" + Date.now() + ".gif";
|
||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
a.remove();
|
a.remove();
|
||||||
},
|
},
|
||||||
|
|
||||||
refreshIcons() {
|
|
||||||
this.$nextTick(() => window.lucide?.createIcons());
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
});
|
||||||
|
});
|
||||||
let registered = false;
|
})();
|
||||||
const register = () => {
|
|
||||||
if (registered) return;
|
|
||||||
registered = true;
|
|
||||||
window.Alpine.data("vibeGifApp", vibeGifApp);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (window.Alpine) register();
|
|
||||||
document.addEventListener("alpine:init", register);
|
|
||||||
|
|||||||
Reference in New Issue
Block a user