Add renderStreet endpoint: managed-street JSON β labeled beauty-shot image - #1860
Add renderStreet endpoint: managed-street JSON β labeled beauty-shot image#1860kfarr wants to merge 11 commits into
Conversation
β¦hot image Public HTTP Cloud Function (POST/GET, /render-street hosting rewrite) that renders a managed-street JSON blob into a pseudo-orthographic beauty shot β perspective camera at 45Β° azimuth with a narrow FOV, cross-section label bar, environment lighting β using the same A-Frame component stack the app uses. Aimed at LLM/skill/MCP callers as a zero-friction on-ramp: every response carries an #managed-street-json: editor deep link so a generated image funnels users into 3dstreet.app for 3D maps, AI rendering, and editing. - render.html + src/render/ (new `render` webpack entry, street-render.js): lean editor-free bundle; street-render-harness scene component handles payload input, model-load quiescence readiness (THREE DefaultLoadingManager busy-tracking + event quiet window), bounding-box corner-fit camera framing with auto side selection (shoot from the side with the lower boundary so buildings backdrop rather than occlude), and a capture API with title/branding annotations. - public/functions/render-street.js: v2 onRequest, puppeteer-core + @sparticuz/chromium (SwiftShader WebGL), input validation and option clamping, PNG/JPEG bytes or format=json with dataURL + openInEditorUrl. - docs/street-render-endpoint.md: API contract, JSON format summary, architecture, deploy notes. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01WKpg1pMhRpJB3AxmantgV3
Tuning from side-by-side comparison against NACTO street illustrations: - Camera: default azimuth 45 -> 20 (shallower, more NACTO-like); new screen-space refit after the analytic corner-fit β project bbox corners through a probe camera, pan to center the on-screen bounds and rescale distance to fill the frame, fixing the off-center street + excess sky at square aspects. - Default street length 27m (was managed-street's 60m) when the payload omits length; the fit camera makes shorter streets fill the frame. - street-label restyle (shared with editor): white cells with a segment-colored accent band on the street-surface edge (segment color, falling back to per-surface swatches), bold width over lighter name, hairline separators; flat unlit material with shadows off so scene lighting can't tint it and vehicles can't shadow it. Label bar now tucked flush against the street's start face instead of floating 4m out. - street-environment (shared): shadow camera box was a lopsided ~70x50m around the origin, leaving everything beyond it unshadowed; now a symmetric 90x90m at the same 2048 map (no extra GPU memory, ~4.4cm texels). Render harness aligns streets length: middle so they sit inside the origin-centered box β a start-aligned 60m street exited the shadow volume halfway. Co-Authored-By: Claude Fable 5 <[email protected]>
concurrency 2 x maxInstances 10 allowed 20 concurrent chromium renders: ~$1.4k/mo compute if saturated 24/7 (10 busy instances x 2vCPU+2GiB at Cloud Run rates) plus ~$120 egress. Two instances bound that at ~$280/mo while still serving 4 concurrent renders; excess traffic gets 429s. Co-Authored-By: Claude Fable 5 <[email protected]>
Successful renders are cached in the default bucket under renders/v1/<hash>.<png|jpg> (hash = truncated sha256 of the sorted-key canonicalized street JSON + sanitized options) with a <hash>.json input sidecar, and the response now carries a durable URL β imageUrl in format=json, X-3DStreet-Image-Url on byte responses β that LLM callers can embed in artifacts. The URL is a proxy contract: hosting rewrite /render/img/** β new lightweight serveRenderImage function (256MiB, concurrency 80) β Storage, served with immutable year-long cache headers so the hosting CDN absorbs repeat traffic; the storage backend can move later without breaking published links. Cache writes are best-effort (imageUrl: null on failure, render still succeeds). The v1 path segment allows clean invalidation when the renderer's look changes; share page + eviction/re-render-on-miss tracked in #1864. Co-Authored-By: Claude Fable 5 <[email protected]>
|
Added in 8e2e94f: stable image URLs. Every successful render is cached at Not covered by local testing (no storage emulator in this repo's emulator config), so the staging smoke test should verify: |
Manual QA checklist
|
β¦rop autoSide - Serve identical repeat requests from the render cache before spinning up a puppeteer render; sidecar now stores meta so cache hits answer ?format=json completely - Single-flight browser relaunch: close the dead Chromium and reuse a concurrent request's fresh launch instead of orphaning a second one - Omit X-3DStreet-Editor-Url when the deep link exceeds 8KB (hosting caps response headers); shared sendRenderResponse for hit/miss paths - GET query params now win over options inside the ?data payload, and the flat merge only applies to GET - Remove autoSide camera flip (v1 overkill; also fixes explicit azimuth=20 being treated as unset) - Docs + CLAUDE.md updated (serveRenderImage, header/GET behavior)
β¦kill v1 "caller surface" for the renderStreet endpoint (#1864 Β§2) β a thin instructions + schema layer over the existing public HTTP endpoint. No MCP server, no new Cloud Function, no auth. - public/openapi/render-street.yaml: OpenAPI 3.1 spec for /render-street. Single servers entry (https://3dstreet.app), POST (renderStreet) and GET (renderStreetLink) operations, reuses the managed-street Segment schema from nonCommandTools.js. Response modeled as ?format=json returning only imageUrl + openInEditorUrl + meta (no base64 image field, which blows the GPT Action response-size limit). Imports cleanly as a GPT Action. - public/firebase.json: serve /openapi/** with application/yaml content-type + CORS so the GPT builder can import the spec by URL (static-file precedence already serves it at /openapi/render-street.yaml). - docs/integrations/chatgpt-gpt.md: full custom-GPT setup package β name, description, system instructions, conversation starters, Action config, a worked suburban-arterial example, and a pre-publish note (privacy-policy URL, cold-start latency mitigation). - .claude/skills/street-cross-section/SKILL.md: Claude Code Agent Skill teaching the same flow via curl, with the embedded segment schema and worked example. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01LaYFYQ18Z4URsNmgbyM2fZ
CI builds the PR merge commit (branch + main). main's commit 2d19b95 ("Geospatial map opacity + 3d-tiles-renderer 0.5.0 upgrade") deleted src/aframe-components/blending-opacity.js and removed its require from src/index.js. This branch's render entry (src/render/index.js) still required that component, so the merge build failed with "Can't resolve '../aframe-components/blending-opacity.js'". blending-opacity is a Google 3D Tiles opacity component used only by street-geo.js, which the render bundle never loads β so the render entry never needed it. Drop the require. Also set minInstances: 1 on renderStreet to keep one instance warm and skip the 30-90s cold start (@sparticuz/chromium unpack + first-render asset downloads) that GPT Actions / agent callers time out on. Idle min-instances bill CPU at ~10% of the active rate, so the warm floor is ~$25-30/mo (2vCPU+2GiB) versus the ~$139/mo busy-instance rate; maxInstances 2 still caps worst-case cost. Docs updated to match. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01LaYFYQ18Z4URsNmgbyM2fZ
Mirror the AI generators' Discord showcase for the render endpoint: a successful fresh render posts an embed (rendered image, street name/stats, and the "Open in editor" deep link) to the shared DISCORD_WEBHOOK_URL webhook. - Anonymous by design (the endpoint has no auth/user); footer "3DStreet Render API", teal accent to distinguish from the generators' purple AI posts. - Only fresh renders with a stable imageUrl post β cache hits already posted once, and a base64 data URL can't embed β so one post per new render. - Awaited before the response (v2/Cloud Run throttles CPU after the response is sent, so fire-and-forget would stall) but internally bounded by a 4s AbortController, so a slow/down Discord never fails or noticeably delays a render (which already took 30-90s). - The editor deep link embeds the whole street JSON and can be many KB; Discord rejects over-long embed URLs, so it's used as the clickable link only when short, else we fall back to the always-short imageUrl. Adds the DISCORD_WEBHOOK_URL secret to the function config. Docs updated. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01LaYFYQ18Z4URsNmgbyM2fZ
β¦y flags) minInstances: 1 was hardcoded, so every environment reserved a warm 2GiB instance (~$21/mo per Firebase's estimate) β wasteful on dev/staging that nobody hits. Gate it on the target project instead: warm only in prod (dstreet-305604), 0 everywhere else. Resolution uses GCLOUD_PROJECT, which is set at both deploy discovery and runtime, so a plain `firebase deploy --only functions` bakes the right value per project with no per-deploy flags β the same staging-vs-prod pattern rad-dispatch.js already uses. Same mechanism also fixes RENDER_PAGE_URL / EDITOR_BASE_URL: they now default per project (prod -> 3dstreet.app, else -> dev-3dstreet.web.app) instead of always pointing at prod. This corrects a latent dev bug where the dev function rendered via the prod page and returned prod image/editor URLs. Explicit env vars still override (e.g. local dev). Docs updated. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01LaYFYQ18Z4URsNmgbyM2fZ
The /render-street hosting rewrite is capped at Firebase Hosting's 60s limit, so a cold render (30-90s) returns a 502 even though the function completes and caches server-side. Document the mitigations (prod warm instance + content cache; use the direct function URL for cold/one-off renders, notably on dev/staging where minInstances is 0) and note GPT Actions' own ~45s ceiling. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01LaYFYQ18Z4URsNmgbyM2fZ
Decision: render natively in Blender; the browser-free path is managed-street JSON β GLB β Blender β PNGTL;DR: keeping a browser in the render path costs 23β59s/render, so we're We are deliberately keeping this managed-street-only for now. A more Why the browser is outThe current
The killer is rasterization on SwiftShader: the harness does ~3 settle passes at Why native, and why BlenderThe dependency isn't "a browser," it's A-Frame + WebGL. THREE.js runs
The architecture (managed-street only; no IR)What lives where:
Next-steps prompt (paste into a fresh session)Build the browser-free street renderer for 3DStreet. Repo: /Users/kieranfarr/dev/ GOAL: a DOM-free Node function that takes managed-street JSON and returns a GLB, WORKING AGREEMENT (read first):
THE PIPELINE: TASK 1 β the assembler (this is the actual raw function; do this first):
TASK 2 β Blender render: VALIDATE with two golden streets end to end (JSON -> GLB -> PNG):
WIRE-UP (after the pipeline works): KEY FILES:
NOTES:
|
Summary
Public HTTP endpoint (
renderStreetCloud Function +/render-streethosting rewrite) that turns a managed-street JSON blob into a labeled, angled pseudo-orthographic "beauty shot" PNG, aimed at LLM/skill/MCP callers as a zero-friction on-ramp: every response carries an#managed-street-json:editor deep link. Full API contract and deploy notes indocs/street-render-endpoint.md.Commit 1 adds the endpoint:
render.html+ a lean editor-free webpack bundle (street-render.js) with astreet-render-harnessscene component (payload input, model-load quiescence readiness, bbox corner-fit camera framing, capture API), and the puppeteer-core + @sparticuz/chromium Cloud Function.Commit 2 is a visual pass tuned side-by-side against NACTO street illustrations:
street-labelrestyle (shared with the editor): white cells + segment-colored accent band on the street-surface edge, bold width over lighter name, flat unlit material with shadows off; bar tucked flush against the street face instead of floating 4m outstreet-environment(shared): shadow camera box was a lopsided ~70Γ50m around the origin leaving everything beyond it unshadowed; now symmetric 90Γ90m at the same 2048 map (no added GPU memory, ~4.4cm texels). Harness aligns streetslength: middleso a 60m street fits the origin-centered box (default-alignment follow-up: managed-street: default length alignment should be middleΒ #1863)App-wide impact
street-labelandstreet-environmentare shared components: the editor gets the restyled cross-section bar and the larger shadow coverage. Both are visual-only; geometry, alignment handling, units, and save/load paths untouched. Shadow box is strictly larger than before on all four sides at unchanged resolution.Test plan
npm test(774 passing, incl. managed-street JSON round-trip contract)npm run lintrender.htmlat 1:1 and 16:10, azimuths 15/20/25/45, lengths 27/40/50/60: framing centered, label flush + legible, full shadow coverage to the far end of a 60m streetRENDER_PAGE_URL/EDITOR_BASE_URLoverrides in.env.dev-3dstreet) + curl smoke testπ€ Generated with Claude Code