From a95181aba5dd5013b5e3ac95ca58bf1955e9379c Mon Sep 17 00:00:00 2001 From: multipleof4 Date: Fri, 20 Mar 2026 20:59:09 -0700 Subject: [PATCH] Feat: OpenRouter image frame generation --- assets/js/openrouter.js | 137 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 assets/js/openrouter.js diff --git a/assets/js/openrouter.js b/assets/js/openrouter.js new file mode 100644 index 0000000..162cf31 --- /dev/null +++ b/assets/js/openrouter.js @@ -0,0 +1,137 @@ +import { NEXT_FRAME_PROMPT_TEMPLATE } from "./config.js"; + +const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"; + +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 buildFramePrompt({ + masterPrompt, + userPrompt, + 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(NEXT_FRAME_PROMPT_TEMPLATE(frameCount)); + 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(promptText, previousFrames = []) { + const parts = [{ type: "text", text: promptText }]; + + for (const frame of previousFrames.slice(-2)) { + parts.push({ + type: "image_url", + image_url: { url: frame }, + }); + } + + return parts; +} + +function extractImageUrl(json) { + const message = json?.choices?.[0]?.message; + const image = message?.images?.[0]; + return image?.image_url?.url || image?.imageUrl?.url || ""; +} + +function extractErrorMessage(text, status, fallbackStatusText) { + try { + const parsed = JSON.parse(text); + return ( + parsed?.error?.message || + parsed?.message || + `Request failed (${status} ${fallbackStatusText})` + ); + } catch { + return text || `Request failed (${status} ${fallbackStatusText})`; + } +} + +export async function requestFrameImage({ + apiKey, + model, + userPrompt, + masterPrompt, + frameCount, + frameIndex, + previousFrames, + imageSize, + aspectRatio, +}) { + const promptText = buildFramePrompt({ + masterPrompt, + userPrompt, + frameCount, + frameIndex, + }); + + const body = { + model, + stream: false, + modalities: ["image"], + messages: [ + { role: "system", content: SYSTEM_PROMPT }, + { + role: "user", + content: buildUserContent(promptText, previousFrames), + }, + ], + image_config: { + image_size: imageSize, + aspect_ratio: aspectRatio, + }, + }; + + const headers = { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }; + + if (typeof window !== "undefined" && window.location?.origin?.startsWith("http")) { + headers["HTTP-Referer"] = window.location.origin; + headers["X-OpenRouter-Title"] = "vibegif.lol"; + } + + const response = await fetch(OPENROUTER_URL, { + method: "POST", + headers, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const raw = await response.text(); + throw new Error( + extractErrorMessage(raw, response.status, response.statusText) + ); + } + + const json = await response.json(); + const imageUrl = extractImageUrl(json); + + if (!imageUrl) { + throw new Error("No image returned by model. Try again with fewer frames or simpler prompt."); + } + + return imageUrl; +}