diff --git a/backend/app/main.py b/backend/app/main.py index f93d143..ff43765 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -3,9 +3,9 @@ import json from datetime import datetime, timezone import httpx -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException, Query from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import StreamingResponse +from fastapi.responses import Response, StreamingResponse from pydantic import BaseModel from .analysis import classify_posts, derive_factions @@ -52,6 +52,23 @@ async def list_models(): return {"models": ids, "default": DEFAULT_MODEL} +@app.get("/api/avatar") +async def avatar(url: str = Query(...)): + """Same-origin avatar proxy: cdn.bsky.app sends no CORS headers, which + blocks WebGL texture loads in the graph.""" + if not url.startswith("https://cdn.bsky.app/"): + raise HTTPException(400, "only cdn.bsky.app URLs are proxied") + async with httpx.AsyncClient(timeout=30) as client: + r = await client.get(url) + if r.status_code != 200: + raise HTTPException(r.status_code, "upstream error") + return Response( + content=r.content, + media_type=r.headers.get("content-type", "image/jpeg"), + headers={"Cache-Control": "public, max-age=86400"}, + ) + + @app.post("/api/analyze") async def analyze(req: AnalyzeRequest): queue: asyncio.Queue = asyncio.Queue() diff --git a/flake.nix b/flake.nix index 76618b4..6034810 100644 --- a/flake.nix +++ b/flake.nix @@ -22,6 +22,11 @@ UV_PYTHON_DOWNLOADS = "never"; }; }; + # Headless UI screenshots (frontend/scripts/ui-shots.mjs): chromium's + # SwiftShader provides software WebGL for sigma. + ui-test = pkgs.mkShell { + packages = with pkgs; [ nodejs_22 chromium ]; + }; }); }; } diff --git a/frontend/scripts/ui-shots.mjs b/frontend/scripts/ui-shots.mjs index de359b2..127a856 100644 --- a/frontend/scripts/ui-shots.mjs +++ b/frontend/scripts/ui-shots.mjs @@ -12,10 +12,15 @@ const OUT = "/tmp/fray-shots"; import { mkdirSync } from "node:fs"; mkdirSync(OUT, { recursive: true }); +// Run inside `nix develop ..#ui-test` so chromium is on PATH; SwiftShader +// provides software WebGL so sigma renders headlessly. +import { execSync } from "node:child_process"; +const CHROMIUM = + process.env.FRAY_CHROMIUM ?? execSync("which chromium").toString().trim(); const browser = await puppeteer.launch({ - browser: "firefox", - executablePath: "/run/current-system/sw/bin/firefox", + executablePath: CHROMIUM, headless: true, + args: ["--no-sandbox", "--disable-gpu", "--enable-unsafe-swiftshader", "--use-gl=angle", "--use-angle=swiftshader"], defaultViewport: { width: 1440, height: 900 }, }); const page = await browser.newPage(); @@ -57,11 +62,16 @@ try { await sleep(800); await page.screenshot({ path: `${OUT}/4-radial-dark.png` }); - // Click the root node (center of the graph container) + // Click a node: project graph coordinates of a mid-thread node via sigma. const rect = await page.evaluate(() => { - const el = document.querySelector(".graph-container"); - const r = el.getBoundingClientRect(); - return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; + const sigma = window.__fraySigma; + const graph = sigma.getGraph(); + const nodes = graph.nodes(); + const node = nodes[Math.floor(nodes.length / 3)]; + const attrs = graph.getNodeAttributes(node); + const p = sigma.graphToViewport({ x: attrs.x, y: attrs.y }); + const r = sigma.getContainer().getBoundingClientRect(); + return { x: r.x + p.x, y: r.y + p.y }; }); await page.mouse.click(rect.x, rect.y); await sleep(1000); diff --git a/frontend/src/GraphView.tsx b/frontend/src/GraphView.tsx index 7d18989..a9f4b77 100644 --- a/frontend/src/GraphView.tsx +++ b/frontend/src/GraphView.tsx @@ -1,4 +1,3 @@ -import { createNodeImageProgram } from "@sigma/node-image"; import type Graph from "graphology"; import { useEffect, useMemo, useRef } from "react"; import Sigma from "sigma"; @@ -71,14 +70,23 @@ export default function GraphView(props: Props) { const stateRef = useRef(null as any); stateRef.current = { mode, path, hiddenFactions, highlightFaction, factionsById, selectedUri }; + const layoutRef = useRef(layout); + layoutRef.current = layout; useEffect(() => { - const graph = buildGraph(result); - graphRef.current = graph; + let disposed = false; + // Dynamic import: @sigma/node-image probes WebGL at module scope, which + // would crash the whole app at load in browsers without WebGL. + void (async () => { + const { createNodeImageProgram } = await import("@sigma/node-image"); + if (disposed || !containerRef.current) return; + const graph = buildGraph(result); + graphRef.current = graph; + applyLayout(graph, layoutRef.current, false); - const sigma = new Sigma(graph, containerRef.current!, { + const sigma = new Sigma(graph, containerRef.current, { nodeProgramClasses: { - image: createNodeImageProgram({ drawingMode: "background", padding: 0.15 }), + image: createNodeImageProgram({ drawingMode: "background", padding: 0.22 }), }, defaultNodeType: "image", labelSize: 11, @@ -133,21 +141,26 @@ export default function GraphView(props: Props) { } return res; }, - }); - sigma.on("clickNode", ({ node }) => onSelectRef.current(node)); - sigma.on("clickStage", () => onSelectRef.current(null)); - sigmaRef.current = sigma; + }); + sigma.on("clickNode", ({ node }) => onSelectRef.current(node)); + sigma.on("clickStage", () => onSelectRef.current(null)); + sigma.setSetting("labelColor", { color: CHROME[stateRef.current.mode].inkPrimary }); + sigma.setSetting("defaultDrawNodeHover", makeHoverDrawer(stateRef.current.mode) as any); + sigmaRef.current = sigma; + (window as any).__fraySigma = sigma; // headless UI-test hook + })(); return () => { - sigma.kill(); + disposed = true; + sigmaRef.current?.kill(); sigmaRef.current = null; graphRef.current = null; }; }, [result]); - // Layout switching (also positions the freshly built graph). + // Layout switching. useEffect(() => { if (graphRef.current) applyLayout(graphRef.current, layout); - }, [layout, result]); + }, [layout]); // Theme-dependent sigma settings. useEffect(() => { diff --git a/frontend/src/graph.ts b/frontend/src/graph.ts index 389ed64..3f08b98 100644 --- a/frontend/src/graph.ts +++ b/frontend/src/graph.ts @@ -11,7 +11,8 @@ export function buildGraph(result: AnalyzeResult): Graph { label: "@" + p.author.handle, faction: p.faction, size: p.parent_uri === null ? 18 : Math.min(13, 5 + 1.6 * Math.log2(1 + p.likeCount)), - image: p.author.avatar ?? undefined, + // Proxied through the backend: WebGL textures need CORS/same-origin. + image: p.author.avatar ? `/api/avatar?url=${encodeURIComponent(p.author.avatar)}` : undefined, type: "image", x: 0, y: 0,