diff --git a/assets/css/styles.css b/assets/css/styles.css deleted file mode 100644 index b54ab6f..0000000 --- a/assets/css/styles.css +++ /dev/null @@ -1,12 +0,0 @@ -[x-cloak] { display: none !important; } - -@font-face { - font-family: "Stain"; - src: url("https://cdn.jsdelivr.net/gh/multipleof4/stain.otf@master/dist/Stain.otf") format("opentype"); - font-weight: normal; - font-style: normal; -} - -body { - font-family: "Stain", sans-serif; -} diff --git a/assets/js/app.js b/assets/js/app.js deleted file mode 100644 index 067322c..0000000 --- a/assets/js/app.js +++ /dev/null @@ -1,272 +0,0 @@ -import { generateImageFrame } from "./openrouter.js"; -import { buildGifFromFrames } from "./gif.js"; -import { firstPrompt, nextFramePrompt } from "./ui.js"; - -const GEMINI_MODEL = "google/gemini-3.1-flash-image-preview"; - -// Wrap in try-catch to prevent fatal crashes in strict iframes/sandboxes -let savedApiKey = ""; -try { - savedApiKey = localStorage.getItem("openrouter_api_key") || ""; -} catch (err) { - console.warn("localStorage is blocked in this environment."); -} - -const state = { - apiKey: savedApiKey, - frames: [], - gifUrl: "", - 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(); - try { - localStorage.setItem("openrouter_api_key", state.apiKey); - } catch (err) { - console.warn("Could not save to localStorage."); - } - closeSettings(); -} - -function clearApiKey() { - state.apiKey = ""; - el.apiKeyInput.value = ""; - try { - localStorage.removeItem("openrouter_api_key"); - } catch (err) { - console.warn("Could not modify localStorage."); - } -} - -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(); -} - -// Since module scripts are natively deferred, the DOM is guaranteed to be fully parsed. -init(); diff --git a/assets/js/gif.js b/assets/js/gif.js deleted file mode 100644 index 2510a12..0000000 --- a/assets/js/gif.js +++ /dev/null @@ -1,33 +0,0 @@ -function loadImage(src) { - return new Promise((resolve, reject) => { - const img = new Image(); - img.crossOrigin = "anonymous"; - img.onload = () => resolve(img); - img.onerror = reject; - img.src = src; - }); -} - -export async function buildGifFromFrames(frames, fps = 6) { - if (!frames?.length) throw new Error("No frames to build GIF."); - - const delay = Math.max(40, Math.floor(1000 / Math.max(1, fps))); - const images = await Promise.all(frames.map(loadImage)); - - const gif = new GIF({ - workers: 2, - quality: 10, - workerScript: "https://cdn.jsdelivr.net/npm/gif.js.optimized/dist/gif.worker.js", - width: images[0].naturalWidth, - height: images[0].naturalHeight - }); - - for (const img of images) gif.addFrame(img, { delay }); - - const blob = await new Promise((resolve) => { - gif.on("finished", resolve); - gif.render(); - }); - - return URL.createObjectURL(blob); -} diff --git a/assets/js/openrouter.js b/assets/js/openrouter.js deleted file mode 100644 index 9b419d4..0000000 --- a/assets/js/openrouter.js +++ /dev/null @@ -1,102 +0,0 @@ -const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"; - -function toDataUrlMaybe(item) { - if (!item) return null; - if (typeof item === "string") { - if (item.startsWith("http://") || item.startsWith("https://") || item.startsWith("data:image/")) return item; - return `data:image/png;base64,${item}`; - } - if (item.url) return item.url; - if (item.image_url?.url) return item.image_url.url; - if (item.imageUrl?.url) return item.imageUrl.url; - if (item.b64_json) return `data:image/png;base64,${item.b64_json}`; - if (item.data) return `data:image/png;base64,${item.data}`; - return null; -} - -function extractImageFromResponse(json) { - const msg = json?.choices?.[0]?.message; - if (!msg) return null; - - // Primary docs format: message.images[].image_url.url - if (Array.isArray(msg.images) && msg.images.length) { - for (const image of msg.images) { - const url = image?.image_url?.url || image?.imageUrl?.url || image?.url; - if (url) return url; - const maybe = toDataUrlMaybe(image); - if (maybe) return maybe; - } - } - - // Fallback: content parts - if (Array.isArray(msg.content)) { - for (const part of msg.content) { - if (part?.type === "image_url") { - const u = part?.image_url?.url || part?.imageUrl?.url || part?.url; - if (u) return u; - } - if (part?.type === "output_image" && part?.image_url?.url) return part.image_url.url; - } - } - - // Extra fallback pools - const fallbackPools = [json?.images, json?.data, msg?.data, json?.choices?.[0]?.images]; - for (const pool of fallbackPools) { - if (Array.isArray(pool) && pool.length) { - const maybe = toDataUrlMaybe(pool[0]); - if (maybe) return maybe; - } - } - - return null; -} - -export async function generateImageFrame({ - apiKey, - model, - textPrompt, - previousFrames = [], - imageSize = "1K", - aspectRatio = "1:1" -}) { - // Docs recommend text first, then images in content array - const content = [{ type: "text", text: textPrompt }]; - for (const frame of previousFrames.slice(-2)) { - content.push({ - type: "image_url", - image_url: { url: frame } - }); - } - - const body = { - model, - modalities: ["image"], // As requested: image only - messages: [{ role: "user", content }], - image_config: { - image_size: imageSize, - aspect_ratio: aspectRatio - }, - stream: false - }; - - const res = await fetch(OPENROUTER_URL, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, - "HTTP-Referer": window.location.origin, - "X-Title": "vibegif.lol" - }, - body: JSON.stringify(body) - }); - - const json = await res.json().catch(() => ({})); - if (!res.ok) { - const msg = json?.error?.message || `OpenRouter error (${res.status})`; - throw new Error(msg); - } - - const image = extractImageFromResponse(json); - if (!image) throw new Error("No image found in model response."); - return image; -} diff --git a/assets/js/ui.js b/assets/js/ui.js deleted file mode 100644 index f67050c..0000000 --- a/assets/js/ui.js +++ /dev/null @@ -1,18 +0,0 @@ -export const FIRST_PROMPT_TEMPLATE = "minimal black and white line doodle, single stroke, white background, kawaii style, {userPrompt}"; - -export function firstPrompt(userPrompt) { - return FIRST_PROMPT_TEMPLATE.replace("{userPrompt}", userPrompt); -} - -export function nextFramePrompt(frameCount) { - return `imagine we are trying to create a ${frameCount} frame gif. generate the next meaningful frame`; -} - -export function clampForm(form) { - form.frameCount = Math.min(24, Math.max(2, Number(form.frameCount || 4))); - form.fps = Math.min(24, Math.max(1, Number(form.fps || 6))); - - if (form.model !== "google/gemini-3.1-flash-image-preview" && form.imageSize === "0.5K") { - form.imageSize = "1K"; - } -}