Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions 20-image-redraw/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Image Redraw — FastAPI + R2 + Queues + Workflows + Workers AI

[![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)

Upload a picture and get it back redrawn as a wobbly MS Paint doodle.

This example demonstrates how to leverage multiple Cloudflare services to
create a full-featured image processing pipeline using Python Workers.

## What it uses

- [Python Workers](https://developers.cloudflare.com/workers/languages/python/) — the runtime
- [FastAPI](https://fastapi.tiangolo.com/) — the HTTP API, served over ASGI
- [R2](https://developers.cloudflare.com/r2/) — stores uploaded originals and redrawn outputs
- [Queues](https://developers.cloudflare.com/queues/) — hands work off the request path
- [Workflows](https://developers.cloudflare.com/workflows/) — durable, retrying background execution
- [Workers AI](https://developers.cloudflare.com/workers-ai/) — image generation guided by the uploaded reference
- [Pillow](https://pillow.readthedocs.io/en/stable/) — image normalization inside the Workflow
- [Static Assets](https://developers.cloudflare.com/workers/static-assets/) — the plain HTML/CSS/JS frontend

```mermaid
flowchart LR
Browser["Browser"]
Worker["Python Worker<br/>FastAPI"]
Queue[["Queue"]]
Workflow["Workflow"]
R2[("R2")]
AI["Workers AI"]

Browser --> Worker
Worker --> R2
Worker --> Queue
Queue --> Workflow
Workflow --> R2
Workflow --> AI
```

## The flow

1. The browser POSTs the image bytes to the Worker.
2. FastAPI validates the image and stores the original in R2, and
enqueues the job.
3. The queue consumer turns each batch of IDs into Workflow instances.
4. The Workflow reads the original from R2, normalizes it with Pillow, calls
Workers AI, and stores the result back in R2.


## Setup

First ensure that `uv` is installed:
https://docs.astral.sh/uv/getting-started/installation/#standalone-installer

**Workers AI is a remote binding, even during local development.** `wrangler.jsonc`
declares `"ai": { "binding": "AI", "remote": true }`, so inference always runs on
Cloudflare's network and bills against your account. Log in before starting the
dev server:

```sh
uv run pwrangler login
```

## How to Run

```sh
uv run pywrangler dev
```

Then open http://localhost:8787/ in your browser.

## How to deploy

```sh
uv run pywrangler deploy
```
13 changes: 13 additions & 0 deletions 20-image-redraw/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "image-redraw-worker",
"version": "0.0.0",
"private": true,
"scripts": {
"deploy": "uv run pywrangler deploy",
"dev": "uv run pywrangler dev",
"start": "uv run pywrangler dev"
},
"devDependencies": {
"wrangler": "^4.114.0"
}
}
279 changes: 279 additions & 0 deletions 20-image-redraw/public/app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
const REFERENCE_SIZE = 511;
const MAX_UPLOAD_BYTES = 5_000_000;
const POLL_INTERVAL_MS = 2000;
const MAX_POLLS = 150;
const MAX_POLL_FAILURES = 3;
const TYPE_LABELS = {
"image/png": "PNG",
"image/jpeg": "JPEG",
"image/webp": "WebP",
};
const ACCEPTED_TYPES = Object.keys(TYPE_LABELS);

const byId = (id) => document.getElementById(id);
const uploadForm = byId("upload-form");
const fileInput = byId("file-input");
const submitButton = byId("submit-button");
const uploadStatus = byId("upload-status");
const preview = byId("preview");
const previewImage = byId("preview-image");
const previewCaption = byId("preview-caption");
const compare = byId("compare");
const compareEmpty = byId("compare-empty");
const compareMeta = byId("compare-meta");
const originalImage = byId("original-image");
const outputImage = byId("output-image");
const gallery = byId("gallery");
const galleryStatus = byId("gallery-status");
const refreshButton = byId("refresh-button");

let rawUpload = null;
let previewUrl = null;
let selectedJobId = null;

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const shortId = (jobId) => jobId.slice(0, 8);

function setStatus(element, message, tone = "info") {
element.textContent = message;
element.dataset.tone = tone;
}

function formatTime(isoString) {
const date = new Date(isoString);
if (Number.isNaN(date.getTime())) return "unknown time";
return date.toLocaleString([], {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}

function formatBytes(bytes) {
const kilobytes = bytes / 1024;
if (kilobytes < 1000) return `${kilobytes.toFixed(1)} KB`;
return `${(kilobytes / 1024).toFixed(2)} MB`;
}

async function requestJson(path, options) {
let response;
try {
response = await fetch(path, options);
} catch {
throw new Error("Network error. Is the Worker still running?");
}

const data = await response.json().catch(() => null);
if (!response.ok) {
const detail = typeof data?.detail === "string" ? data.detail : null;
throw new Error(detail ?? `Request failed with status ${response.status}`);
}
return data;
}

// Decodes only to prove the bytes are an image and to read the source size.
// The bitmap is discarded; the original File is what gets uploaded.
async function readSourceSize(file) {
let bitmap;
try {
bitmap = await createImageBitmap(file);
} catch {
throw new Error("That file could not be decoded as an image.");
}
const size = { width: bitmap.width, height: bitmap.height };
bitmap.close?.();
return size;
}

async function inspectFile(file) {
if (!ACCEPTED_TYPES.includes(file.type)) {
throw new Error("Only PNG, JPEG and WebP are supported.");
}
if (file.size > MAX_UPLOAD_BYTES) {
throw new Error(
`That file is ${file.size.toLocaleString()} bytes, over the ${MAX_UPLOAD_BYTES.toLocaleString()} byte limit. Try a smaller picture.`,
);
}

const { width, height } = await readSourceSize(file);
const label = TYPE_LABELS[file.type];
return {
file,
caption:
`${width}x${height} ${label} (${file.type}), ${formatBytes(file.size)}`,
};
}

async function showPreview() {
rawUpload = null;
preview.hidden = true;
if (previewUrl) URL.revokeObjectURL(previewUrl);
previewUrl = null;

const file = fileInput.files?.[0];
if (!file) {
setStatus(uploadStatus, "");
return;
}

setStatus(uploadStatus, "Checking your picture...");
try {
rawUpload = await inspectFile(file);
previewUrl = URL.createObjectURL(rawUpload.file);
previewImage.src = previewUrl;
previewImage.alt =
"The original picture you chose, shown at full size before it is uploaded.";
previewCaption.textContent = rawUpload.caption;
preview.hidden = false;
setStatus(uploadStatus, 'Ready. Press "Redraw it!".');
} catch (error) {
setStatus(uploadStatus, error.message, "error");
}
}

// Resolves with the terminal job object so the caller can read job.reason.
async function pollJob(jobId) {
let failures = 0;

for (let attempt = 1; attempt <= MAX_POLLS; attempt += 1) {
await sleep(POLL_INTERVAL_MS);

let job;
try {
job = await requestJson(`/api/jobs/${jobId}`);
failures = 0;
} catch (error) {
failures += 1;
if (failures >= MAX_POLL_FAILURES) {
throw new Error(`Lost contact with the Worker. ${error.message}`);
}
continue;
}

if (job.status === "complete" || job.status === "failed") return job;
setStatus(
uploadStatus,
`Job ${shortId(jobId)} is ${job.status}... (checked ${attempt} times)`,
);
}

throw new Error("This redraw is taking too long. Try Refresh later.");
}

function selectJob(job) {
selectedJobId = job.jobId;
originalImage.src = job.originalUrl;
originalImage.alt = `The picture you uploaded for job ${shortId(job.jobId)}.`;
outputImage.src = job.outputUrl;
outputImage.alt = `Workers AI redraw of job ${shortId(job.jobId)}, in a clumsy MS Paint style.`;
compareMeta.textContent = `Job ${job.jobId} - finished ${formatTime(job.completedAt)}`;
compare.hidden = false;
compareEmpty.hidden = true;

for (const card of gallery.querySelectorAll(".card")) {
card.setAttribute("aria-pressed", String(card.dataset.jobId === job.jobId));
}
}

function createCard(job) {
const thumb = document.createElement("img");
thumb.src = job.outputUrl;
thumb.loading = "lazy";
thumb.alt = `Redrawn picture from job ${shortId(job.jobId)}`;

const time = document.createElement("span");
time.textContent = formatTime(job.completedAt);

const card = document.createElement("button");
card.type = "button";
card.className = "card";
card.dataset.jobId = job.jobId;
card.setAttribute("aria-pressed", String(job.jobId === selectedJobId));
card.append(thumb, time);
card.addEventListener("click", () => selectJob(job));

const item = document.createElement("li");
item.append(card);
return item;
}

function renderGallery(jobs) {
if (jobs.length === 0) {
const empty = document.createElement("li");
empty.className = "empty";
empty.textContent = "No pictures yet. Upload one to start the gallery.";
gallery.replaceChildren(empty);
return;
}
gallery.replaceChildren(...jobs.map(createCard));
}

async function loadGallery(jobIdToSelect) {
refreshButton.disabled = true;
setStatus(galleryStatus, "Loading gallery...");

try {
const jobs = (await requestJson("/api/jobs"))?.jobs ?? [];
renderGallery(jobs);
setStatus(
galleryStatus,
jobs.length === 1 ? "1 picture saved." : `${jobs.length} pictures saved.`,
);

const target = jobs.find((job) => job.jobId === jobIdToSelect);
if (target) selectJob(target);
} catch (error) {
setStatus(galleryStatus, error.message, "error");
} finally {
refreshButton.disabled = false;
}
}

uploadForm.addEventListener("submit", async (event) => {
event.preventDefault();
if (!rawUpload) await showPreview();
if (!rawUpload) {
if (!fileInput.files?.length) {
setStatus(uploadStatus, "Choose a picture first.", "error");
}
fileInput.focus();
return;
}

const upload = rawUpload.file;
fileInput.disabled = true;
submitButton.disabled = true;
try {
setStatus(uploadStatus, "Uploading to the Worker...");
const created = await requestJson("/api/jobs", {
method: "POST",
headers: { "Content-Type": upload.type },
body: upload,
});

setStatus(uploadStatus, `Job ${shortId(created.jobId)} is queued...`);
const job = await pollJob(created.jobId);
if (job.status === "complete") {
setStatus(uploadStatus, "Finished! Behold the artwork.", "done");
await loadGallery(job.jobId);
} else {
// The backend only ever sends a reason it is happy to show a visitor.
const reason =
typeof job.reason === "string" && job.reason
? job.reason
: "The Workflow gave up on that one.";
setStatus(uploadStatus, reason, "error");
}
} catch (error) {
setStatus(uploadStatus, error.message, "error");
} finally {
fileInput.disabled = false;
submitButton.disabled = false;
}
});

fileInput.addEventListener("change", () => void showPreview());
refreshButton.addEventListener("click", () => void loadGallery());

void loadGallery();
Loading
Loading