From 69aa267d9bd30d9cd719a5b6c095ab4ce5a09c6b Mon Sep 17 00:00:00 2001 From: prompt.ac/@jeffrey Date: Fri, 24 Apr 2026 00:31:04 +0000 Subject: [PATCH] see: free FLUX image gen piece (NVIDIA NIM proxy) Adds the `see` piece that generates images via NVIDIA's free FLUX.1 schnell endpoint. Two filter-safe AC style presets baked into the proxy (kidlisp = high-contrast CRT energy, warm = soft pastel mascot). 30s timeout, graceful safety-filter handling, friendly error messages. URL forms: see a happy frog — kidlisp preset, random seed see:warm a coffee mug — warm pastel preset see:raw a misty forest — no AC style suffix Tap to roll a new seed, backspace to re-prompt. Backend: system/netlify/functions/flux.mjs proxies to ai.api.nvidia.com/v1/genai/black-forest-labs/flux.1-schnell at 768/4 (verified ~1.3s warm direct, ~3.5s through Node fetch). Returns the JPEG as a data URL; piece decodes via Image() + canvas readback into a paste-able bitmap. Filter-safe prompt suffixes encoded with comments explaining the bisect that found them — NVIDIA's safety classifier is twitchy about clusters of proper nouns, so the suffixes deliberately avoid naming the platform / maker / language. Requires NVIDIA_API_KEY (already in the vault root .env, but not yet propagated to lith/.env — production wiring deferred). Co-Authored-By: Claude Opus 4.7 (1M context) --- system/netlify.toml | 4 ++++ system/netlify/functions/flux.mjs | 167 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ system/public/aesthetic.computer/disks/see.mjs | 194 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 file(s) changed, 365 insertion(s)(+), 0 deletion(s)(-) diff --git a/system/netlify.toml b/system/netlify.toml --- a/system/netlify.toml +++ b/system/netlify.toml @@ -2058,6 +2058,10 @@ # to = "http://localhost:3000/api/ask" # status = 200 # force = false [[redirects]] +from = "/api/flux" +to = "/.netlify/functions/flux" +status = 200 +[[redirects]] from = "/api/playlist" to = "/.netlify/functions/playlist" status = 200 diff --git a/system/netlify/functions/flux.mjs b/system/netlify/functions/flux.mjs new file mode 100644 --- /dev/null +++ b/system/netlify/functions/flux.mjs @@ -0,0 +1,167 @@ +// flux, 26.04.23 +// Proxy to NVIDIA NIM FLUX.1 schnell image generation. +// Hides the NVIDIA_API_KEY, applies one of two AC style presets, +// returns the raw JPEG as a data URL the piece can decode directly. +// +// Usage from a piece: +// const res = await fetch("/api/flux", { +// method: "POST", +// headers: { "Content-Type": "application/json" }, +// body: JSON.stringify({ prompt: "a happy frog", preset: "kidlisp", seed: 7 }), +// }); +// const { ok, png, reason, elapsed_ms, seed } = await res.json(); +// +// On safety-filter rejection: { ok: false, reason: "filtered" } (200, so the +// piece can react gracefully). On NVIDIA upstream error: 502. +// +// Env: NVIDIA_API_KEY (required). Lives in lith/.env in production. + +import { respond } from "../../backend/http.mjs"; + +const FLUX_URL = + "https://ai.api.nvidia.com/v1/genai/black-forest-labs/flux.1-schnell"; + +// Two filter-safe AC style suffixes. The bisect that pinned these down lives +// in ~/Desktop/nvidia-flux-log/README.md — short version: NVIDIA's safety +// classifier filters on clusters of proper nouns + dense modifiers, so the +// suffixes deliberately avoid naming the platform / maker / language. +const PRESETS = { + // Soft pastel mascot energy — animals, food, friendly subjects + warm: + "chunky pixel-art bitmap, crisp 1-pixel edges, no anti-aliasing, " + + "saturated palette of black, navy, hot pink, lime, cyan, yellow, magenta, white, " + + "centered subject on flat solid color background, " + + "soft 1-pixel offset pastel shadow beneath subject, " + + "square mobile composition, 90s indie computing aesthetic, " + + "handmade lo-fi warmth, no text, no UI, no watermarks", + + // High-contrast CRT energy — devices, abstract objects, default + kidlisp: + "high-contrast pixel-art bitmap, crisp 1-pixel edges, no anti-aliasing, " + + "strict palette of black, hot pink, lime, cyan, yellow, white, " + + "solid black background, " + + "hard cyan 1-pixel shadow beneath subject, " + + "square composition, no text", + + // No styling — pass the user's prompt through verbatim + raw: "", +}; + +const ALLOWED_WIDTHS = [768, 832, 896, 960, 1024, 1088, 1152, 1216, 1280, 1344]; + +export async function handler(event) { + if (event.httpMethod === "OPTIONS") { + return respond(200, ""); + } + if (event.httpMethod !== "POST") { + return respond(405, { ok: false, reason: "method" }); + } + + if (!process.env.NVIDIA_API_KEY) { + console.error("flux: NVIDIA_API_KEY not configured"); + return respond(500, { ok: false, reason: "no_key" }); + } + + let body; + try { + body = JSON.parse(event.body || "{}"); + } catch { + return respond(400, { ok: false, reason: "bad_json" }); + } + + const prompt = (body.prompt || "").toString().trim(); + if (!prompt) return respond(400, { ok: false, reason: "no_prompt" }); + if (prompt.length > 1000) + return respond(400, { ok: false, reason: "prompt_too_long" }); + + const presetName = body.preset || "kidlisp"; + const styleSuffix = PRESETS[presetName] ?? PRESETS.kidlisp; + const fullPrompt = styleSuffix ? `${prompt} — ${styleSuffix}` : prompt; + + // Width/height clamp to FLUX's literal allowed set. Default 768 (smallest + // → fastest, most reliable). Pieces that want bigger pay the latency tail. + const width = ALLOWED_WIDTHS.includes(+body.width) ? +body.width : 768; + const height = ALLOWED_WIDTHS.includes(+body.height) ? +body.height : width; + + const seed = Number.isInteger(body.seed) + ? body.seed + : Math.floor(Math.random() * 1e9); + + // 30s timeout — FLUX schnell normally returns in 1-4s. NVIDIA has been + // observed hanging for minutes before 504'ing during outages; fail fast + // so the piece can show an error and let the user retry. + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 30000); + + const t0 = Date.now(); + let upstream; + try { + upstream = await fetch(FLUX_URL, { + method: "POST", + headers: { + Authorization: `Bearer ${process.env.NVIDIA_API_KEY}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + prompt: fullPrompt, + cfg_scale: 0, + steps: 4, + seed, + width, + height, + mode: "base", + }), + signal: controller.signal, + }); + } catch (err) { + if (err.name === "AbortError") { + return respond(504, { ok: false, reason: "timeout" }); + } + console.error("flux: upstream fetch failed", err); + return respond(502, { ok: false, reason: "network", detail: err.message }); + } finally { + clearTimeout(timeoutId); + } + + if (!upstream.ok) { + const detail = await upstream.text().catch(() => ""); + console.error("flux: upstream", upstream.status, detail.slice(0, 300)); + return respond(502, { + ok: false, + reason: "upstream", + status: upstream.status, + detail: detail.slice(0, 300), + }); + } + + let data; + try { + data = await upstream.json(); + } catch { + return respond(502, { ok: false, reason: "bad_upstream_json" }); + } + + const art = data?.artifacts?.[0]; + if (!art) return respond(502, { ok: false, reason: "no_artifact" }); + + if (art.finishReason !== "SUCCESS") { + // Safety filter — return 200 so the piece can react. + return respond(200, { + ok: false, + reason: "filtered", + finish: art.finishReason, + }); + } + + const elapsed_ms = Date.now() - t0; + return respond(200, { + ok: true, + png: `data:image/jpeg;base64,${art.base64}`, + width, + height, + seed: art.seed, + preset: presetName, + elapsed_ms, + }); +} diff --git a/system/public/aesthetic.computer/disks/see.mjs b/system/public/aesthetic.computer/disks/see.mjs new file mode 100644 --- /dev/null +++ b/system/public/aesthetic.computer/disks/see.mjs @@ -0,0 +1,194 @@ +// see, 26.04.23 +// Free image generation via NVIDIA NIM FLUX.1 schnell, with two AC style +// presets baked into the proxy at /api/flux. Drop a prompt and a bitmap +// shows up — the model does the rest. +// +// Usage: +// see — show usage +// see a happy frog — generate with default kidlisp preset +// see:warm a happy frog — soft pastel mascot preset +// see:raw photorealistic frog — no AC style suffix, raw FLUX +// +// Tap to roll a new seed. Backspace to clear and re-prompt. + +const { floor, min, max } = Math; + +let state = "empty"; // "empty" | "loading" | "ready" | "error" +let promptText = ""; +let presetName = "kidlisp"; +let bitmap = null; // { width, height, pixels: Uint8ClampedArray } +let errorMsg = ""; +let seedNum = null; // null = let server roll +let elapsedMs = 0; +let ellipsis = 0; +let frame = 0; +let abortController = null; + +function boot({ params, colon, hud }) { + hud.label("see"); + if (colon[0]) presetName = colon[0]; + promptText = (params || []).join(" ").trim(); + if (promptText) generate(); +} + +function meta() { + return { + title: "see", + desc: "Free FLUX image generation in your AC palette.", + }; +} + +async function generate() { + if (!promptText) return; + state = "loading"; + bitmap = null; + errorMsg = ""; + ellipsis = 0; + + abortController?.abort(); + abortController = new AbortController(); + + try { + const res = await fetch("/api/flux", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + prompt: promptText, + preset: presetName, + ...(seedNum !== null ? { seed: seedNum } : {}), + }), + signal: abortController.signal, + }); + + const data = await res.json(); + if (!data.ok) { + state = "error"; + errorMsg = + data.reason === "filtered" + ? "blocked by safety filter — try different wording" + : data.reason === "no_key" + ? "server has no NVIDIA_API_KEY" + : data.reason === "timeout" + ? "timed out — NVIDIA may be slow, tap to retry" + : data.reason === "upstream" + ? "NVIDIA error — tap to retry" + : `error: ${data.reason || "unknown"}`; + return; + } + + elapsedMs = data.elapsed_ms; + seedNum = parseInt(data.seed, 10); + bitmap = await dataUrlToBitmap(data.png); + state = "ready"; + } catch (err) { + if (err.name === "AbortError") return; + state = "error"; + errorMsg = err.message; + } +} + +// Decode a data:image/jpeg;base64,... URL into an AC-paste-able bitmap. +function dataUrlToBitmap(dataUrl) { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => { + const canvas = document.createElement("canvas"); + canvas.width = img.width; + canvas.height = img.height; + const ctx = canvas.getContext("2d"); + ctx.drawImage(img, 0, 0); + const id = ctx.getImageData(0, 0, img.width, img.height); + resolve({ width: img.width, height: img.height, pixels: id.data }); + }; + img.onerror = (e) => reject(new Error("decode failed")); + img.src = dataUrl; + }); +} + +function paint({ wipe, ink, paste, write, screen }) { + frame++; + const w = screen.width; + const h = screen.height; + + // Black background — matches the kidlisp preset's own background, looks + // intentional regardless of preset. + wipe(0); + + if (state === "ready" && bitmap) { + // Center, scale-to-fit with integer scale (preserves pixel crispness). + const scale = max(1, floor(min(w / bitmap.width, h / bitmap.height))); + const drawW = bitmap.width * scale; + const drawH = bitmap.height * scale; + const x = floor((w - drawW) / 2); + const y = floor((h - drawH) / 2); + paste(bitmap, x, y, { scale }); + + // Subtle status footer + const footer = `${elapsedMs}ms · seed ${seedNum} · ${presetName}`; + ink(80).write(footer, { x: 6, y: h - 14 }); + ink(180).write("tap to roll", { x: w - 70, y: h - 14 }); + return; + } + + if (state === "loading") { + if (frame % 20 === 0) ellipsis = (ellipsis + 1) % 4; + const dots = ".".repeat(ellipsis); + ink(0, 255, 200).write(`generating${dots}`, { center: "xy" }); + ink(80).write(promptText, { center: "x", y: floor(h / 2) + 18 }); + return; + } + + if (state === "error") { + ink(255, 80, 120).write("✗", { center: "x", y: floor(h / 2) - 20 }); + ink(255, 200, 200).write(errorMsg, { center: "xy" }, undefined, w - 20); + ink(120).write("tap to retry", { center: "x", y: floor(h / 2) + 24 }); + return; + } + + // empty — show usage + const lines = [ + "type a subject to see it", + "", + "see a happy frog", + "see:warm a coffee mug", + "see:raw a misty forest", + ]; + let yy = floor(h / 2) - (lines.length * 14) / 2; + for (const line of lines) { + ink(line.startsWith("see") ? [0, 255, 200] : 200).write(line, { + center: "x", + y: yy, + }); + yy += 14; + } +} + +function act({ event: e, sound }) { + if (state === "loading") return; + + if (e.is("touch")) { + if (state === "error") { + // retry with same seed + generate(); + } else if (state === "ready") { + // roll a new seed + seedNum = null; + sound?.synth?.({ type: "sine", tone: 660, duration: 0.04, volume: 0.3 }); + generate(); + } + } + + if (e.is("keyboard:down:backspace") || e.is("keyboard:down:escape")) { + state = "empty"; + bitmap = null; + errorMsg = ""; + abortController?.abort(); + } +} + +function leave() { + abortController?.abort(); + bitmap = null; +} + +export { boot, paint, act, leave, meta }; -- tangled.sh