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

1

Create

POST /api/jobs returns immediately with an id and status: "queued". Nothing has been rendered yet.

2

Wait

Poll GET /api/jobs/{id} every couple of seconds until status is no longer queued or running.

3

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
}
FieldTypeDefaultNotes
subjectstringrequired What to photograph. Write it as a noun phrase, not a sentence — it is dropped into a fixed prompt template.
titlestringfirst clause of the subject A label for your own use. It has no effect on the image.
modelstringqwen-image-2.1 See Models.
width / heightnumber1024 256–2048, rounded to a multiple of 16.
countnumber1 1–8. Each is a separate render with seed seed + n.
personbooleanauto Adds the “generic, non-famous person” clause. Detected from the subject; set it explicitly to override.
seednumberrandom Pass one to reproduce an earlier image exactly.
steps / cfgnumberper 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.

statusMeaningKeep polling?
queuedWaiting its turn behind other jobs.yes
runningRendering. progress goes 0 → 1.yes
doneFinished. images[] is complete.no
errorFailed. Read error.no
cancelledSomeone 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
    }
  ]
}
The 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']}")
Download before you delete. There is no undo and no recycle bin — the PNG is gone from S3 the moment the call returns. Check your downloads succeeded first.

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

ModelidStepsCharacter
Qwen-Image 2.1 defaultqwen-image-2.125 Best prompt adherence. The one to use unless you have a reason not to.
Krea-2 Turbokrea28 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:

Presetwidth × height
Square default1024 × 1024
HD1280 × 720
Full HD1920 × 1080
2K2048 × 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:

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

EndpointWhat it does
POST /api/jobsQueue a request.
GET /api/jobsList jobs, newest first. ?limit=&offset=&status=
GET /api/jobs/{id}One job with its images.
POST /api/jobs/{id}/cancelDrop it from the queue, or interrupt it if running.
DELETE /api/jobs/{id}Delete the job and its images, including from S3.
GET /api/imagesFlat gallery, newest first. ?limit=&offset=
GET /api/images/{id}/rawThe bytes. Cacheable, immutable.
GET /api/images/{id}/downloadThe bytes, as a named attachment.
DELETE /api/images/{id}Delete one image, including from S3.
GET /api/modelsModels, their defaults, and whether they are installed.
POST /api/previewRender the final prompt without queueing anything.
GET /api/prompt-templateThe fixed template and the negative prompt.
GET /api/eventsServer-sent events. See below.
GET /api/healthRenderer 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.