-
Notifications
You must be signed in to change notification settings - Fork 69
Add Image Redraw example combining multiple Cloudflare bindings #93
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
6b98a5f
Initial image-redraw implementation
ryanking13 931c6b5
Resize image inside python workers
ryanking13 da0cc23
Handle errors
ryanking13 9e197fb
Fix body type
ryanking13 1e380f9
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] 5b39d91
Merge remote-tracking branch 'origin/main' into gyeongjae/ai-redraw
ryanking13 9689516
tidy up
ryanking13 d8b78bc
formatting
ryanking13 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<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 | ||
| ``` |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
|
ryanking13 marked this conversation as resolved.
Dismissed
|
||
| 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(); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.