diff --git a/README.md b/README.md
index 5c67c4b..177bb74 100644
--- a/README.md
+++ b/README.md
@@ -34,7 +34,7 @@ Need to deploy your Worker to Cloudflare? Python Workers are in open beta and ha
- [**`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.
- [**`django/`**](django) — runs a naive Django WSGI application directly on Python Workers.
- [**`django-todo-d1/`**](django-todo-d1) — uses Django with D1 for a basic TODO application.
-
+- [**`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.
## Open Beta and Limits
diff --git a/image-redraw/README.md b/image-redraw/README.md
new file mode 100644
index 0000000..333a223
--- /dev/null
+++ b/image-redraw/README.md
@@ -0,0 +1,74 @@
+# Image Redraw — FastAPI + R2 + Queues + Workflows + Workers AI
+
+[](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
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
+```
diff --git a/image-redraw/package.json b/image-redraw/package.json
new file mode 100644
index 0000000..2a460ff
--- /dev/null
+++ b/image-redraw/package.json
@@ -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"
+ }
+}
diff --git a/image-redraw/public/app.js b/image-redraw/public/app.js
new file mode 100644
index 0000000..931bdaa
--- /dev/null
+++ b/image-redraw/public/app.js
@@ -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();
diff --git a/image-redraw/public/index.html b/image-redraw/public/index.html
new file mode 100644
index 0000000..356c94e
--- /dev/null
+++ b/image-redraw/public/index.html
@@ -0,0 +1,92 @@
+
+
+
+
+
+ Image Redraw - Python Workers + Workers AI
+
+
+
+
+
+
+
+
+
+
+
untitled - Paint
+ _ □ x
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
before-and-after.bmp
+ _ □ x
+
+
+
+ Nothing open yet. Redraw a picture, or pick one from My Pictures.
+
+
+
+
+ Original
+
+
+
+ Redrawn
+
+
+
+
+
+
+
+
+
+
My Pictures
+ _ □ x
+
+
+
+
+
+
+
+
diff --git a/image-redraw/public/style.css b/image-redraw/public/style.css
new file mode 100644
index 0000000..acf1100
--- /dev/null
+++ b/image-redraw/public/style.css
@@ -0,0 +1,275 @@
+/* A deliberately clumsy MS Paint / Windows 95 look, in plain CSS. */
+:root {
+ --face: #c0c0c0;
+ --face-light: #dfdfdf;
+ --face-shadow: #808080;
+ --white: #ffffff;
+ --ink: #000000;
+ --ink-muted: #3a3a3a;
+ --desktop: #008080;
+ --title: #000080;
+ --yellow: #ffff00;
+ --red: #d40000;
+ --green: #007700;
+ --space-1: 0.25rem;
+ --space-2: 0.5rem;
+ --space-3: 1rem;
+ --space-4: 1.5rem;
+ --font-display: "Comic Sans MS", "Chalkboard SE", cursive;
+ --font-ui: Tahoma, Verdana, sans-serif;
+ --font-mono: "Courier New", Courier, monospace;
+ --bevel-out: inset -2px -2px 0 var(--face-shadow),
+ inset 2px 2px 0 var(--face-light), inset -3px -3px 0 var(--ink);
+ --bevel-in: inset 2px 2px 0 var(--face-shadow),
+ inset -2px -2px 0 var(--face-light), inset 3px 3px 0 var(--ink);
+}
+
+* {
+ box-sizing: border-box;
+}
+body {
+ margin: 0;
+ background: var(--desktop);
+ color: var(--ink);
+ font: 0.9375rem/1.5 var(--font-ui);
+}
+img {
+ display: block;
+ max-width: 100%;
+}
+[hidden] {
+ display: none !important;
+}
+:focus-visible {
+ outline: 3px dotted var(--ink);
+ outline-offset: 2px;
+}
+
+/* Desktop and header */
+.desktop {
+ max-width: 58rem;
+ margin: 0 auto;
+ padding: var(--space-4) var(--space-3);
+}
+.page-header {
+ text-align: center;
+ margin-bottom: var(--space-4);
+}
+.page-header h1 {
+ margin: 0;
+ font: 2.25rem var(--font-display);
+ color: var(--white);
+ text-shadow: 3px 3px 0 var(--ink);
+ transform: rotate(-1deg);
+}
+.page-header p {
+ max-width: 34rem;
+ margin: var(--space-3) auto 0;
+ padding: var(--space-2);
+ background: var(--white);
+ border: 2px solid var(--ink);
+ font-size: 0.8125rem;
+ transform: rotate(0.8deg);
+}
+
+/* Windows */
+.columns {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(19rem, 1fr));
+ gap: var(--space-4);
+}
+.window {
+ background: var(--face);
+ border: 2px solid var(--ink);
+ box-shadow: 6px 6px 0 rgba(0, 0, 0, 0.4);
+ padding: 3px;
+ margin-bottom: var(--space-4);
+ transform: rotate(-0.8deg);
+}
+.titlebar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--space-1) var(--space-2);
+ background: var(--title);
+}
+.titlebar h2 {
+ margin: 0;
+ font-size: 0.8125rem;
+ color: var(--white);
+}
+.titlebar-buttons {
+ padding: 0 var(--space-1);
+ background: var(--face);
+ box-shadow: var(--bevel-out);
+ font-size: 0.6875rem;
+ letter-spacing: 0.3em;
+}
+.window-body {
+ padding: var(--space-3);
+}
+
+/* Form controls */
+form {
+ display: grid;
+ gap: var(--space-2);
+}
+.field-label {
+ font: 1.125rem var(--font-display);
+}
+.hint {
+ margin: 0;
+ font-size: 0.8125rem;
+ color: var(--ink-muted);
+}
+input[type="file"] {
+ padding: var(--space-2);
+ background: var(--white);
+ border: 0;
+ box-shadow: var(--bevel-in);
+ font: 0.8125rem var(--font-mono);
+}
+.btn {
+ justify-self: start;
+ padding: var(--space-2) var(--space-3);
+ border: 0;
+ background: var(--face);
+ box-shadow: var(--bevel-out);
+ font: 0.9375rem var(--font-ui);
+ color: var(--ink);
+ cursor: pointer;
+}
+.btn:hover:not(:disabled),
+.card:hover {
+ background: var(--face-light);
+}
+.btn:active:not(:disabled) {
+ box-shadow: var(--bevel-in);
+}
+.btn:disabled {
+ color: var(--face-shadow);
+ cursor: not-allowed;
+}
+.btn-go {
+ background: var(--yellow);
+ font: 1.5rem var(--font-display);
+ transform: rotate(0.9deg);
+}
+
+/* Status, preview and comparison */
+.status {
+ min-height: 1.5rem;
+ margin: var(--space-2) 0 0;
+ font: 0.8125rem var(--font-mono);
+}
+.status[data-tone="error"],
+.status[data-tone="done"] {
+ padding: var(--space-2);
+ background: var(--white);
+ border: 2px solid currentcolor;
+ font-weight: bold;
+}
+.status[data-tone="error"] {
+ color: var(--red);
+}
+.status[data-tone="done"] {
+ color: var(--green);
+}
+figure {
+ margin: var(--space-3) 0 0;
+}
+figure img {
+ width: 100%;
+ aspect-ratio: 1;
+ object-fit: contain;
+ background: var(--white);
+ box-shadow: var(--bevel-in);
+}
+figcaption,
+.meta,
+.card {
+ font: 0.6875rem var(--font-mono);
+ color: var(--ink-muted);
+}
+.compare {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: var(--space-3);
+}
+.meta {
+ margin: var(--space-3) 0 0;
+ word-break: break-all;
+}
+.empty {
+ margin: 0;
+ padding: var(--space-4);
+ background: var(--white);
+ border: 3px dashed var(--face-shadow);
+ font-family: var(--font-display);
+ text-align: center;
+ color: var(--ink-muted);
+}
+
+/* Gallery */
+.gallery-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-3);
+ flex-wrap: wrap;
+}
+#gallery {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr));
+ gap: var(--space-3);
+ list-style: none;
+ margin: var(--space-2) 0 0;
+ padding: 0;
+}
+#gallery .empty {
+ grid-column: 1 / -1;
+}
+.card {
+ display: grid;
+ gap: var(--space-1);
+ padding: var(--space-2);
+ border: 0;
+ background: var(--face);
+ box-shadow: var(--bevel-out);
+ cursor: pointer;
+}
+.card[aria-pressed="true"] {
+ background: var(--yellow);
+ box-shadow: var(--bevel-in);
+ color: var(--ink);
+ font-weight: bold;
+ transform: rotate(-1.1deg);
+}
+.card img {
+ aspect-ratio: 1;
+ object-fit: contain;
+ background: var(--white);
+ border: 2px solid var(--ink);
+ image-rendering: pixelated; /* crunchy on purpose */
+}
+
+/* Small screens: straighten up so nothing runs off the edge */
+@media (max-width: 40rem) {
+ .window,
+ .btn-go,
+ .page-header h1,
+ .page-header p,
+ .card[aria-pressed="true"] {
+ transform: none;
+ }
+ .compare {
+ grid-template-columns: 1fr;
+ }
+}
+@media (prefers-reduced-motion: reduce) {
+ * {
+ animation-duration: 0.01ms !important;
+ transition-duration: 0.01ms !important;
+ scroll-behavior: auto !important;
+ }
+}
diff --git a/image-redraw/pyproject.toml b/image-redraw/pyproject.toml
new file mode 100644
index 0000000..080ef12
--- /dev/null
+++ b/image-redraw/pyproject.toml
@@ -0,0 +1,16 @@
+[project]
+name = "image-redraw-worker"
+version = "0.1.0"
+description = "Image redraw example using R2, Queues, Workflows and Workers AI"
+readme = "README.md"
+requires-python = ">=3.13"
+dependencies = [
+ "fastapi",
+ "pillow",
+]
+
+[dependency-groups]
+dev = [
+ "workers-py",
+ "workers-runtime-sdk",
+]
diff --git a/image-redraw/src/entry.py b/image-redraw/src/entry.py
new file mode 100644
index 0000000..86710d6
--- /dev/null
+++ b/image-redraw/src/entry.py
@@ -0,0 +1,27 @@
+from image_redraw.api import app
+from image_redraw.constants import is_job_id
+from image_redraw.workflow import RedrawWorkflow
+from workers import WorkerEntrypoint, asgi
+
+# Re-export RedrawWorkflow so workerd can find workflow classes
+__all__ = ["Default", "RedrawWorkflow"]
+
+
+class Default(WorkerEntrypoint):
+ async def fetch(self, request):
+ return await asgi.fetch(app, request, self.env)
+
+ async def queue(self, batch, env, ctx):
+ pending = []
+ for message in batch.messages:
+ body = message.body
+ job_id = body.get("jobId") if isinstance(body, dict) else None
+ if not is_job_id(job_id):
+ print(f"Skipping malformed queue message {message.id}")
+ message.ack()
+ continue
+ pending.append((message, {"id": job_id, "params": {"jobId": job_id}}))
+ if pending:
+ await self.env.REDRAW_WORKFLOW.create_batch([spec for _, spec in pending])
+ for message, _ in pending:
+ message.ack()
diff --git a/image-redraw/src/image_redraw/__init__.py b/image-redraw/src/image_redraw/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/image-redraw/src/image_redraw/api.py b/image-redraw/src/image_redraw/api.py
new file mode 100644
index 0000000..7faa49a
--- /dev/null
+++ b/image-redraw/src/image_redraw/api.py
@@ -0,0 +1,153 @@
+import uuid
+from datetime import UTC, datetime
+from typing import Any
+
+from fastapi import FastAPI, HTTPException, Request, Response
+
+from .constants import (
+ ALLOWED_CONTENT_TYPES,
+ GALLERY_SIZE,
+ LIST_PAGE_SIZE,
+ OUTPUT_PREFIX,
+ STATUS_MAP,
+ failure_key,
+ is_job_id,
+ job_id_from_output_key,
+ original_key,
+ output_key,
+)
+
+app = FastAPI()
+
+IMAGE_HEADERS = {"X-Content-Type-Options": "nosniff"}
+
+
+def declared_length(request: Request) -> int | None:
+ try:
+ return int(request.headers["content-length"])
+ except (KeyError, ValueError):
+ # A missing or malformed header falls through to the post-read check.
+ return None
+
+
+@app.post("/api/jobs", status_code=202)
+async def create_job(request: Request) -> dict[str, str]:
+ content_type = request.headers.get("content-type", "")
+ if not content_type.startswith(ALLOWED_CONTENT_TYPES):
+ raise HTTPException(415, "Send a raw image/png, image/jpeg or image/webp body.")
+
+ image = await request.body()
+ if not image:
+ raise HTTPException(400, "The request body is empty.")
+
+ env = request.scope["env"]
+ job_id = uuid.uuid4().hex
+ created_at = datetime.now(UTC).isoformat()
+
+ await env.REDRAW_BUCKET.put(
+ original_key(job_id),
+ image,
+ httpMetadata={"contentType": content_type},
+ customMetadata={"createdAt": created_at},
+ )
+
+ try:
+ await env.REDRAW_QUEUE.send({"jobId": job_id})
+ except Exception:
+ await env.REDRAW_BUCKET.delete(original_key(job_id))
+ raise
+ return {"jobId": job_id, "status": "queued", "createdAt": created_at}
+
+
+@app.get("/api/jobs")
+async def list_jobs(request: Request) -> dict[str, list[dict[str, str]]]:
+ env = request.scope["env"]
+ bucket = env.REDRAW_BUCKET
+ jobs = []
+ cursor = None
+
+ while True:
+ options: dict[str, Any] = {"prefix": OUTPUT_PREFIX, "limit": LIST_PAGE_SIZE}
+ if cursor:
+ options["cursor"] = cursor
+
+ listed = await bucket.list(**options)
+ for obj in listed["objects"]:
+ job_id = job_id_from_output_key(obj.key)
+ if not is_job_id(job_id):
+ continue
+ jobs.append(
+ {
+ "jobId": job_id,
+ "completedAt": obj.uploaded.toISOString(),
+ "originalUrl": f"/api/images/original/{job_id}",
+ "outputUrl": f"/api/images/output/{job_id}",
+ }
+ )
+
+ # R2 only returns a cursor while more pages remain.
+ cursor = listed["cursor"] if listed["truncated"] else None
+ if not cursor:
+ break
+
+ jobs.sort(key=lambda job: job["completedAt"], reverse=True)
+ return {"jobs": jobs[:GALLERY_SIZE]}
+
+
+@app.get("/api/jobs/{job_id}")
+async def get_job(job_id: str, request: Request) -> dict[str, str]:
+ if not is_job_id(job_id):
+ raise HTTPException(404, "Job not found.")
+
+ env = request.scope["env"]
+ bucket = env.REDRAW_BUCKET
+
+ if await bucket.head(output_key(job_id)) is not None:
+ return {
+ "jobId": job_id,
+ "status": "complete",
+ "outputUrl": f"/api/images/output/{job_id}",
+ }
+
+ failure = await bucket.get(failure_key(job_id))
+ if failure is not None:
+ blob = await failure.blob()
+ return {"jobId": job_id, "status": "failed", "reason": await blob.text()}
+
+ if await bucket.head(original_key(job_id)) is None:
+ raise HTTPException(404, "Job not found.")
+
+ try:
+ instance = await env.REDRAW_WORKFLOW.get(job_id)
+ status = await instance.status()
+ except Exception:
+ # The original exists, so the consumer just has not created the instance yet.
+ return {"jobId": job_id, "status": "queued"}
+ return {"jobId": job_id, "status": STATUS_MAP.get(status["status"], "running")}
+
+
+@app.get("/api/images/{kind}/{job_id}")
+async def get_image(kind: str, job_id: str, request: Request) -> Response:
+ if not is_job_id(job_id):
+ raise HTTPException(404, "Image not found.")
+
+ if kind == "original":
+ key = original_key(job_id)
+ elif kind == "output":
+ key = output_key(job_id)
+ else:
+ raise HTTPException(404, "Image kind must be 'original' or 'output'.")
+
+ env = request.scope["env"]
+ obj = await env.REDRAW_BUCKET.get(key)
+ if obj is None:
+ raise HTTPException(404, "Image not found.")
+
+ http_metadata = obj.httpMetadata
+ media_type = http_metadata.contentType if http_metadata is not None else None
+ blob = await obj.blob()
+ return Response(
+ content=await blob.bytes(),
+ media_type=media_type or "application/octet-stream",
+ headers=IMAGE_HEADERS,
+ )
diff --git a/image-redraw/src/image_redraw/constants.py b/image-redraw/src/image_redraw/constants.py
new file mode 100644
index 0000000..cd6fda0
--- /dev/null
+++ b/image-redraw/src/image_redraw/constants.py
@@ -0,0 +1,83 @@
+import re
+
+MODEL = "@cf/black-forest-labs/flux-2-klein-4b"
+# FLUX takes multipart form fields, so every option is sent as a string.
+AI_OPTIONS = {
+ "prompt": (
+ "redraw the scene in the reference image as a clumsy MS Paint drawing, "
+ "keeping the same subject and composition but with wobbly mouse-drawn "
+ "outlines, flat bucket-fill colors, jagged pixelated edges, childlike "
+ "and amateur"
+ ),
+ "guidance": "2.5",
+ "width": "512",
+ "height": "512",
+}
+AI_RETRIES = {"retries": {"limit": 5, "delay": "5 seconds", "backoff": "exponential"}}
+
+AI_ERROR_CODE_PATTERN = re.compile(r"(\d+)\s*:")
+# 3030 means the safety filter refused the generated image. This app
+# deliberately treats it as final rather than retrying, to avoid repeated
+# billable inference on a reference the filter is likely to refuse again.
+AI_SAFETY_ERROR_CODE = 3030
+
+ORIGINAL_PREFIX = "originals/"
+OUTPUT_PREFIX = "outputs/"
+FAILURE_PREFIX = "failures/"
+
+ALLOWED_CONTENT_TYPES = ("image/png", "image/jpeg", "image/webp")
+
+# A deliberately small, fixed canvas for this example: it keeps inference cheap
+# and every reference picture uniform. It is a choice made here, not a limit
+# documented by the model.
+TARGET_SIZE = (511, 511)
+CANVAS_COLOR = (255, 255, 255)
+JPEG_QUALITY = 82
+MAX_SOURCE_PIXELS = {"JPEG": 40_000_000, "PNG": 8_000_000, "WEBP": 4_000_000}
+
+# The gallery shows the newest finished redraws; R2 lists keys in lexicographic
+# order, so every page has to be read before the newest ones can be picked.
+GALLERY_SIZE = 20
+LIST_PAGE_SIZE = 100
+
+JOB_ID_PATTERN = re.compile(r"[0-9a-f]{32}")
+STATUS_MAP = {
+ "queued": "queued",
+ "complete": "complete",
+ "errored": "failed",
+ "terminated": "failed",
+}
+SAFETY_REJECTED_REASON = (
+ "Workers AI refused to redraw that picture. Try a different one."
+)
+INVALID_IMAGE_REASON = "That upload could not be decoded as a usable image."
+MISSING_ORIGINAL_REASON = "The uploaded picture is no longer available."
+INVALID_OUTPUT_REASON = "Workers AI returned something that was not a picture."
+
+
+def is_job_id(value: object) -> bool:
+ # Job IDs are uuid4().hex, and a malformed one would fail the whole create_batch.
+ return isinstance(value, str) and JOB_ID_PATTERN.fullmatch(value) is not None
+
+
+def original_key(job_id: str) -> str:
+ return f"{ORIGINAL_PREFIX}{job_id}"
+
+
+def output_key(job_id: str) -> str:
+ return f"{OUTPUT_PREFIX}{job_id}"
+
+
+def failure_key(job_id: str) -> str:
+ return f"{FAILURE_PREFIX}{job_id}"
+
+
+def job_id_from_output_key(key: str) -> str:
+ return key.removeprefix(OUTPUT_PREFIX)
+
+
+def ai_error_code(message: str | None) -> int | None:
+ if not message:
+ return None
+ match = AI_ERROR_CODE_PATTERN.match(message.strip())
+ return int(match.group(1)) if match else None
diff --git a/image-redraw/src/image_redraw/workflow.py b/image-redraw/src/image_redraw/workflow.py
new file mode 100644
index 0000000..71d9626
--- /dev/null
+++ b/image-redraw/src/image_redraw/workflow.py
@@ -0,0 +1,168 @@
+import base64
+import io
+import json
+
+from PIL import Image, ImageOps
+from workers import Blob, FormData, Response, WorkflowEntrypoint
+from workers.workflows import NonRetryableError
+
+from .constants import (
+ AI_OPTIONS,
+ AI_RETRIES,
+ AI_SAFETY_ERROR_CODE,
+ CANVAS_COLOR,
+ INVALID_IMAGE_REASON,
+ INVALID_OUTPUT_REASON,
+ JPEG_QUALITY,
+ MAX_SOURCE_PIXELS,
+ MISSING_ORIGINAL_REASON,
+ MODEL,
+ SAFETY_REJECTED_REASON,
+ TARGET_SIZE,
+ ai_error_code,
+ failure_key,
+ original_key,
+ output_key,
+)
+
+# Magic bytes, because the model tells us nothing about the format it picked.
+IMAGE_SIGNATURES = (
+ (b"\x89PNG", "image/png"),
+ (b"\xff\xd8\xff", "image/jpeg"),
+ (b"RIFF", "image/webp"),
+)
+
+
+class UnusableImageError(Exception):
+ pass
+
+
+def sniff_content_type(image_bytes: bytes) -> str | None:
+ for signature, content_type in IMAGE_SIGNATURES:
+ if image_bytes.startswith(signature):
+ return content_type
+ return None
+
+
+def resize(image_bytes: bytes) -> bytes:
+ try:
+ with Image.open(io.BytesIO(image_bytes)) as image:
+ source_format = image.format or ""
+ pixel_limit = MAX_SOURCE_PIXELS.get(source_format)
+ if pixel_limit is None:
+ raise UnusableImageError(f"Unsupported image format {source_format!r}.")
+
+ width, height = image.size
+ if width * height > pixel_limit:
+ raise UnusableImageError(
+ f"{source_format} input is {width}x{height}, "
+ f"over the {pixel_limit} pixel budget."
+ )
+
+ if source_format == "JPEG":
+ image.draft("RGB", TARGET_SIZE)
+ ImageOps.exif_transpose(image, in_place=True)
+
+ image.thumbnail(TARGET_SIZE, Image.Resampling.LANCZOS, reducing_gap=2.0)
+ return _encode_centered_jpeg(image)
+ except UnusableImageError:
+ raise
+ except (Image.DecompressionBombError, OSError, ValueError) as exc:
+ raise UnusableImageError(f"Pillow could not decode the image: {exc}") from exc
+
+
+def _encode_centered_jpeg(image: Image.Image) -> bytes:
+ if image.mode in ("RGBA", "LA") or "transparency" in image.info:
+ # Dropping alpha without a mask would render transparency as black.
+ image = image.convert("RGBA")
+ mask = image.getchannel("A")
+ else:
+ image = image.convert("RGB")
+ mask = None
+
+ canvas = Image.new("RGB", TARGET_SIZE, CANVAS_COLOR)
+ left = (TARGET_SIZE[0] - image.width) // 2
+ top = (TARGET_SIZE[1] - image.height) // 2
+ canvas.paste(image, (left, top), mask)
+
+ buffer = io.BytesIO()
+ canvas.save(buffer, format="JPEG", quality=JPEG_QUALITY)
+ return buffer.getvalue()
+
+
+class RedrawWorkflow(WorkflowEntrypoint):
+ async def run(self, event, step):
+ job_id = event["payload"]["jobId"]
+ bucket = self.env.REDRAW_BUCKET
+ source_key = original_key(job_id)
+ target_key = output_key(job_id)
+
+ @step.do()
+ async def verify_original():
+ if await bucket.head(source_key) is None:
+ raise NonRetryableError(f"No original stored for job {job_id}.")
+ return source_key
+
+ @step.do(config=AI_RETRIES)
+ async def redraw(verify_original):
+ source = await bucket.get(verify_original)
+ if source is None:
+ return json.dumps({"ok": False, "reason": MISSING_ORIGINAL_REASON})
+ original = await source.blob()
+
+ try:
+ reference = resize(await original.bytes())
+ except UnusableImageError as exc:
+ print(f"Job {job_id} has an unusable original: {exc}")
+ return json.dumps({"ok": False, "reason": INVALID_IMAGE_REASON})
+
+ form = FormData()
+ for field, value in AI_OPTIONS.items():
+ form[field] = value
+ form.append("input_image_0", Blob(reference, "image/jpeg"), "input.jpg")
+
+ serialized = Response(form)
+ multipart_type = serialized.headers["content-type"]
+
+ try:
+ generated = await self.env.AI.run(
+ MODEL,
+ {
+ "multipart": {
+ "body": serialized.body,
+ "contentType": multipart_type,
+ }
+ },
+ )
+ except Exception as exc:
+ if ai_error_code(getattr(exc, "message", None)) != AI_SAFETY_ERROR_CODE:
+ raise
+ # Returning rather than raising checkpoints the step, which is
+ # what guarantees no further inference happens.
+ return json.dumps({"ok": False, "reason": SAFETY_REJECTED_REASON})
+
+ # FLUX replies with JSON holding a base64 image, so decode before storing.
+ image = base64.b64decode(generated["image"])
+ content_type = sniff_content_type(image)
+ if content_type is None:
+ return json.dumps({"ok": False, "reason": INVALID_OUTPUT_REASON})
+
+ await bucket.put(
+ target_key,
+ image,
+ httpMetadata={"contentType": content_type},
+ customMetadata={"jobId": job_id},
+ )
+ return json.dumps({"ok": True, "key": target_key})
+
+ result = json.loads(await redraw())
+ if not result["ok"]:
+ await bucket.put(
+ failure_key(job_id),
+ result["reason"],
+ httpMetadata={"contentType": "text/plain"},
+ customMetadata={"jobId": job_id},
+ )
+ # Raised outside the retrying step so the failure is final.
+ raise NonRetryableError(result["reason"])
+ return None
diff --git a/image-redraw/wrangler.jsonc b/image-redraw/wrangler.jsonc
new file mode 100644
index 0000000..3cd4337
--- /dev/null
+++ b/image-redraw/wrangler.jsonc
@@ -0,0 +1,52 @@
+{
+ "$schema": "node_modules/wrangler/config-schema.json",
+ "name": "image-redraw-worker",
+ "main": "src/entry.py",
+ "compatibility_date": "2026-08-21",
+ "compatibility_flags": [
+ "python_workers",
+ "python_workflows_implicit_dependencies"
+ ],
+ "assets": {
+ "directory": "./public",
+ "run_worker_first": [
+ "/api/*"
+ ]
+ },
+ "r2_buckets": [
+ {
+ "binding": "REDRAW_BUCKET",
+ "bucket_name": "image-redraw"
+ }
+ ],
+ "queues": {
+ "producers": [
+ {
+ "binding": "REDRAW_QUEUE",
+ "queue": "image-redraw-jobs"
+ }
+ ],
+ "consumers": [
+ {
+ "queue": "image-redraw-jobs",
+ "max_batch_size": 10,
+ "max_batch_timeout": 2,
+ "max_retries": 3
+ }
+ ]
+ },
+ "workflows": [
+ {
+ "name": "image-redraw",
+ "binding": "REDRAW_WORKFLOW",
+ "class_name": "RedrawWorkflow"
+ }
+ ],
+ "ai": {
+ "binding": "AI",
+ "remote": true
+ },
+ "observability": {
+ "enabled": true
+ }
+}