Undo for AI image edits: keep a version chain of every saved result
Generative edits have no undo button. Download each result, hash it, record its parent and prompt, and walk the chain back. Python, no database.

To undo an AI image edit, you need the previous result saved as a file with a record of which prompt produced the next one. A generation API returns a new image each call; it does not keep a stack for you. A small version chain, with each saved file pointing at its parent, gives you undo, branch and compare, and fits in about thirty lines of Python.
Multi-step editing is the headline of FLUX 3 Image per BFL (read 2026-10-03), and multi-step editing is exactly where undo matters: step four goes wrong and you want step three back without re-running one through three.
What to store
Store the image bytes, not the link. Sume's docs describe data[].url as Sume-hosted and signed, so treat it as a delivery URL and keep your own copy. Alongside each file keep the parent version, the prompt and any model parameters. Add metadata on the request, which Sume stores on the job and does not send to the provider, so a job can be matched to a version from the Sume side too.
| Field | Why |
|---|---|
| Content hash (ten characters) | Stable id; the same bytes never duplicate |
| Parent hash | Gives undo and branching |
| Prompt and parameters | Lets you re-run or diff |
| File path | Local copy of the bytes |
| Sume job metadata | Link back to the job |
The chain
save_version downloads the result, hashes it, writes it under that hash and appends an index entry with its parent. lineage walks the parents back to the first image and returns the path from root to the current version. To undo, you call it, take the second to last id and use that file as the next request's source.
import hashlib
import json
import pathlib
import requests
ROOT = pathlib.Path("versions")
ROOT.mkdir(exist_ok=True)
INDEX = ROOT / "index.json"
def load():
return json.loads(INDEX.read_text()) if INDEX.exists() else {}
def save_version(parent_id, prompt, image_url):
"""Download the result now (hosted URLs are signed) and record who it came from."""
data = requests.get(image_url, timeout=60).content
vid = hashlib.sha256(data).hexdigest()[:10]
path = ROOT / f"{vid}.png"
path.write_bytes(data)
idx = load()
idx[vid] = {"parent": parent_id, "prompt": prompt, "file": str(path)}
INDEX.write_text(json.dumps(idx, indent=2))
return vid
def lineage(vid):
idx, chain = load(), []
while vid:
chain.append(vid)
vid = idx[vid]["parent"]
return chain[::-1]A typical session
You start with a hero shot, save it as version A and stamp the request metadata with its id. You ask for a warmer background, save the result as B with A as parent, then try a tighter crop to get C. The client prefers A's background with C's crop. You never lose A, because it is still on disk with its prompt, and you can start a branch D from A with the new instruction. Without the chain, that sequence would have been a hunt through a downloads folder.
Limits of this approach
The chain records files, not model state. Re-running a prompt from the same parent will not give the same image, since Sume's image API takes no seed on most rows, so a version is only reproducible as a file, never as a recipe. That is why you store the bytes. Hashing the bytes also means two identical results collapse into one version, which is usually what you want. If you want a record of a rejected result too, save it and mark it in the index rather than deleting it.
Branching and comparing
Because versions point to parents, two edits of the same parent are siblings. That makes A/B comparison natural: run two prompts from one parent and show both. It also catches drift: line up the whole lineage and see where the subject began to change. For that problem, repeat the preserve list in every turn.
Keep an eye on disk use. Ten-character hashes are safe for a single project, but PNG files are large; prune dead branches once the work is signed off. Multi-round editing practice is covered in the multi-round edits post, and a lighter lineage idea built on job metadata is in Midjourney-style edit history. For the job fields, see the jobs and results docs.
Sources
Related posts
More in Developers
- unsupported_media_type: video_url served as text/html or an image
Sume video trim and filter HEAD the source and refuse a declared non-video content type. What is checked, why octet-stream passes, and a runnable check.
- Valibot safeParse on a Sume job status: keep polling on bad data
Valibot's safeParse returns a result instead of throwing, so a malformed Sume status body can be logged and retried. A short schema and runnable code.
- Validate video duration and resolution in Python before you submit
Fetch GET /v1/videos/models and check duration, resolution and aspect_ratio per model in about 25 lines of Python, before a Sume video job fails.
- Veo 2.0 and Veo 3.0 shut down June 30: what model id to call now
Google retired veo-2.0 and veo-3.0 ids on 2026-06-30. See which Veo and Omni ids the Gemini docs list now, and how to move the call to Sume.
Written by Sume