From 6956f400baa78d5ca4082e3f207e15e4e8f24973 Mon Sep 17 00:00:00 2001 From: multipleof4 Date: Fri, 20 Mar 2026 21:07:24 -0700 Subject: [PATCH] Fix: Non-module global registration for Alpine --- assets/js/app.js | 493 ++++++++++++++++++++++++++++++----------------- 1 file changed, 316 insertions(+), 177 deletions(-) diff --git a/assets/js/app.js b/assets/js/app.js index ea702bd..faf2afd 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -1,198 +1,337 @@ -import { - STORAGE_KEYS, - MASTER_PROMPT, - MODELS, - BASE_ASPECT_RATIOS, - GEMINI_EXTRA_ASPECT_RATIOS, -} from "./config.js"; -import { requestFrameImage } from "./openrouter.js"; -import { createGifFromFrames } from "./gif-builder.js"; +(function () { + "use strict"; -const clamp = (n, min, max) => Math.max(min, Math.min(max, Number(n) || min)); -const modelById = (id) => MODELS.find((m) => m.id === id) || MODELS[0]; + /* ── Config ── */ + const STORAGE_KEY = "vibegif_openrouter_api_key"; -function vibeGifApp() { - return { - apiKey: "", - apiKeyInput: "", - showSettings: false, + const MASTER_PROMPT = + "minimal black and white line doodle, single stroke, white background, kawaii style"; - 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() { - const saved = localStorage.getItem(STORAGE_KEYS.apiKey) || ""; - this.apiKey = saved; - this.apiKeyInput = saved; - this.showSettings = !saved; - - this.normalizeSelections(); - this.$watch("model", () => this.normalizeSelections()); - - this.refreshIcons(); + const MODELS = [ + { + id: "google/gemini-3.1-flash-image-preview", + label: "google/gemini-3.1-flash-image-preview", + supportsHalfK: true, }, - - get modelOptions() { - return MODELS; + { + id: "bytedance-seed/seedream-4.5", + label: "bytedance-seed/seedream-4.5", + supportsHalfK: false, }, + ]; - get selectedModel() { - return modelById(this.model); - }, + const ASPECT_RATIOS = [ + "1:1", "2:3", "3:2", "3:4", "4:3", + "4:5", "5:4", "9:16", "16:9", "21:9", + ]; - get imageSizeOptions() { - const opts = ["1K"]; - if (this.selectedModel.supportsHalfK) opts.push("0.5K"); - return opts; - }, + const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"; - get aspectRatioOptions() { - const opts = [...BASE_ASPECT_RATIOS]; - if (this.selectedModel.supportsExtendedAspectRatios) { - opts.push(...GEMINI_EXTRA_ASPECT_RATIOS); - } - return opts; - }, + const GIF_WORKER_URL = + "https://cdn.jsdelivr.net/npm/gif.js@0.2.0/dist/gif.worker.js"; - get hasApiKey() { - return !!this.apiKey; - }, + /* ── 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]; - get canGenerate() { - return this.hasApiKey && !this.isGenerating && this.userPrompt.trim().length > 0; - }, + function refreshIcons() { + requestAnimationFrame(() => { + if (window.lucide) window.lucide.createIcons(); + }); + } - normalizeSelections() { - if (!this.imageSizeOptions.includes(this.imageSize)) { - this.imageSize = this.imageSizeOptions[0]; - } - if (!this.aspectRatioOptions.includes(this.aspectRatio)) { - this.aspectRatio = "1:1"; - } - }, + /* ── 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(" "); - openSettings() { - this.apiKeyInput = this.apiKey; - this.showSettings = true; - this.refreshIcons(); - }, + 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"); + } - closeSettings() { - this.showSettings = false; - }, + 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; + } - saveApiKey() { - const key = (this.apiKeyInput || "").trim(); - this.apiKey = key; - - if (key) localStorage.setItem(STORAGE_KEYS.apiKey, key); - else localStorage.removeItem(STORAGE_KEYS.apiKey); - - this.showSettings = false; - }, - - clearApiKey() { - this.apiKey = ""; - this.apiKeyInput = ""; - localStorage.removeItem(STORAGE_KEYS.apiKey); - this.showSettings = true; - }, - - 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; - } - - const frameCount = clamp(this.frameCount, 2, 24); - const fps = clamp(this.fps, 1, 24); - const prompt = this.userPrompt.trim(); - - this.frameCount = frameCount; - this.fps = fps; - - this.resetOutput(); - this.isGenerating = true; - - try { - for (let i = 1; i <= frameCount; i++) { - this.progressText = `Generating frame ${i}/${frameCount}...`; - - const image = await requestFrameImage({ - apiKey: this.apiKey, - model: this.model, - userPrompt: prompt, - masterPrompt: this.masterPrompt, - frameCount, - frameIndex: i, - previousFrames: this.frames.slice(-2), // rolling window = 2 - imageSize: this.imageSize, - aspectRatio: this.aspectRatio, - }); - - this.frames.push(image); + 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; } - - this.progressText = "Rendering GIF..."; - this.gifBlob = await createGifFromFrames(this.frames, { fps }); - this.gifUrl = URL.createObjectURL(this.gifBlob); - this.progressText = "Done ✨"; - } catch (err) { - this.errorText = err?.message || "Generation failed."; - } finally { - this.isGenerating = false; } - }, + } + // 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 ""; + } - downloadGif() { - if (!this.gifUrl) 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, + }, + }; - const a = document.createElement("a"); - a.href = this.gifUrl; - a.download = `vibegif-${Date.now()}.gif`; - document.body.appendChild(a); - a.click(); - a.remove(); - }, + var headers = { + Authorization: "Bearer " + opts.apiKey, + "Content-Type": "application/json", + "HTTP-Referer": window.location.origin || "https://vibegif.lol", + "X-OpenRouter-Title": "vibegif.lol", + }; - refreshIcons() { - this.$nextTick(() => window.lucide?.createIcons()); - }, - }; -} + var res = await fetch(OPENROUTER_URL, { + method: "POST", + headers: headers, + body: JSON.stringify(body), + }); -let registered = false; -const register = () => { - if (registered) return; - registered = true; - window.Alpine.data("vibeGifApp", vibeGifApp); -}; + 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); + } -if (window.Alpine) register(); -document.addEventListener("alpine:init", register); + 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(); + }, + }; + }); + }); +})();