(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(); }, }; }); }); })();