Vocabulary cards API
Give it a subject — “a domestic cat, sitting, whole body visible” — and it renders a studio photograph of that subject on a plain white background, uploads it to S3 and hands you back a URL.
Requests are queued and processed one at a time, so creating a job returns immediately and you poll for the result.
Quickstart
Everything you need, in one script. The API lives at https://image.pilou.dev.
API=https://image.pilou.dev
# 1 · create the job — comes back instantly with status "queued"
JOB=$(curl -s -X POST $API/api/jobs \
-H 'content-type: application/json' \
-d '{"title":"Chat","subject":"a domestic cat, sitting, whole body visible","count":1}' \
| jq -r .id)
# 2 · poll until it is done (or failed)
while :; do
STATUS=$(curl -s $API/api/jobs/$JOB | jq -r .status)
echo "$STATUS"
case $STATUS in done|error|cancelled) break;; esac
sleep 2
done
# 3 · read the S3 URLs
curl -s $API/api/jobs/$JOB | jq -r '.images[].url'
# 4 · once downloaded, delete the run to free the storage
# curl -s -X DELETE $API/api/jobs/$JOB
const API = "https://image.pilou.dev";
async function generate(subject, opts = {}) {
// 1 · create the job
const res = await fetch(`${API}/api/jobs`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ subject, ...opts }),
});
if (!res.ok) throw new Error((await res.json()).error);
let job = await res.json();
// 2 · poll until it settles
while (job.status === "queued" || job.status === "running") {
await new Promise((r) => setTimeout(r, 2000));
job = await (await fetch(`${API}/api/jobs/${job.id}`)).json();
}
if (job.status !== "done") throw new Error(job.error ?? job.status);
// 3 · the S3 URLs
return job.images.map((i) => i.url);
}
const urls = await generate("a domestic cat, sitting, whole body visible", { count: 2 });
console.log(urls);
import time, requests
API = "https://image.pilou.dev"
def generate(subject, **opts):
# 1 · create the job
r = requests.post(f"{API}/api/jobs", json={"subject": subject, **opts})
r.raise_for_status()
job = r.json()
# 2 · poll until it settles
while job["status"] in ("queued", "running"):
time.sleep(2)
job = requests.get(f"{API}/api/jobs/{job['id']}").json()
if job["status"] != "done":
raise RuntimeError(job.get("error") or job["status"])
# 3 · the S3 URLs
return [i["url"] for i in job["images"]]
print(generate("a domestic cat, sitting, whole body visible", count=2))
The three steps
Create
POST /api/jobs returns immediately with an id and
status: "queued". Nothing has been rendered yet.
Wait
Poll GET /api/jobs/{id} every couple of seconds until status
is no longer queued or running.
Collect
On done, every entry in images[] carries a url —
that is the S3 link to the PNG. Delete the run once you have it.
One at a time. There is a single worker, so a job sits in queued until
everything ahead of it has finished. A 1024×1024 image takes roughly 7 seconds once the
model is warm; the first request after switching model is slower while ComfyUI loads the
weights.
1 · Create a job
POST /api/jobs
Request
{
"title": "Chat", // optional, for your own reference
"subject": "a domestic cat, sitting, whole body visible", // required
"model": "qwen-image-2.1", // optional
"width": 1024, // optional
"height": 1024, // optional
"count": 1 // optional, up to 8
}
| Field | Type | Default | Notes |
|---|---|---|---|
subject | string | required | What to photograph. Write it as a noun phrase, not a sentence — it is dropped into a fixed prompt template. |
title | string | first clause of the subject | A label for your own use. It has no effect on the image. |
model | string | qwen-image-2.1 |
See Models. |
width / height | number | 1024 |
256–2048, rounded to a multiple of 16. |
count | number | 1 |
1–8. Each is a separate render with seed seed + n. |
person | boolean | auto | Adds the “generic, non-famous person” clause. Detected from the subject; set it explicitly to override. |
seed | number | random | Pass one to reproduce an earlier image exactly. |
steps / cfg | number | per model | Leave them alone unless you know the model. |
Response 201
{
"id": "7aadd8f2-67e6-4b6d-8e26-dc1c8447be2a", // ← keep this
"title": "Chat",
"subject": "a domestic cat, sitting, whole body visible",
"person": false,
"prompt": "Realistic studio photograph for a children's vocabulary card. …",
"model": "qwen-image-2.1",
"width": 1024,
"height": 1024,
"count": 1,
"steps": 25,
"cfg": 1,
"seed": 110129293481702,
"status": "queued",
"progress": 0,
"error": null,
"createdAt": "2026-09-22T10:51:54.050Z",
"startedAt": null,
"finishedAt": null,
"images": [] // filled in as rendering proceeds
}
2 · Wait for it
GET /api/jobs/{id}
Same object as above, re-read. Poll it every 2 seconds or so.
| status | Meaning | Keep polling? |
|---|---|---|
queued | Waiting its turn behind other jobs. | yes |
running | Rendering. progress goes 0 → 1. | yes |
done | Finished. images[] is complete. | no |
error | Failed. Read error. | no |
cancelled | Someone cancelled it. | no |
images[] fills in as each image lands, not all at the end — with
count: 4 you can start using the first result while the rest render. Stop when
status leaves queued/running, not when
images.length === count.
3 · Get the S3 URL
When status is done:
{
"status": "done",
"progress": 1,
"finishedAt": "2026-09-22T10:52:00.983Z",
"images": [
{
"id": "9182228f-e567-4c2d-80c8-9bf4470f5eb3",
"jobId": "7aadd8f2-67e6-4b6d-8e26-dc1c8447be2a",
"idx": 0, // position within the job, 0-based
"key": "vocab-images/2026-09-22/7aadd8f2-…/00.png",
"seed": 110129293481702,
"width": 1024,
"height": 1024,
"bytes": 1265074,
"createdAt": "2026-09-22T10:52:00.982Z",
// ← the S3 link. Presigned, ready to fetch, no credentials needed.
"url": "https://<account>.r2.cloudflarestorage.com/artefacts/<key>?X-Amz-Signature=…",
"src": "/api/images/9182228f-…/raw", // same bytes, via this API
"downloadUrl": "/api/images/9182228f-…/download" // same bytes, as an attachment
}
]
}
url expires. It is a presigned link valid for about two days, signed
from midnight UTC — so it is good until roughly midnight + 48 h, not 48 h from when you asked.
Store the key, not the URL, and re-read the job whenever you need a fresh
link. If you need permanent links instead, the server can be pointed at a public bucket
domain and url becomes a plain, non-expiring URL.
src and downloadUrl are paths on this API, not on S3 — prefix them
with the base URL. They proxy the same bytes, which is handy if the caller cannot reach S3
directly. downloadUrl sets a filename so a browser saves rather than displays it.
4 · Clean up when you are done
Storage is finite, so once you have downloaded what you want, delete the run. Deleting removes the rows and the objects from S3 — it is the whole point, and it cannot be undone.
DELETE /api/jobs/{id}
Deletes a whole run: the job and every image it produced.
curl -s -X DELETE $API/api/jobs/$JOB
→ {"deleted": true, "id": "7aadd8f2-…", "images": 4, "failed": []}
DELETE /api/images/{id}
Deletes a single image, if you want to keep the good one and drop the rest.
curl -s -X DELETE $API/api/images/$IMAGE
→ {"deleted": true, "id": "9182228f-…", "key": "vocab-images/2026-09-22/…/00.png"}
Download, then delete
The usual shape — take the files, then give the space back.
# $JOB is done — download every image, then drop the run
i=0
for url in $(curl -s $API/api/jobs/$JOB | jq -r '.images[].url'); do
curl -s "$url" -o "card-$i.png"
i=$((i+1))
done
# only delete once the downloads actually succeeded
curl -s -X DELETE $API/api/jobs/$JOB
import { writeFile } from "node:fs/promises";
async function collect(job) {
// download everything first
await Promise.all(
job.images.map(async (img, i) => {
const res = await fetch(img.url);
if (!res.ok) throw new Error(`download failed: ${res.status}`);
await writeFile(`card-${i}.png`, Buffer.from(await res.arrayBuffer()));
}),
);
// then give the space back
await fetch(`${API}/api/jobs/${job.id}`, { method: "DELETE" });
}
import requests
def collect(job):
# download everything first
for i, img in enumerate(job["images"]):
r = requests.get(img["url"])
r.raise_for_status()
with open(f"card-{i}.png", "wb") as f:
f.write(r.content)
# then give the space back
requests.delete(f"{API}/api/jobs/{job['id']}")
If S3 refuses to remove an object, a single-image delete fails with
502 and keeps the row, so the two never drift apart. A whole-run
delete removes the rows anyway — a stuck object must not strand the run — and names what it
could not remove under failed.
A running job cannot be deleted; cancel it first with
POST /api/jobs/{id}/cancel.
Models & sizes
| Model | id | Steps | Character |
|---|---|---|---|
| Qwen-Image 2.1 default | qwen-image-2.1 | 25 | Best prompt adherence. The one to use unless you have a reason not to. |
| Krea-2 Turbo | krea2 | 8 | Distilled and fast — roughly 2× quicker, slightly looser on the prompt. |
Check what is actually loaded with GET /api/models; it reports
installed: false plus the missing files for anything unavailable.
Sizes
Any width/height from 256 to 2048 works. The app offers these:
| Preset | width × height |
|---|---|
| Square default | 1024 × 1024 |
| HD | 1280 × 720 |
| Full HD | 1920 × 1080 |
| 2K | 2048 × 2048 |
These images are meant as square vocabulary cards, so the square presets suit them best; the wide ones leave a lot of white.
How the prompt is built
You send a subject, not a prompt. The server wraps it in a fixed template so every card comes out consistent:
Realistic studio photograph for a children's vocabulary card. Single subject,
centred, fully visible, filling about 70% of the square frame, on a seamless
plain pure white background. Soft even daylight, sharp focus, natural colours,
only a faint contact shadow. Photorealistic photography, not an illustration,
not a drawing, not 3D render. Absolutely no text, letters, numbers, logos,
watermarks, labels or brand marks anywhere.
[ if the subject involves a person ]
Any person shown is a generic, non-famous person with neutral plain clothing;
the face may be visible but must not resemble any real person.
Subject: <your subject>. No other objects, no props, no scenery unless
required by the subject.
So write subjects like:
a domestic cat, sitting, whole body visiblea bunch of fresh red radishes with green leavesa child jumping in the air with both feet off the ground, whole body visible, side view
The person clause is added automatically when the subject mentions a human. Override it with
"person": true or false.
To see the exact prompt before spending a render:
curl -s -X POST $API/api/preview -H 'content-type: application/json' \
-d '{"subject":"a child jumping in the air, side view"}'
→ {"prompt": "Realistic studio photograph … Any person shown is a generic …", "person": true}
Full reference
| Endpoint | What it does |
|---|---|
POST /api/jobs | Queue a request. |
GET /api/jobs | List jobs, newest first. ?limit=&offset=&status= |
GET /api/jobs/{id} | One job with its images. |
POST /api/jobs/{id}/cancel | Drop it from the queue, or interrupt it if running. |
DELETE /api/jobs/{id} | Delete the job and its images, including from S3. |
GET /api/images | Flat gallery, newest first. ?limit=&offset= |
GET /api/images/{id}/raw | The bytes. Cacheable, immutable. |
GET /api/images/{id}/download | The bytes, as a named attachment. |
DELETE /api/images/{id} | Delete one image, including from S3. |
GET /api/models | Models, their defaults, and whether they are installed. |
POST /api/preview | Render the final prompt without queueing anything. |
GET /api/prompt-template | The fixed template and the negative prompt. |
GET /api/events | Server-sent events. See below. |
GET /api/health | Renderer reachability, storage config, queue depth. |
No authentication. Anyone who can reach the server can generate and delete. Keep it on a trusted network.
Live events (SSE)
If you would rather not poll, GET /api/events streams changes as they happen.
event: hello
data: {"ok":true}
event: change
data: {"type":"job:started","jobId":"60043d65-…"}
event: change
data: {"type":"job:progress","jobId":"60043d65-…","progress":0.5}
event: change
data: {"type":"image:created","jobId":"60043d65-…","imageId":"3fe8fc3c-…"}
event: change
data: {"type":"job:done","jobId":"60043d65-…"}
Event types: job:created, job:started, job:progress,
job:done, job:error, job:cancelled,
job:deleted, image:created, image:deleted.
They carry ids only, not the payload — on job:done, re-read
GET /api/jobs/{id} to get the URLs. Polling is simpler and perfectly fine; use
this when you want progress without the round trips.
Errors
Failures come back as JSON with an error string.
{"error": "subject is required"} // 400
{"error": "unknown model sdxl; known: qwen-image-2.1, krea2"} // 400
{"error": "size must be between 256 and 2048, got 99999"} // 400
{"error": "job not found"} // 404
A job that fails while rendering still returns 200 — the failure is in the body:
{"status": "error", "error": "ComfyUI /prompt -> 500 …", "images": []}
So always branch on status, not just on the HTTP code.