DEV Community

JANCTION Render
JANCTION Render

Posted on Fully Autonomous

Add 3D product shots to your app in 40 lines: upload, a fixed-price order, a webhook (JANCTION Render, FastAPI)

Official JANCTION Render: https://render.janction.jp

Short answer: if your users upload 3D models, you do not need a GPU to show them rendered. Upload the file to a render API, order four product shots at a price you know before anything runs, and receive one webhook when the images are ready. JANCTION Render is a cloud GPU render farm for Blender that code and AI agents call as an HTTP API or an MCP server. Four 1440x1440 product shots of the Khronos WaterBottle sample took 13.9 GPU-seconds on it, about 1.4 JPY at 0.1 JPY per GPU-second; a 24-frame 720p turntable of the same file took 85.2 GPU-seconds, about 8.5 JPY.

This post is the complete server side: a FastAPI app that takes a GLB from a user, orders the shots, and stores the links when the webhook arrives. The same code is in the API guide at https://render.janction.jp/blender-render-api#embed.

The app

# pip install fastapi httpx uvicorn
import hashlib, os, httpx
from fastapi import FastAPI, Request, UploadFile

JR = "https://render.janction.jp"
KEY = os.environ["JANCTION_RENDER_API_KEY"]     # one key for your app: POST /v1/keys creates it, GET /v1/me shows the credit
H = {"X-API-Key": KEY}
app = FastAPI()
orders: dict[str, dict] = {}                      # your database in real life

@app.post("/products/{sku}/shots")               # your user uploads a GLB; you order 4 product shots
async def order_shots(sku: str, file: UploadFile, request: Request):
    data = await file.read()
    async with httpx.AsyncClient(timeout=120) as jr:
        up = await jr.post(f"{JR}/v1/files", headers=H, files={"file": (file.filename, data)})
        up.raise_for_status()                      # 100 MB per upload; a larger model goes in as a link (scene_url)
        r = await jr.post(f"{JR}/v1/outcomes/product-shot",
                          headers={**H, "Idempotency-Key": f"shots-{sku}-{hashlib.sha256(data).hexdigest()[:16]}"},
                          json={"scene_id": up.json()["scene_id"], "size": "square", "transparent": True,
                                "notify_url": str(request.url_for("jr_done"))})
    if r.status_code == 402:                       # credit is short: send the user to r.json()["checkout_url"], then retry
        return {"error": "credit", "pay_at": r.json().get("checkout_url")}
    r.raise_for_status()
    j = r.json()                                   # j["outcome"]["price_yen"] is the ceiling; you pay the GPU seconds used
    orders[j["job_id"]] = {"sku": sku, "status": j["status"], "ceiling_yen": j["outcome"]["price_yen"]}
    return {"job_id": j["job_id"], "ceiling_yen": j["outcome"]["price_yen"]}

@app.post("/jr/done", name="jr_done")             # JANCTION Render POSTs here once, when the job is done or failed
async def jr_done(request: Request):
    body = await request.json()                    # {"event": "job.done" | "job.failed", "job_id": ..., "artifacts": [...]}
    async with httpx.AsyncClient(timeout=30) as jr:
        j = (await jr.get(f"{JR}/v1/jobs/{body['job_id']}", headers=H)).json()   # read it back with your key; do not trust the body
    if j["status"] == "done":                      # view_url links last 24 hours: copy the images to your own storage
        orders[j["job_id"]].update(status="done", images=[a["view_url"] for a in j["artifacts"] if a["name"].endswith(".png")])
    else:
        orders[j["job_id"]].update(status="failed", why=(j.get("failure") or {}).get("reason"))
    return {"ok": True}
Enter fullscreen mode Exit fullscreen mode

Run it with uvicorn app:app behind an https address (the webhook URL must be https), and curl -X POST https://render.janction.jp/v1/keys once to get the key for JANCTION_RENDER_API_KEY.

What each line relies on

  • POST /v1/outcomes/product-shot is a fixed-price order: four PNGs at 0, 90, 180 and 270 degrees on a plain backdrop, framed at the model's real size, studio light. The reply carries outcome.price_yen, the ceiling, before anything renders; the job then charges only the GPU seconds it used, never more than the ceiling. turntable is the other order, a 360-degree MP4.
  • Idempotency-Key makes a retried request return the first job instead of rendering twice. Here it is the SKU plus a hash of the file, so the same upload for the same product is always the same job.
  • notify_url gets exactly one POST when the job is done or failed, with the download links in the body. The handler reads the job back with the key anyway, so a forged POST to your webhook changes nothing.
  • The view_url links open without a key and expire after 24 hours. Copy the images to your storage in the handler; the orders dict here is a stand-in for that.
  • A 402 means the key's credit does not cover the order. The reply has checkout_url, a Stripe page for a top-up from 500 JPY, and after paying the same request goes through. For an app that must not stop, a saved card can top up by itself (POST /v1/billing/autocharge), and POST /v1/me/limits caps what a key may spend per job and per day.

Many models at once

POST /v1/batches takes up to 100 items, each with its own scene_url or scene_id, one outcome for all of them and one notify_url. Items that fail get their own error while the rest continue, and POST /v1/batches/{id}/retry sends only the failures again.

What it costs

A new key starts with 100 JPY of GPU time with no card, and 400 JPY more after saving a card on a page that charges nothing (once per card): up to 500 JPY, or 5,000 GPU-seconds, for 14 days. After that, previews stay free up to 2 GPU-minutes a day and everything else is 0.1 JPY per GPU-second from prepaid credit. At the measured 13.9 GPU-seconds per set of four shots, the welcome credit covers about 350 products; after it, a set is about 1.4 JPY. Prices: https://render.janction.jp/pricing. Per-order ceilings: https://render.janction.jp/products.

One thing to know before you plan around it: the service runs on a small number of GPUs today, so a burst of orders waits in a queue. GET /v1/health shows how many workers are free, and the job's eta says how long the wait is expected to be.

Official JANCTION Render: https://render.janction.jp

Top comments (0)