From 6b98a5f552e41f9110f7011a0f3a863ffcc982c6 Mon Sep 17 00:00:00 2001 From: Gyeongjae Choi Date: Fri, 21 Aug 2026 19:21:34 +0900 Subject: [PATCH 1/7] Initial image-redraw implementation --- 20-image-redraw/README.md | 77 +++++ 20-image-redraw/package.json | 13 + 20-image-redraw/public/app.js | 278 ++++++++++++++++++ 20-image-redraw/public/index.html | 92 ++++++ 20-image-redraw/public/style.css | 275 +++++++++++++++++ 20-image-redraw/pyproject.toml | 15 + 20-image-redraw/src/entry.py | 27 ++ 20-image-redraw/src/image_redraw/__init__.py | 0 20-image-redraw/src/image_redraw/api.py | 128 ++++++++ 20-image-redraw/src/image_redraw/constants.py | 48 +++ 20-image-redraw/src/image_redraw/workflow.py | 73 +++++ 20-image-redraw/wrangler.jsonc | 52 ++++ README.md | 1 + 13 files changed, 1079 insertions(+) create mode 100644 20-image-redraw/README.md create mode 100644 20-image-redraw/package.json create mode 100644 20-image-redraw/public/app.js create mode 100644 20-image-redraw/public/index.html create mode 100644 20-image-redraw/public/style.css create mode 100644 20-image-redraw/pyproject.toml create mode 100644 20-image-redraw/src/entry.py create mode 100644 20-image-redraw/src/image_redraw/__init__.py create mode 100644 20-image-redraw/src/image_redraw/api.py create mode 100644 20-image-redraw/src/image_redraw/constants.py create mode 100644 20-image-redraw/src/image_redraw/workflow.py create mode 100644 20-image-redraw/wrangler.jsonc diff --git a/20-image-redraw/README.md b/20-image-redraw/README.md new file mode 100644 index 0000000..35c6f8f --- /dev/null +++ b/20-image-redraw/README.md @@ -0,0 +1,77 @@ +# 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. + +- [Python Workers / FastAPI](https://fastapi.tiangolo.com/) - For API server +- [R2](https://developers.cloudflare.com/r2/) - Storing images +- [Workers AI](https://developers.cloudflare.com/workers-ai/) - Image-to-image inference +- [Queues](https://developers.cloudflare.com/queues/) - Work queue +- [Workflows](https://developers.cloudflare.com/workflows/) - Durable execution + +```mermaid +flowchart LR + Browser["Browser"] + + subgraph Worker["Python Worker"] + Assets["Static Assets"] + API["FastAPI
/api/*"] + Consumer["Queue consumer"] + Workflow["RedrawWorkflow
durable steps"] + end + + Queue[["Queue
image-redraw-jobs"]] + Originals[("R2
originals/{jobId}")] + Outputs[("R2
outputs/{jobId}")] + AI["Workers AI
image-to-image"] + + Browser -->|"GET /"| Assets + Browser -->|"POST /api/jobs"| API + API -->|"1. put image bytes"| Originals + API -->|"2. send jobId only"| Queue + API -->|"3. 202 Accepted + jobId"| Browser + Queue -->|"batch of jobId only"| Consumer + Consumer -->|"instance id = jobId"| Workflow + Originals -->|"read original"| Workflow + Workflow -->|"multipart reference image"| AI + AI -->|"base64 PNG or JPEG"| Workflow + Workflow -->|"store bytes + content type"| Outputs + Browser -->|"poll GET /api/jobs/{jobId}"| API + Outputs -->|"status + redrawn bytes"| API +``` + +The uploaded image is stored in R2 and the job ID is enqueued to the queue. The +queue consumer turns each batch of messages into Workflow instances. +Workflows call the Workers AI API to redraw the image and stores the result 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/20-image-redraw/package.json b/20-image-redraw/package.json new file mode 100644 index 0000000..2a460ff --- /dev/null +++ b/20-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/20-image-redraw/public/app.js b/20-image-redraw/public/app.js new file mode 100644 index 0000000..295d587 --- /dev/null +++ b/20-image-redraw/public/app.js @@ -0,0 +1,278 @@ +const CANVAS_SIZE = 511; +const MAX_BYTES = 700_000; +const JPEG_QUALITY = 0.82; +const POLL_INTERVAL_MS = 2000; +const MAX_POLLS = 150; +const MAX_POLL_FAILURES = 3; +const ACCEPTED_TYPES = ["image/png", "image/jpeg", "image/webp"]; + +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 prepared = 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", + }); +} + +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; +} + +// FLUX reference images must be under 512x512, so letterbox on white at 511x511. +async function prepareUpload(file) { + let bitmap; + try { + bitmap = await createImageBitmap(file); + } catch { + throw new Error("That file could not be decoded as an image."); + } + + const scale = Math.min(1, CANVAS_SIZE / Math.max(bitmap.width, bitmap.height)); + const width = Math.max(1, Math.round(bitmap.width * scale)); + const height = Math.max(1, Math.round(bitmap.height * scale)); + const source = `${bitmap.width}x${bitmap.height}`; + + const canvas = document.createElement("canvas"); + canvas.width = CANVAS_SIZE; + canvas.height = CANVAS_SIZE; + const context = canvas.getContext("2d"); + context.fillStyle = "#ffffff"; + context.fillRect(0, 0, CANVAS_SIZE, CANVAS_SIZE); + context.drawImage( + bitmap, + (CANVAS_SIZE - width) / 2, + (CANVAS_SIZE - height) / 2, + width, + height, + ); + bitmap.close?.(); + + const blob = await new Promise((resolve) => { + canvas.toBlob(resolve, "image/jpeg", JPEG_QUALITY); + }); + if (!blob) throw new Error("This browser could not encode the resized image."); + if (blob.size > MAX_BYTES) { + throw new Error( + `The resized image is still ${blob.size.toLocaleString()} bytes, over the ${MAX_BYTES.toLocaleString()} byte limit. Try a smaller picture.`, + ); + } + + const kilobytes = (blob.size / 1024).toFixed(1); + return { + blob, + caption: `${source} drawn at ${width}x${height} on ${CANVAS_SIZE}x${CANVAS_SIZE} - JPEG q${JPEG_QUALITY}, ${kilobytes} KB`, + }; +} + +async function showPreview() { + prepared = null; + preview.hidden = true; + if (previewUrl) URL.revokeObjectURL(previewUrl); + previewUrl = null; + + const file = fileInput.files?.[0]; + if (!file) { + setStatus(uploadStatus, ""); + return; + } + if (!ACCEPTED_TYPES.includes(file.type)) { + setStatus(uploadStatus, "Only PNG, JPEG and WebP are supported.", "error"); + return; + } + + setStatus(uploadStatus, "Squashing your picture onto a 511x511 canvas..."); + try { + prepared = await prepareUpload(file); + previewUrl = URL.createObjectURL(prepared.blob); + previewImage.src = previewUrl; + previewImage.alt = + "Your picture centred on a 511 by 511 white square, exactly as the AI will see it."; + previewCaption.textContent = prepared.caption; + preview.hidden = false; + setStatus(uploadStatus, 'Ready. Press "Redraw it!".'); + } catch (error) { + setStatus(uploadStatus, error.message, "error"); + } +} + +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.status; + 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 (!prepared) await showPreview(); + if (!prepared) { + if (!fileInput.files?.length) { + setStatus(uploadStatus, "Choose a picture first.", "error"); + } + fileInput.focus(); + return; + } + + fileInput.disabled = true; + submitButton.disabled = true; + try { + setStatus(uploadStatus, "Uploading to the Python Worker..."); + const job = await requestJson("/api/jobs", { + method: "POST", + headers: { "Content-Type": prepared.blob.type }, + body: prepared.blob, + }); + + setStatus(uploadStatus, `Job ${shortId(job.jobId)} is queued...`); + if ((await pollJob(job.jobId)) === "complete") { + setStatus(uploadStatus, "Finished! Behold the artwork.", "done"); + await loadGallery(job.jobId); + } else { + setStatus(uploadStatus, "The Workflow gave up on that one.", "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/20-image-redraw/public/index.html b/20-image-redraw/public/index.html new file mode 100644 index 0000000..365dd16 --- /dev/null +++ b/20-image-redraw/public/index.html @@ -0,0 +1,92 @@ + + + + + + Image Redraw - Python Workers + Workers AI + + + + +
+ + +
+
+
+

untitled - Paint

+ +
+
+
+ + +

+ PNG, JPEG or WebP. +

+ +
+ +

+ + +
+
+ +
+
+

before-and-after.bmp

+ +
+
+

+ Nothing open yet. Redraw a picture, or pick one from My Pictures. +

+ +

+
+
+
+ +
+
+ + +
+
+ + + +
+
+
+ + + + diff --git a/20-image-redraw/public/style.css b/20-image-redraw/public/style.css new file mode 100644 index 0000000..acf1100 --- /dev/null +++ b/20-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/20-image-redraw/pyproject.toml b/20-image-redraw/pyproject.toml new file mode 100644 index 0000000..2b4d890 --- /dev/null +++ b/20-image-redraw/pyproject.toml @@ -0,0 +1,15 @@ +[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", +] + +[dependency-groups] +dev = [ + "workers-py", + "workers-runtime-sdk", +] diff --git a/20-image-redraw/src/entry.py b/20-image-redraw/src/entry.py new file mode 100644 index 0000000..86710d6 --- /dev/null +++ b/20-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/20-image-redraw/src/image_redraw/__init__.py b/20-image-redraw/src/image_redraw/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/20-image-redraw/src/image_redraw/api.py b/20-image-redraw/src/image_redraw/api.py new file mode 100644 index 0000000..99c9a73 --- /dev/null +++ b/20-image-redraw/src/image_redraw/api.py @@ -0,0 +1,128 @@ +import uuid +from datetime import UTC, datetime + +from fastapi import FastAPI, HTTPException, Request, Response + +from .constants import ( + ALLOWED_CONTENT_TYPES, + GALLERY_SIZE, + LIST_PAGE_SIZE, + MAX_UPLOAD_BYTES, + OUTPUT_PREFIX, + STATUS_MAP, + job_id_from_output_key, + original_key, + output_key, +) + +app = FastAPI() + + +@app.post("/api/jobs", status_code=202) +async def create_job(request: Request): + content_type = request.headers.get("content-type", "").split(";")[0].strip().lower() + if content_type not in 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.") + + if len(image) > MAX_UPLOAD_BYTES: + raise HTTPException(413, f"Images must be at most {MAX_UPLOAD_BYTES} bytes.") + + env = request.scope["env"] + job_id = uuid.uuid4().hex + created_at = datetime.now(UTC).isoformat() + + # Store the original image + await env.REDRAW_BUCKET.put( + original_key(job_id), + image, + httpMetadata={"contentType": content_type}, + customMetadata={"createdAt": created_at}, + ) + + # Enqueue the job + 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): + env = request.scope["env"] + bucket = env.REDRAW_BUCKET + jobs = [] + cursor = None + + while True: + options = {"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) + job = {"jobId": job_id, "completedAt": obj.uploaded.toISOString()} + job["originalUrl"] = f"/api/images/original/{job_id}" + job["outputUrl"] = f"/api/images/output/{job_id}" + jobs.append(job) + + # 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): + env = request.scope["env"] + output_url = f"/api/images/output/{job_id}" + + # Done + if await env.REDRAW_BUCKET.head(output_key(job_id)) is not None: + return {"jobId": job_id, "status": "complete", "outputUrl": output_url} + + # Not found + if await env.REDRAW_BUCKET.head(original_key(job_id)) is None: + raise HTTPException(404, "Job not found.") + + # Running + 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): + 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.") + + # Serve the type recorded when the bytes were stored; never guess from the key. + 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", + ) diff --git a/20-image-redraw/src/image_redraw/constants.py b/20-image-redraw/src/image_redraw/constants.py new file mode 100644 index 0000000..83649c7 --- /dev/null +++ b/20-image-redraw/src/image_redraw/constants.py @@ -0,0 +1,48 @@ +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"}} +ORIGINAL_PREFIX = "originals/" +OUTPUT_PREFIX = "outputs/" +ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg", "image/webp"} +MAX_UPLOAD_BYTES = 700_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", +} + + +def is_job_id(value): + # 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): + return f"{ORIGINAL_PREFIX}{job_id}" + + +def output_key(job_id): + return f"{OUTPUT_PREFIX}{job_id}" + + +def job_id_from_output_key(key): + return key.removeprefix(OUTPUT_PREFIX) diff --git a/20-image-redraw/src/image_redraw/workflow.py b/20-image-redraw/src/image_redraw/workflow.py new file mode 100644 index 0000000..20ebef3 --- /dev/null +++ b/20-image-redraw/src/image_redraw/workflow.py @@ -0,0 +1,73 @@ +import base64 + +from workers import Blob, FormData, Response, WorkflowEntrypoint +from workers.workflows import NonRetryableError + +from .constants import AI_OPTIONS, AI_RETRIES, MODEL, 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")) + + +def sniff_content_type(image_bytes): + for signature, content_type in IMAGE_SIGNATURES: + if image_bytes.startswith(signature): + return content_type + return None + + +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: + raise NonRetryableError(f"No original stored for job {job_id}.") + original = await source.blob() + image_bytes = await original.bytes() + + form = FormData() + for field, value in AI_OPTIONS.items(): + form[field] = value + reference = Blob(image_bytes, original.content_type or "image/png") + form.append("input_image_0", reference, "input.png") + + serialized = Response(form) + generated = await self.env.AI.run( + MODEL, + { + "multipart": { + "body": serialized.body, + "contentType": serialized.headers["content-type"], + } + }, + ) + + # 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: + raise NonRetryableError( + f"Model returned neither PNG nor JPEG bytes for job {job_id}." + ) + + await bucket.put( + target_key, + image, + httpMetadata={"contentType": content_type}, + customMetadata={"jobId": job_id}, + ) + return {"key": target_key, "contentType": content_type} + + return await redraw() diff --git a/20-image-redraw/wrangler.jsonc b/20-image-redraw/wrangler.jsonc new file mode 100644 index 0000000..3cd4337 --- /dev/null +++ b/20-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 + } +} diff --git a/README.md b/README.md index af04d58..2fb6e93 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ Need to deploy your Worker to Cloudflare? Python Workers are in open beta and ha - [**`17-dynamic-py-py/`**](17-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. - [**`18-django/`**](18-django) — runs a naive Django WSGI application directly on Python Workers. - [**`19-django-todo-d1/`**](19-django-todo-d1) — uses Django with D1 for a basic TODO application. +- [**`20-image-redraw/`**](20-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. From 931c6b524c7a62dfe9659904e819ef0b5b7842f3 Mon Sep 17 00:00:00 2001 From: Gyeongjae Choi Date: Fri, 21 Aug 2026 19:51:53 +0900 Subject: [PATCH 2/7] Resize image inside python workers --- 20-image-redraw/README.md | 65 +++++----- 20-image-redraw/public/app.js | 106 ++++++++-------- 20-image-redraw/public/index.html | 2 +- 20-image-redraw/pyproject.toml | 1 + 20-image-redraw/src/image_redraw/api.py | 71 ++++++++--- 20-image-redraw/src/image_redraw/constants.py | 45 ++++++- 20-image-redraw/src/image_redraw/workflow.py | 118 +++++++++++++++--- 7 files changed, 281 insertions(+), 127 deletions(-) diff --git a/20-image-redraw/README.md b/20-image-redraw/README.md index 35c6f8f..333a223 100644 --- a/20-image-redraw/README.md +++ b/20-image-redraw/README.md @@ -7,46 +7,43 @@ 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. -- [Python Workers / FastAPI](https://fastapi.tiangolo.com/) - For API server -- [R2](https://developers.cloudflare.com/r2/) - Storing images -- [Workers AI](https://developers.cloudflare.com/workers-ai/) - Image-to-image inference -- [Queues](https://developers.cloudflare.com/queues/) - Work queue -- [Workflows](https://developers.cloudflare.com/workflows/) - Durable execution +## 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"] - - subgraph Worker["Python Worker"] - Assets["Static Assets"] - API["FastAPI
/api/*"] - Consumer["Queue consumer"] - Workflow["RedrawWorkflow
durable steps"] - end - - Queue[["Queue
image-redraw-jobs"]] - Originals[("R2
originals/{jobId}")] - Outputs[("R2
outputs/{jobId}")] - AI["Workers AI
image-to-image"] - - Browser -->|"GET /"| Assets - Browser -->|"POST /api/jobs"| API - API -->|"1. put image bytes"| Originals - API -->|"2. send jobId only"| Queue - API -->|"3. 202 Accepted + jobId"| Browser - Queue -->|"batch of jobId only"| Consumer - Consumer -->|"instance id = jobId"| Workflow - Originals -->|"read original"| Workflow - Workflow -->|"multipart reference image"| AI - AI -->|"base64 PNG or JPEG"| Workflow - Workflow -->|"store bytes + content type"| Outputs - Browser -->|"poll GET /api/jobs/{jobId}"| API - Outputs -->|"status + redrawn bytes"| API + 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 uploaded image is stored in R2 and the job ID is enqueued to the queue. The -queue consumer turns each batch of messages into Workflow instances. -Workflows call the Workers AI API to redraw the image and stores the result in R2. +## 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 diff --git a/20-image-redraw/public/app.js b/20-image-redraw/public/app.js index 295d587..b86d13b 100644 --- a/20-image-redraw/public/app.js +++ b/20-image-redraw/public/app.js @@ -4,7 +4,12 @@ const JPEG_QUALITY = 0.82; const POLL_INTERVAL_MS = 2000; const MAX_POLLS = 150; const MAX_POLL_FAILURES = 3; -const ACCEPTED_TYPES = ["image/png", "image/jpeg", "image/webp"]; +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"); @@ -23,7 +28,7 @@ const gallery = byId("gallery"); const galleryStatus = byId("gallery-status"); const refreshButton = byId("refresh-button"); -let prepared = null; +let rawUpload = null; let previewUrl = null; let selectedJobId = null; @@ -46,6 +51,12 @@ function formatTime(isoString) { }); } +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 { @@ -62,54 +73,41 @@ async function requestJson(path, options) { return data; } -// FLUX reference images must be under 512x512, so letterbox on white at 511x511. -async function prepareUpload(file) { +// 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 scale = Math.min(1, CANVAS_SIZE / Math.max(bitmap.width, bitmap.height)); - const width = Math.max(1, Math.round(bitmap.width * scale)); - const height = Math.max(1, Math.round(bitmap.height * scale)); - const source = `${bitmap.width}x${bitmap.height}`; - - const canvas = document.createElement("canvas"); - canvas.width = CANVAS_SIZE; - canvas.height = CANVAS_SIZE; - const context = canvas.getContext("2d"); - context.fillStyle = "#ffffff"; - context.fillRect(0, 0, CANVAS_SIZE, CANVAS_SIZE); - context.drawImage( - bitmap, - (CANVAS_SIZE - width) / 2, - (CANVAS_SIZE - height) / 2, - width, - height, - ); + const size = { width: bitmap.width, height: bitmap.height }; bitmap.close?.(); + return size; +} - const blob = await new Promise((resolve) => { - canvas.toBlob(resolve, "image/jpeg", JPEG_QUALITY); - }); - if (!blob) throw new Error("This browser could not encode the resized image."); - if (blob.size > MAX_BYTES) { +async function inspectFile(file) { + if (!ACCEPTED_TYPES.includes(file.type)) { + throw new Error("Only PNG, JPEG and WebP are supported."); + } + if (file.size > MAX_BYTES) { throw new Error( - `The resized image is still ${blob.size.toLocaleString()} bytes, over the ${MAX_BYTES.toLocaleString()} byte limit. Try a smaller picture.`, + `That file is ${file.size.toLocaleString()} bytes, over the ${MAX_UPLOAD_BYTES.toLocaleString()} byte limit. Try a smaller picture.`, ); } - const kilobytes = (blob.size / 1024).toFixed(1); + const { width, height } = await readSourceSize(file); + const label = TYPE_LABELS[file.type]; return { - blob, - caption: `${source} drawn at ${width}x${height} on ${CANVAS_SIZE}x${CANVAS_SIZE} - JPEG q${JPEG_QUALITY}, ${kilobytes} KB`, + file, + caption: + `${width}x${height} ${label} (${file.type}), ${formatBytes(file.size)}`, }; } async function showPreview() { - prepared = null; + rawUpload = null; preview.hidden = true; if (previewUrl) URL.revokeObjectURL(previewUrl); previewUrl = null; @@ -119,19 +117,15 @@ async function showPreview() { setStatus(uploadStatus, ""); return; } - if (!ACCEPTED_TYPES.includes(file.type)) { - setStatus(uploadStatus, "Only PNG, JPEG and WebP are supported.", "error"); - return; - } - setStatus(uploadStatus, "Squashing your picture onto a 511x511 canvas..."); + setStatus(uploadStatus, "Checking your picture..."); try { - prepared = await prepareUpload(file); - previewUrl = URL.createObjectURL(prepared.blob); + rawUpload = await inspectFile(file); + previewUrl = URL.createObjectURL(rawUpload.file); previewImage.src = previewUrl; previewImage.alt = - "Your picture centred on a 511 by 511 white square, exactly as the AI will see it."; - previewCaption.textContent = prepared.caption; + "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) { @@ -139,6 +133,7 @@ async function showPreview() { } } +// Resolves with the terminal job object so the caller can read job.reason. async function pollJob(jobId) { let failures = 0; @@ -157,7 +152,7 @@ async function pollJob(jobId) { continue; } - if (job.status === "complete" || job.status === "failed") return job.status; + if (job.status === "complete" || job.status === "failed") return job; setStatus( uploadStatus, `Job ${shortId(jobId)} is ${job.status}... (checked ${attempt} times)`, @@ -238,8 +233,8 @@ async function loadGallery(jobIdToSelect) { uploadForm.addEventListener("submit", async (event) => { event.preventDefault(); - if (!prepared) await showPreview(); - if (!prepared) { + if (!rawUpload) await showPreview(); + if (!rawUpload) { if (!fileInput.files?.length) { setStatus(uploadStatus, "Choose a picture first.", "error"); } @@ -247,22 +242,29 @@ uploadForm.addEventListener("submit", async (event) => { return; } + const upload = rawUpload.file; fileInput.disabled = true; submitButton.disabled = true; try { - setStatus(uploadStatus, "Uploading to the Python Worker..."); - const job = await requestJson("/api/jobs", { + setStatus(uploadStatus, "Uploading to the Worker..."); + const created = await requestJson("/api/jobs", { method: "POST", - headers: { "Content-Type": prepared.blob.type }, - body: prepared.blob, + headers: { "Content-Type": upload.type }, + body: upload, }); - setStatus(uploadStatus, `Job ${shortId(job.jobId)} is queued...`); - if ((await pollJob(job.jobId)) === "complete") { + 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 { - setStatus(uploadStatus, "The Workflow gave up on that one.", "error"); + // 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"); diff --git a/20-image-redraw/public/index.html b/20-image-redraw/public/index.html index 365dd16..356c94e 100644 --- a/20-image-redraw/public/index.html +++ b/20-image-redraw/public/index.html @@ -12,7 +12,7 @@ diff --git a/20-image-redraw/pyproject.toml b/20-image-redraw/pyproject.toml index 2b4d890..080ef12 100644 --- a/20-image-redraw/pyproject.toml +++ b/20-image-redraw/pyproject.toml @@ -6,6 +6,7 @@ readme = "README.md" requires-python = ">=3.13" dependencies = [ "fastapi", + "pillow", ] [dependency-groups] diff --git a/20-image-redraw/src/image_redraw/api.py b/20-image-redraw/src/image_redraw/api.py index 99c9a73..5fef9e5 100644 --- a/20-image-redraw/src/image_redraw/api.py +++ b/20-image-redraw/src/image_redraw/api.py @@ -1,5 +1,6 @@ import uuid from datetime import UTC, datetime +from typing import Any from fastapi import FastAPI, HTTPException, Request, Response @@ -9,7 +10,10 @@ LIST_PAGE_SIZE, MAX_UPLOAD_BYTES, OUTPUT_PREFIX, + SAFETY_REJECTED_REASON, STATUS_MAP, + failure_key, + is_job_id, job_id_from_output_key, original_key, output_key, @@ -17,19 +21,33 @@ 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): +async def create_job(request: Request) -> dict[str, str]: content_type = request.headers.get("content-type", "").split(";")[0].strip().lower() if content_type not in ALLOWED_CONTENT_TYPES: raise HTTPException(415, "Send a raw image/png, image/jpeg or image/webp body.") + too_long = f"Images must be at most {MAX_UPLOAD_BYTES} bytes." + length = declared_length(request) + if length is not None and length > MAX_UPLOAD_BYTES: + raise HTTPException(413, too_long) + image = await request.body() if not image: raise HTTPException(400, "The request body is empty.") - if len(image) > MAX_UPLOAD_BYTES: - raise HTTPException(413, f"Images must be at most {MAX_UPLOAD_BYTES} bytes.") + raise HTTPException(413, too_long) env = request.scope["env"] job_id = uuid.uuid4().hex @@ -53,24 +71,28 @@ async def create_job(request: Request): @app.get("/api/jobs") -async def list_jobs(request: Request): +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 = {"prefix": OUTPUT_PREFIX, "limit": LIST_PAGE_SIZE} + 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) - job = {"jobId": job_id, "completedAt": obj.uploaded.toISOString()} - job["originalUrl"] = f"/api/images/original/{job_id}" - job["outputUrl"] = f"/api/images/output/{job_id}" - jobs.append(job) + 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 @@ -82,19 +104,26 @@ async def list_jobs(request: Request): @app.get("/api/jobs/{job_id}") -async def get_job(job_id: str, request: Request): +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"] - output_url = f"/api/images/output/{job_id}" + bucket = env.REDRAW_BUCKET - # Done - if await env.REDRAW_BUCKET.head(output_key(job_id)) is not None: - return {"jobId": job_id, "status": "complete", "outputUrl": output_url} + if await bucket.head(output_key(job_id)) is not None: + return { + "jobId": job_id, + "status": "complete", + "outputUrl": f"/api/images/output/{job_id}", + } - # Not found - if await env.REDRAW_BUCKET.head(original_key(job_id)) is None: + if await bucket.head(failure_key(job_id)) is not None: + return {"jobId": job_id, "status": "failed", "reason": SAFETY_REJECTED_REASON} + + if await bucket.head(original_key(job_id)) is None: raise HTTPException(404, "Job not found.") - - # Running + try: instance = await env.REDRAW_WORKFLOW.get(job_id) status = await instance.status() @@ -105,7 +134,10 @@ async def get_job(job_id: str, request: Request): @app.get("/api/images/{kind}/{job_id}") -async def get_image(kind: str, job_id: str, request: Request): +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": @@ -125,4 +157,5 @@ async def get_image(kind: str, job_id: str, request: Request): return Response( content=await blob.bytes(), media_type=media_type or "application/octet-stream", + headers=IMAGE_HEADERS, ) diff --git a/20-image-redraw/src/image_redraw/constants.py b/20-image-redraw/src/image_redraw/constants.py index 83649c7..d613248 100644 --- a/20-image-redraw/src/image_redraw/constants.py +++ b/20-image-redraw/src/image_redraw/constants.py @@ -14,14 +14,32 @@ "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"} -MAX_UPLOAD_BYTES = 700_000 +MAX_UPLOAD_BYTES = 5_000_000 + +# FLUX reference images have to stay under 512x512, so the Workflow letterboxes +# every picture onto a white square just below that before inference. +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", @@ -29,20 +47,37 @@ "errored": "failed", "terminated": "failed", } +# Shown to the browser in place of the Workflow error, which may quote the +# model's own description of why the picture was refused. +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." -def is_job_id(value): +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): +def original_key(job_id: str) -> str: return f"{ORIGINAL_PREFIX}{job_id}" -def output_key(job_id): +def output_key(job_id: str) -> str: return f"{OUTPUT_PREFIX}{job_id}" -def job_id_from_output_key(key): +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/20-image-redraw/src/image_redraw/workflow.py b/20-image-redraw/src/image_redraw/workflow.py index 20ebef3..629533b 100644 --- a/20-image-redraw/src/image_redraw/workflow.py +++ b/20-image-redraw/src/image_redraw/workflow.py @@ -1,21 +1,87 @@ import base64 +import io +from PIL import Image, ImageOps from workers import Blob, FormData, Response, WorkflowEntrypoint from workers.workflows import NonRetryableError -from .constants import AI_OPTIONS, AI_RETRIES, MODEL, original_key, output_key +from .constants import ( + AI_OPTIONS, + AI_RETRIES, + AI_SAFETY_ERROR_CODE, + CANVAS_COLOR, + INVALID_IMAGE_REASON, + JPEG_QUALITY, + MAX_SOURCE_PIXELS, + 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")) -def sniff_content_type(image_bytes): +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: + 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"] @@ -35,24 +101,40 @@ async def redraw(verify_original): if source is None: raise NonRetryableError(f"No original stored for job {job_id}.") original = await source.blob() - image_bytes = await original.bytes() + + try: + reference = resize(await original.bytes()) + except UnusableImageError as exc: + print(f"Job {job_id} has an unusable original: {exc}") + return {"ok": False, "reason": INVALID_IMAGE_REASON} form = FormData() for field, value in AI_OPTIONS.items(): form[field] = value - reference = Blob(image_bytes, original.content_type or "image/png") - form.append("input_image_0", reference, "input.png") + form.append("input_image_0", Blob(reference, "image/jpeg"), "input.jpg") serialized = Response(form) - generated = await self.env.AI.run( - MODEL, - { - "multipart": { - "body": serialized.body, - "contentType": serialized.headers["content-type"], - } - }, - ) + try: + generated = await self.env.AI.run( + MODEL, + { + "multipart": { + "body": serialized.body, + "contentType": serialized.headers["content-type"], + } + }, + ) + except Exception as exc: + if hasattr(exc, "message") and ai_error_code(exc.message) != AI_SAFETY_ERROR_CODE: + raise + # Returning rather than raising checkpoints the step, which is + # what actually guarantees no further inference happens. + await bucket.put( + failure_key(job_id), + SAFETY_REJECTED_REASON, + customMetadata={"jobId": job_id}, + ) + return {"ok": False, "reason": SAFETY_REJECTED_REASON} # FLUX replies with JSON holding a base64 image, so decode before storing. image = base64.b64decode(generated["image"]) @@ -68,6 +150,10 @@ async def redraw(verify_original): httpMetadata={"contentType": content_type}, customMetadata={"jobId": job_id}, ) - return {"key": target_key, "contentType": content_type} + return {"ok": True, "key": target_key, "contentType": content_type} - return await redraw() + result = await redraw() + if not result["ok"]: + # Raised outside the retrying step so the failure is final. + raise NonRetryableError(result["reason"]) + return result From da0cc23b4f9a6c8b65d813e64273bb4503d78702 Mon Sep 17 00:00:00 2001 From: Gyeongjae Choi Date: Fri, 21 Aug 2026 20:11:00 +0900 Subject: [PATCH 3/7] Handle errors --- 20-image-redraw/public/app.js | 7 ++-- 20-image-redraw/src/image_redraw/api.py | 13 +++--- 20-image-redraw/src/image_redraw/constants.py | 9 +++-- 20-image-redraw/src/image_redraw/workflow.py | 40 +++++++++++-------- 20-image-redraw/wrangler.jsonc | 2 +- 5 files changed, 39 insertions(+), 32 deletions(-) diff --git a/20-image-redraw/public/app.js b/20-image-redraw/public/app.js index b86d13b..931bdaa 100644 --- a/20-image-redraw/public/app.js +++ b/20-image-redraw/public/app.js @@ -1,6 +1,5 @@ -const CANVAS_SIZE = 511; -const MAX_BYTES = 700_000; -const JPEG_QUALITY = 0.82; +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; @@ -91,7 +90,7 @@ async function inspectFile(file) { if (!ACCEPTED_TYPES.includes(file.type)) { throw new Error("Only PNG, JPEG and WebP are supported."); } - if (file.size > MAX_BYTES) { + 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.`, ); diff --git a/20-image-redraw/src/image_redraw/api.py b/20-image-redraw/src/image_redraw/api.py index 5fef9e5..44c90bb 100644 --- a/20-image-redraw/src/image_redraw/api.py +++ b/20-image-redraw/src/image_redraw/api.py @@ -10,7 +10,6 @@ LIST_PAGE_SIZE, MAX_UPLOAD_BYTES, OUTPUT_PREFIX, - SAFETY_REJECTED_REASON, STATUS_MAP, failure_key, is_job_id, @@ -53,15 +52,13 @@ async def create_job(request: Request) -> dict[str, str]: job_id = uuid.uuid4().hex created_at = datetime.now(UTC).isoformat() - # Store the original image await env.REDRAW_BUCKET.put( original_key(job_id), image, httpMetadata={"contentType": content_type}, customMetadata={"createdAt": created_at}, ) - - # Enqueue the job + try: await env.REDRAW_QUEUE.send({"jobId": job_id}) except Exception: @@ -85,6 +82,8 @@ async def list_jobs(request: Request) -> dict[str, list[dict[str, str]]]: 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, @@ -118,8 +117,10 @@ async def get_job(job_id: str, request: Request) -> dict[str, str]: "outputUrl": f"/api/images/output/{job_id}", } - if await bucket.head(failure_key(job_id)) is not None: - return {"jobId": job_id, "status": "failed", "reason": SAFETY_REJECTED_REASON} + 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.") diff --git a/20-image-redraw/src/image_redraw/constants.py b/20-image-redraw/src/image_redraw/constants.py index d613248..fdab990 100644 --- a/20-image-redraw/src/image_redraw/constants.py +++ b/20-image-redraw/src/image_redraw/constants.py @@ -28,8 +28,9 @@ ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg", "image/webp"} MAX_UPLOAD_BYTES = 5_000_000 -# FLUX reference images have to stay under 512x512, so the Workflow letterboxes -# every picture onto a white square just below that before inference. +# 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 @@ -47,12 +48,12 @@ "errored": "failed", "terminated": "failed", } -# Shown to the browser in place of the Workflow error, which may quote the -# model's own description of why the picture was refused. 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: diff --git a/20-image-redraw/src/image_redraw/workflow.py b/20-image-redraw/src/image_redraw/workflow.py index 629533b..b813591 100644 --- a/20-image-redraw/src/image_redraw/workflow.py +++ b/20-image-redraw/src/image_redraw/workflow.py @@ -1,5 +1,6 @@ import base64 import io +import json from PIL import Image, ImageOps from workers import Blob, FormData, Response, WorkflowEntrypoint @@ -11,8 +12,10 @@ 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, @@ -66,6 +69,7 @@ def resize(image_bytes: bytes) -> bytes: 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: @@ -99,14 +103,14 @@ async def verify_original(): async def redraw(verify_original): source = await bucket.get(verify_original) if source is None: - raise NonRetryableError(f"No original stored for job {job_id}.") + 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 {"ok": False, "reason": INVALID_IMAGE_REASON} + return json.dumps({"ok": False, "reason": INVALID_IMAGE_REASON}) form = FormData() for field, value in AI_OPTIONS.items(): @@ -114,13 +118,16 @@ async def redraw(verify_original): form.append("input_image_0", Blob(reference, "image/jpeg"), "input.jpg") serialized = Response(form) + multipart_type = serialized.headers["content-type"] + multipart_body = await serialized.bytes() + try: generated = await self.env.AI.run( MODEL, { "multipart": { - "body": serialized.body, - "contentType": serialized.headers["content-type"], + "body": multipart_body, + "contentType": multipart_type, } }, ) @@ -128,21 +135,14 @@ async def redraw(verify_original): if hasattr(exc, "message") and ai_error_code(exc.message) != AI_SAFETY_ERROR_CODE: raise # Returning rather than raising checkpoints the step, which is - # what actually guarantees no further inference happens. - await bucket.put( - failure_key(job_id), - SAFETY_REJECTED_REASON, - customMetadata={"jobId": job_id}, - ) - return {"ok": False, "reason": SAFETY_REJECTED_REASON} + # 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: - raise NonRetryableError( - f"Model returned neither PNG nor JPEG bytes for job {job_id}." - ) + return json.dumps({"ok": False, "reason": INVALID_OUTPUT_REASON}) await bucket.put( target_key, @@ -150,10 +150,16 @@ async def redraw(verify_original): httpMetadata={"contentType": content_type}, customMetadata={"jobId": job_id}, ) - return {"ok": True, "key": target_key, "contentType": content_type} + return json.dumps({"ok": True, "key": target_key}) - result = await redraw() + 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 result + return None diff --git a/20-image-redraw/wrangler.jsonc b/20-image-redraw/wrangler.jsonc index 3cd4337..d402dc1 100644 --- a/20-image-redraw/wrangler.jsonc +++ b/20-image-redraw/wrangler.jsonc @@ -49,4 +49,4 @@ "observability": { "enabled": true } -} +} \ No newline at end of file From 9e197fb1ba012dd73466c950129751099d6b7859 Mon Sep 17 00:00:00 2001 From: Gyeongjae Choi Date: Fri, 21 Aug 2026 20:22:01 +0900 Subject: [PATCH 4/7] Fix body type --- 20-image-redraw/src/image_redraw/workflow.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/20-image-redraw/src/image_redraw/workflow.py b/20-image-redraw/src/image_redraw/workflow.py index b813591..c92db88 100644 --- a/20-image-redraw/src/image_redraw/workflow.py +++ b/20-image-redraw/src/image_redraw/workflow.py @@ -119,20 +119,19 @@ async def redraw(verify_original): serialized = Response(form) multipart_type = serialized.headers["content-type"] - multipart_body = await serialized.bytes() try: generated = await self.env.AI.run( MODEL, { "multipart": { - "body": multipart_body, + "body": serialized.body, "contentType": multipart_type, } }, ) except Exception as exc: - if hasattr(exc, "message") and ai_error_code(exc.message) != AI_SAFETY_ERROR_CODE: + 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. From 1e380f9bdd67f66ae90b2a23237dd52c47d3f827 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:26:24 +0000 Subject: [PATCH 5/7] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- 20-image-redraw/wrangler.jsonc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/20-image-redraw/wrangler.jsonc b/20-image-redraw/wrangler.jsonc index d402dc1..3cd4337 100644 --- a/20-image-redraw/wrangler.jsonc +++ b/20-image-redraw/wrangler.jsonc @@ -49,4 +49,4 @@ "observability": { "enabled": true } -} \ No newline at end of file +} From 9689516e4cc7c06e58f2e2625bbd6b604199d0fc Mon Sep 17 00:00:00 2001 From: Gyeongjae Choi Date: Fri, 28 Aug 2026 18:34:49 +0900 Subject: [PATCH 6/7] tidy up --- image-redraw/src/image_redraw/api.py | 13 ++----------- image-redraw/src/image_redraw/constants.py | 3 +-- image-redraw/src/image_redraw/workflow.py | 2 +- 3 files changed, 4 insertions(+), 14 deletions(-) diff --git a/image-redraw/src/image_redraw/api.py b/image-redraw/src/image_redraw/api.py index 44c90bb..7faa49a 100644 --- a/image-redraw/src/image_redraw/api.py +++ b/image-redraw/src/image_redraw/api.py @@ -8,7 +8,6 @@ ALLOWED_CONTENT_TYPES, GALLERY_SIZE, LIST_PAGE_SIZE, - MAX_UPLOAD_BYTES, OUTPUT_PREFIX, STATUS_MAP, failure_key, @@ -33,20 +32,13 @@ def declared_length(request: Request) -> int | None: @app.post("/api/jobs", status_code=202) async def create_job(request: Request) -> dict[str, str]: - content_type = request.headers.get("content-type", "").split(";")[0].strip().lower() - if content_type not in ALLOWED_CONTENT_TYPES: + 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.") - too_long = f"Images must be at most {MAX_UPLOAD_BYTES} bytes." - length = declared_length(request) - if length is not None and length > MAX_UPLOAD_BYTES: - raise HTTPException(413, too_long) - image = await request.body() if not image: raise HTTPException(400, "The request body is empty.") - if len(image) > MAX_UPLOAD_BYTES: - raise HTTPException(413, too_long) env = request.scope["env"] job_id = uuid.uuid4().hex @@ -151,7 +143,6 @@ async def get_image(kind: str, job_id: str, request: Request) -> Response: if obj is None: raise HTTPException(404, "Image not found.") - # Serve the type recorded when the bytes were stored; never guess from the key. http_metadata = obj.httpMetadata media_type = http_metadata.contentType if http_metadata is not None else None blob = await obj.blob() diff --git a/image-redraw/src/image_redraw/constants.py b/image-redraw/src/image_redraw/constants.py index fdab990..cd6fda0 100644 --- a/image-redraw/src/image_redraw/constants.py +++ b/image-redraw/src/image_redraw/constants.py @@ -25,8 +25,7 @@ OUTPUT_PREFIX = "outputs/" FAILURE_PREFIX = "failures/" -ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg", "image/webp"} -MAX_UPLOAD_BYTES = 5_000_000 +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 diff --git a/image-redraw/src/image_redraw/workflow.py b/image-redraw/src/image_redraw/workflow.py index c92db88..da566c3 100644 --- a/image-redraw/src/image_redraw/workflow.py +++ b/image-redraw/src/image_redraw/workflow.py @@ -26,7 +26,7 @@ ) # 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")) +IMAGE_SIGNATURES = ((b"\x89PNG", "image/png"), (b"\xff\xd8\xff", "image/jpeg"), (b"RIFF", "image/webp")) class UnusableImageError(Exception): From d8b78bc5c2b5f2b4c84dec18b82bf6f40fcce26a Mon Sep 17 00:00:00 2001 From: Gyeongjae Choi Date: Fri, 28 Aug 2026 18:35:40 +0900 Subject: [PATCH 7/7] formatting --- image-redraw/src/image_redraw/workflow.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/image-redraw/src/image_redraw/workflow.py b/image-redraw/src/image_redraw/workflow.py index da566c3..71d9626 100644 --- a/image-redraw/src/image_redraw/workflow.py +++ b/image-redraw/src/image_redraw/workflow.py @@ -26,7 +26,11 @@ ) # 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")) +IMAGE_SIGNATURES = ( + (b"\x89PNG", "image/png"), + (b"\xff\xd8\xff", "image/jpeg"), + (b"RIFF", "image/webp"), +) class UnusableImageError(Exception):