Skip to content

Commit 9524e36

Browse files
Add Image Redraw example combining multiple Cloudflare bindings (#93)
* Initial image-redraw implementation * Resize image inside python workers * Handle errors * Fix body type * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * tidy up * formatting --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent f286df3 commit 9524e36

13 files changed

Lines changed: 1233 additions & 1 deletion

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ Need to deploy your Worker to Cloudflare? Python Workers are in open beta and ha
3434
- [**`dynamic-py-py/`**](dynamic-py-py) — shows how to load and run a Python Worker dynamically at runtime using a [Worker Loader](https://developers.cloudflare.com/workers/runtime-apis/bindings/worker-loader/) binding.
3535
- [**`django/`**](django) — runs a naive Django WSGI application directly on Python Workers.
3636
- [**`django-todo-d1/`**](django-todo-d1) — uses Django with D1 for a basic TODO application.
37-
37+
- [**`image-redraw/`**](image-redraw) — an example that combines [FastAPI](https://fastapi.tiangolo.com/), [R2](https://developers.cloudflare.com/r2/), [Queues](https://developers.cloudflare.com/queues/), [Workflows](https://developers.cloudflare.com/workflows/) and [Workers AI](https://developers.cloudflare.com/workers-ai/) to redraw uploaded images.
3838

3939

4040
## Open Beta and Limits

‎image-redraw/README.md‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# Image Redraw — FastAPI + R2 + Queues + Workflows + Workers AI
2+
3+
[![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/cloudflare/python-workers-examples/tree/main/20-image-redraw)
4+
5+
Upload a picture and get it back redrawn as a wobbly MS Paint doodle.
6+
7+
This example demonstrates how to leverage multiple Cloudflare services to
8+
create a full-featured image processing pipeline using Python Workers.
9+
10+
## What it uses
11+
12+
- [Python Workers](https://developers.cloudflare.com/workers/languages/python/) — the runtime
13+
- [FastAPI](https://fastapi.tiangolo.com/) — the HTTP API, served over ASGI
14+
- [R2](https://developers.cloudflare.com/r2/) — stores uploaded originals and redrawn outputs
15+
- [Queues](https://developers.cloudflare.com/queues/) — hands work off the request path
16+
- [Workflows](https://developers.cloudflare.com/workflows/) — durable, retrying background execution
17+
- [Workers AI](https://developers.cloudflare.com/workers-ai/) — image generation guided by the uploaded reference
18+
- [Pillow](https://pillow.readthedocs.io/en/stable/) — image normalization inside the Workflow
19+
- [Static Assets](https://developers.cloudflare.com/workers/static-assets/) — the plain HTML/CSS/JS frontend
20+
21+
```mermaid
22+
flowchart LR
23+
Browser["Browser"]
24+
Worker["Python Worker<br/>FastAPI"]
25+
Queue[["Queue"]]
26+
Workflow["Workflow"]
27+
R2[("R2")]
28+
AI["Workers AI"]
29+
30+
Browser --> Worker
31+
Worker --> R2
32+
Worker --> Queue
33+
Queue --> Workflow
34+
Workflow --> R2
35+
Workflow --> AI
36+
```
37+
38+
## The flow
39+
40+
1. The browser POSTs the image bytes to the Worker.
41+
2. FastAPI validates the image and stores the original in R2, and
42+
enqueues the job.
43+
3. The queue consumer turns each batch of IDs into Workflow instances.
44+
4. The Workflow reads the original from R2, normalizes it with Pillow, calls
45+
Workers AI, and stores the result back in R2.
46+
47+
48+
## Setup
49+
50+
First ensure that `uv` is installed:
51+
https://docs.astral.sh/uv/getting-started/installation/#standalone-installer
52+
53+
**Workers AI is a remote binding, even during local development.** `wrangler.jsonc`
54+
declares `"ai": { "binding": "AI", "remote": true }`, so inference always runs on
55+
Cloudflare's network and bills against your account. Log in before starting the
56+
dev server:
57+
58+
```sh
59+
uv run pwrangler login
60+
```
61+
62+
## How to Run
63+
64+
```sh
65+
uv run pywrangler dev
66+
```
67+
68+
Then open http://localhost:8787/ in your browser.
69+
70+
## How to deploy
71+
72+
```sh
73+
uv run pywrangler deploy
74+
```

‎image-redraw/package.json‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"name": "image-redraw-worker",
3+
"version": "0.0.0",
4+
"private": true,
5+
"scripts": {
6+
"deploy": "uv run pywrangler deploy",
7+
"dev": "uv run pywrangler dev",
8+
"start": "uv run pywrangler dev"
9+
},
10+
"devDependencies": {
11+
"wrangler": "^4.114.0"
12+
}
13+
}

‎image-redraw/public/app.js‎

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
const REFERENCE_SIZE = 511;
2+
const MAX_UPLOAD_BYTES = 5_000_000;
3+
const POLL_INTERVAL_MS = 2000;
4+
const MAX_POLLS = 150;
5+
const MAX_POLL_FAILURES = 3;
6+
const TYPE_LABELS = {
7+
"image/png": "PNG",
8+
"image/jpeg": "JPEG",
9+
"image/webp": "WebP",
10+
};
11+
const ACCEPTED_TYPES = Object.keys(TYPE_LABELS);
12+
13+
const byId = (id) => document.getElementById(id);
14+
const uploadForm = byId("upload-form");
15+
const fileInput = byId("file-input");
16+
const submitButton = byId("submit-button");
17+
const uploadStatus = byId("upload-status");
18+
const preview = byId("preview");
19+
const previewImage = byId("preview-image");
20+
const previewCaption = byId("preview-caption");
21+
const compare = byId("compare");
22+
const compareEmpty = byId("compare-empty");
23+
const compareMeta = byId("compare-meta");
24+
const originalImage = byId("original-image");
25+
const outputImage = byId("output-image");
26+
const gallery = byId("gallery");
27+
const galleryStatus = byId("gallery-status");
28+
const refreshButton = byId("refresh-button");
29+
30+
let rawUpload = null;
31+
let previewUrl = null;
32+
let selectedJobId = null;
33+
34+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
35+
const shortId = (jobId) => jobId.slice(0, 8);
36+
37+
function setStatus(element, message, tone = "info") {
38+
element.textContent = message;
39+
element.dataset.tone = tone;
40+
}
41+
42+
function formatTime(isoString) {
43+
const date = new Date(isoString);
44+
if (Number.isNaN(date.getTime())) return "unknown time";
45+
return date.toLocaleString([], {
46+
month: "short",
47+
day: "numeric",
48+
hour: "2-digit",
49+
minute: "2-digit",
50+
});
51+
}
52+
53+
function formatBytes(bytes) {
54+
const kilobytes = bytes / 1024;
55+
if (kilobytes < 1000) return `${kilobytes.toFixed(1)} KB`;
56+
return `${(kilobytes / 1024).toFixed(2)} MB`;
57+
}
58+
59+
async function requestJson(path, options) {
60+
let response;
61+
try {
62+
response = await fetch(path, options);
63+
} catch {
64+
throw new Error("Network error. Is the Worker still running?");
65+
}
66+
67+
const data = await response.json().catch(() => null);
68+
if (!response.ok) {
69+
const detail = typeof data?.detail === "string" ? data.detail : null;
70+
throw new Error(detail ?? `Request failed with status ${response.status}`);
71+
}
72+
return data;
73+
}
74+
75+
// Decodes only to prove the bytes are an image and to read the source size.
76+
// The bitmap is discarded; the original File is what gets uploaded.
77+
async function readSourceSize(file) {
78+
let bitmap;
79+
try {
80+
bitmap = await createImageBitmap(file);
81+
} catch {
82+
throw new Error("That file could not be decoded as an image.");
83+
}
84+
const size = { width: bitmap.width, height: bitmap.height };
85+
bitmap.close?.();
86+
return size;
87+
}
88+
89+
async function inspectFile(file) {
90+
if (!ACCEPTED_TYPES.includes(file.type)) {
91+
throw new Error("Only PNG, JPEG and WebP are supported.");
92+
}
93+
if (file.size > MAX_UPLOAD_BYTES) {
94+
throw new Error(
95+
`That file is ${file.size.toLocaleString()} bytes, over the ${MAX_UPLOAD_BYTES.toLocaleString()} byte limit. Try a smaller picture.`,
96+
);
97+
}
98+
99+
const { width, height } = await readSourceSize(file);
100+
const label = TYPE_LABELS[file.type];
101+
return {
102+
file,
103+
caption:
104+
`${width}x${height} ${label} (${file.type}), ${formatBytes(file.size)}`,
105+
};
106+
}
107+
108+
async function showPreview() {
109+
rawUpload = null;
110+
preview.hidden = true;
111+
if (previewUrl) URL.revokeObjectURL(previewUrl);
112+
previewUrl = null;
113+
114+
const file = fileInput.files?.[0];
115+
if (!file) {
116+
setStatus(uploadStatus, "");
117+
return;
118+
}
119+
120+
setStatus(uploadStatus, "Checking your picture...");
121+
try {
122+
rawUpload = await inspectFile(file);
123+
previewUrl = URL.createObjectURL(rawUpload.file);
124+
previewImage.src = previewUrl;
125+
previewImage.alt =
126+
"The original picture you chose, shown at full size before it is uploaded.";
127+
previewCaption.textContent = rawUpload.caption;
128+
preview.hidden = false;
129+
setStatus(uploadStatus, 'Ready. Press "Redraw it!".');
130+
} catch (error) {
131+
setStatus(uploadStatus, error.message, "error");
132+
}
133+
}
134+
135+
// Resolves with the terminal job object so the caller can read job.reason.
136+
async function pollJob(jobId) {
137+
let failures = 0;
138+
139+
for (let attempt = 1; attempt <= MAX_POLLS; attempt += 1) {
140+
await sleep(POLL_INTERVAL_MS);
141+
142+
let job;
143+
try {
144+
job = await requestJson(`/api/jobs/${jobId}`);
145+
failures = 0;
146+
} catch (error) {
147+
failures += 1;
148+
if (failures >= MAX_POLL_FAILURES) {
149+
throw new Error(`Lost contact with the Worker. ${error.message}`);
150+
}
151+
continue;
152+
}
153+
154+
if (job.status === "complete" || job.status === "failed") return job;
155+
setStatus(
156+
uploadStatus,
157+
`Job ${shortId(jobId)} is ${job.status}... (checked ${attempt} times)`,
158+
);
159+
}
160+
161+
throw new Error("This redraw is taking too long. Try Refresh later.");
162+
}
163+
164+
function selectJob(job) {
165+
selectedJobId = job.jobId;
166+
originalImage.src = job.originalUrl;
167+
originalImage.alt = `The picture you uploaded for job ${shortId(job.jobId)}.`;
168+
outputImage.src = job.outputUrl;
169+
outputImage.alt = `Workers AI redraw of job ${shortId(job.jobId)}, in a clumsy MS Paint style.`;
170+
compareMeta.textContent = `Job ${job.jobId} - finished ${formatTime(job.completedAt)}`;
171+
compare.hidden = false;
172+
compareEmpty.hidden = true;
173+
174+
for (const card of gallery.querySelectorAll(".card")) {
175+
card.setAttribute("aria-pressed", String(card.dataset.jobId === job.jobId));
176+
}
177+
}
178+
179+
function createCard(job) {
180+
const thumb = document.createElement("img");
181+
thumb.src = job.outputUrl;
182+
thumb.loading = "lazy";
183+
thumb.alt = `Redrawn picture from job ${shortId(job.jobId)}`;
184+
185+
const time = document.createElement("span");
186+
time.textContent = formatTime(job.completedAt);
187+
188+
const card = document.createElement("button");
189+
card.type = "button";
190+
card.className = "card";
191+
card.dataset.jobId = job.jobId;
192+
card.setAttribute("aria-pressed", String(job.jobId === selectedJobId));
193+
card.append(thumb, time);
194+
card.addEventListener("click", () => selectJob(job));
195+
196+
const item = document.createElement("li");
197+
item.append(card);
198+
return item;
199+
}
200+
201+
function renderGallery(jobs) {
202+
if (jobs.length === 0) {
203+
const empty = document.createElement("li");
204+
empty.className = "empty";
205+
empty.textContent = "No pictures yet. Upload one to start the gallery.";
206+
gallery.replaceChildren(empty);
207+
return;
208+
}
209+
gallery.replaceChildren(...jobs.map(createCard));
210+
}
211+
212+
async function loadGallery(jobIdToSelect) {
213+
refreshButton.disabled = true;
214+
setStatus(galleryStatus, "Loading gallery...");
215+
216+
try {
217+
const jobs = (await requestJson("/api/jobs"))?.jobs ?? [];
218+
renderGallery(jobs);
219+
setStatus(
220+
galleryStatus,
221+
jobs.length === 1 ? "1 picture saved." : `${jobs.length} pictures saved.`,
222+
);
223+
224+
const target = jobs.find((job) => job.jobId === jobIdToSelect);
225+
if (target) selectJob(target);
226+
} catch (error) {
227+
setStatus(galleryStatus, error.message, "error");
228+
} finally {
229+
refreshButton.disabled = false;
230+
}
231+
}
232+
233+
uploadForm.addEventListener("submit", async (event) => {
234+
event.preventDefault();
235+
if (!rawUpload) await showPreview();
236+
if (!rawUpload) {
237+
if (!fileInput.files?.length) {
238+
setStatus(uploadStatus, "Choose a picture first.", "error");
239+
}
240+
fileInput.focus();
241+
return;
242+
}
243+
244+
const upload = rawUpload.file;
245+
fileInput.disabled = true;
246+
submitButton.disabled = true;
247+
try {
248+
setStatus(uploadStatus, "Uploading to the Worker...");
249+
const created = await requestJson("/api/jobs", {
250+
method: "POST",
251+
headers: { "Content-Type": upload.type },
252+
body: upload,
253+
});
254+
255+
setStatus(uploadStatus, `Job ${shortId(created.jobId)} is queued...`);
256+
const job = await pollJob(created.jobId);
257+
if (job.status === "complete") {
258+
setStatus(uploadStatus, "Finished! Behold the artwork.", "done");
259+
await loadGallery(job.jobId);
260+
} else {
261+
// The backend only ever sends a reason it is happy to show a visitor.
262+
const reason =
263+
typeof job.reason === "string" && job.reason
264+
? job.reason
265+
: "The Workflow gave up on that one.";
266+
setStatus(uploadStatus, reason, "error");
267+
}
268+
} catch (error) {
269+
setStatus(uploadStatus, error.message, "error");
270+
} finally {
271+
fileInput.disabled = false;
272+
submitButton.disabled = false;
273+
}
274+
});
275+
276+
fileInput.addEventListener("change", () => void showPreview());
277+
refreshButton.addEventListener("click", () => void loadGallery());
278+
279+
void loadGallery();

0 commit comments

Comments
 (0)