When a file is already on the node, do the conversion there and move only the result. Three places a file lives, and the call that transforms it in each:

  • A render capture. Ask for variants in the screenshot request. The page is loaded and painted once and every encoding comes back as its own artifact: here a full-size PNG, a WebP, an 800 px JPEG and a 400 px thumbnail. There is no second capture and no download to re-encode on your side. Each variant bills one more render.screenshot.
  • A sandbox. A file an agent downloaded or produced sits in its sandbox. Pillow is installed in the code-interpreter template, so one run_code call shrinks a 4000x3000 JPEG to a 1,600 px WebP in the kernel and you read back only that. The sandbox has egress: deny: the transform needs no network.
  • A routine step. sandboxes/exec@1 with sandbox runs a command in an existing sandbox, so the same script can make a 400 px thumbnail on a schedule or after another step, with no process of yours waiting on it.

The script both the kernel and the routine run. Pillow's draft mode decodes a JPEG at 1/2, 1/4 or 1/8 scale when the target is that much smaller, so a thumbnail never decodes all 12 megapixels:

python
"""Shrink an image to a width, as WebP next to the original. Prints one JSON line."""import jsonimport osimport sysimport timefrom PIL import Imagedef shrink(src, width, quality=82): t0 = time.perf_counter() with Image.open(src) as im: im.draft("RGB", (width, width * im.height // im.width)) out = im.convert("RGB") out.thumbnail((width, out.height), Image.LANCZOS) dst = f"{os.path.splitext(src)[0]}-{width}.webp" out.save(dst, "WEBP", quality=quality) return { "width": out.width, "height": out.height, "bytes": os.path.getsize(dst), "path": dst, "ms": round((time.perf_counter() - t0) * 1000, 1), }if __name__ == "__main__": print(json.dumps(shrink(sys.argv[1], int(sys.argv[2]))))
Check

Verified 2026-11-03: this recipe's last run passed against https://beta-api.b3os.org, label live-recorded (recorded from production), from commit e1617763. The recipe harness runs it again, and a failed run takes this page out of the cookbook.

Run it

Save the script above as thumb.py. The sandbox needs a file to work on: the recipe makes a 4000x3000 stand-in photo inside it; to try it on your own, write one with PUT /v1/sandboxes/{id}/files or download it from inside a sandbox that allows the host.

bash
API=https://beta-api.b3os.orgAUTH="Authorization: Bearer $B3OS_API_KEY"post() { curl -s "$API$1" -H "$AUTH" -H "Content-Type: application/json" -d @-; }# 1. render: one capture, four encodingspost /v1/render/screenshots <<'EOF' | jq '.artifacts[] | {content_type, width, bytes}'{"url": "https://beta.b3os.org/demo/billing/", "viewport": {"width": 1440, "height": 900}, "format": "png", "variants": [{"format": "webp", "quality": 85}, {"format": "jpeg", "width": 800, "quality": 80}, {"format": "webp", "width": 400, "quality": 80}]}EOF# 2. sandboxes: shrink the file in the kernel, read back only the resultSBX=$(echo '{"template": "code-interpreter", "timeout_s": 300, "egress": {"mode": "deny"}}' | post /v1/sandboxes | jq -r .id)curl -s -X PUT "$API/v1/sandboxes/$SBX/files?path=/workspace/thumb.py" -H "$AUTH" \ -H "Content-Type: application/octet-stream" --data-binary @thumb.py > /dev/nullcurl -s -X PUT "$API/v1/sandboxes/$SBX/files?path=/workspace/photo.jpg" -H "$AUTH" \ -H "Content-Type: application/octet-stream" --data-binary @photo.jpg > /dev/nullecho '{"code": "import json, sys\nsys.path.insert(0, \"/workspace\")\nfrom thumb import shrink\nprint(json.dumps(shrink(\"/workspace/photo.jpg\", 1600)))"}' \ | post "/v1/sandboxes/$SBX/run_code" | jq -r .stdoutcurl -s "$API/v1/sandboxes/$SBX/files?path=/workspace/photo-1600.webp" -H "$AUTH" -o photo-1600.webp# 3. routines: the same script as a step on the sandbox that holds the filecurl -s -X PUT "$API/v1/routines/thumbnail" -H "$AUTH" -H "Content-Type: application/json" -d @- <<'EOF' > /dev/null{"name": "thumbnail", "inputs": {"type": "object", "required": ["sandbox"], "properties": {"sandbox": {"type": "string"}}}, "steps": [{"id": "thumb", "uses": "sandboxes/exec@1", "with": {"sandbox": "${{ inputs.sandbox }}", "cmd": ["python3", "/workspace/thumb.py", "/workspace/photo.jpg", "400"]}}], "output": {"stdout": "${{ steps.thumb.outputs.stdout }}"}}EOFjq -n --arg sbx "$SBX" '{input: {sandbox: $sbx}}' | post "/v1/routines/thumbnail/runs?wait=60" | jq -r .result.stdoutcurl -s -X DELETE "$API/v1/routines/thumbnail" -H "$AUTH" > /dev/nullcurl -s -X DELETE "$API/v1/sandboxes/$SBX" -H "$AUTH" | jq .status

Which one to use

  • The file came from render: ask for variants in the same request, up to 4 per capture (limits).
  • The file is in an agent's sandbox: run the tool there with run_code or exec and read back the result.
  • The transform should run without you: put the command in a routine as sandboxes/exec@1 with sandbox, or with template for a sandbox made for the step (definitions).
  • The file is on your laptop: send it where it will be used next. Converting it on the node first only adds a hop.

The recipe's own run makes the stand-in photo with numpy and Pillow inside the sandbox, so no photo is uploaded.

Owner: Docs. Products: Render, Sandboxes, Routines.

Output of the last green run

json
{ "page": "https://beta.b3os.org/demo/billing/", "encodings": [ { "content_type": "image/png", "width": 1440, "height": 900, "bytes": 269361 }, { "content_type": "image/webp", "width": 1440, "height": 900, "bytes": 48378 }, { "content_type": "image/jpeg", "width": 800, "height": 500, "bytes": 32757 }, { "content_type": "image/webp", "width": 400, "height": 250, "bytes": 6718 } ], "render_call_ms": 714, "render_server_ms": 427.177, "create_ms": 80.7, "photo": { "width": 4000, "height": 3000, "bytes": 1772041 }, "kernel": { "width": 1600, "height": 1200, "bytes": 18462, "transform_ms": 196.8, "kernel_ms": 205.041, "run_code_ms": 985.8, "read_ms": 59 }, "routine": { "run_status": "succeeded", "step_status": "succeeded", "step_ms": 236.567, "width": 400, "height": 300, "bytes": 2486, "transform_ms": 52.1, "run_ms": 843.7 }, "deleted": true, "total_ms": 5023.9}

The outputs this recipe publishes (its recipe.json publish.outputs), with workspace identifiers redacted. The run's stdout, stderr and other outputs are not published.

Captured 2026-11-03 from commit e1617763 , label live-recorded (recorded from production). Case recipes/transform-file-in-place .

Ask a question... ⌘I