Summary
The Image Cleaner devtools page (/devtools/image-clean) currently runs a fixed pipeline with no user control: it always strips the C2PA caBX chunk and always applies a median(3)+sharpen denoise, then re-encodes. Users can't pick which operations run, and there's no way to run the real SynthID-disruption path (img2img diffusion round-trip) from this page — that capability only exists post-hoc in the ImageGen lightbox (server/services/imageGen/regen.js).
This issue turns Image Cleaner into a composable, opt-in pipeline where the user selects any combination of:
- Metadata / C2PA clean (lossless) — strip provenance + identifying metadata
- Median pass + sharpen (lossy) — artifact reduction
- Diffusion pass (lossy) — img2img round-trip that actually disrupts SynthID, with a CPU-only fallback
It also removes the artificial 40MB upload cap, switches the upload transport to raw bytes (no base64 inflation), and detects source resolution, feeding it into the diffusion step with appropriate options.
The three open questions from the original draft are resolved below (see Resolved design decisions), making this directly actionable.
Current behavior (verified)
| File |
What it does today |
client/src/pages/ImageClean.jsx |
Single auto-run Clean; MAX_BYTES = 40MB (L11); always C2PA + median/sharpen; base64-in-JSON upload via api.cleanImage |
server/routes/imageClean.js |
POST /api/image-clean; base64 in JSON body; MAX_BASE64_CHARS reject (L28); calls cleanImageBuffer() |
server/lib/imageClean.js |
cleanImageBuffer() always strips C2PA + median(3).sharpen(); MAX_INPUT_BYTES = 40MB (L48); MAX_PIXELS = 96MP bomb guard (L54) |
server/services/imageGen/regen.js |
img2img SynthID-disruption: GPU FLUX round-trip (job-queued) and applyLightRegen(buffer) CPU fallback. Pure helpers: clampRegenDimensions, resolveRegenStrengthDefault, getRegenAvailability |
server/routes/imageGen.js:677 |
POST /api/image-gen/:filename/regenerate — gallery-filename-oriented: assertGalleryFilename, reads sidecar, GPU path enqueueJob → gallery variant; CPU path applyLightRegenVariant writes _regen-light.png |
server/index.js:430 |
express.json({ limit: '55mb' }) — the cap that base64-in-JSON is sized against |
server/lib/navManifest.js:108 |
nav entry nav.devtools.image-clean |
Central integration finding: the existing regen flow assumes a gallery image (filename + sidecar + lineage stamping). Image Cleaner operates on an arbitrary uploaded buffer with no gallery presence. So the diffusion step cannot just call the existing /regenerate route — it needs a buffer/temp-oriented entry. The pure helpers (applyLightRegen, clampRegenDimensions, resolveRegenStrengthDefault, getRegenAvailability, buildRegenParams) are reusable; the gallery-write coupling is the only part that must be factored.
Resolved design decisions
Q1 — Upload transport (was: multipart vs. raised JSON limit) → raw bytes via express.raw()
Switch the upload from base64-in-JSON to raw image bytes as the request body, scoped to the clean route(s):
express.raw({ type: ['image/png','image/jpeg','image/webp'], limit: '256mb' })
- Kills the ~33% base64 inflation (the real reason large files were awkward), with no new dependency (no
multer/busboy — uses built-in Express).
- Pipeline options (which steps, strength, model, save-to-gallery) ride in the query string (
?metadata=1&denoise=1&diffusion=gpu&strength=0.25), not the body.
- The synchronous response returns the cleaned bytes as the body, with the small report (format, dims, before/after sizes,
c2paStripped, per-step status) in a response header (e.g. X-Clean-Report: <json>). The client uses the body blob directly for the After preview + download — removing today's base64→Blob decode step entirely.
- Remove the 40MB byte cap (
MAX_INPUT_BYTES/MAX_BASE64_CHARS, client MAX_BYTES). Keep MAX_PIXELS = 96MP — that's a decompression-bomb/OOM guard, not an arbitrary blocker. The express.raw limit becomes the only (generous) byte ceiling.
Q2 — Diffusion reuse (was: call regen.js directly vs. shared helper) → two-tier, both reuse regen.js
The diffusion step exposes two sub-modes, mirroring what regen.js already supports:
- CPU light pass (always available): call
applyLightRegen(buffer) directly — it's already buffer-in/buffer-out with no gallery dependency. Zero refactor. Synchronous, runs in the same request as the other steps. Honestly labeled "best-effort, lower reliability than the GPU round-trip."
- GPU FLUX round-trip (hardware-gated via
getRegenAvailability()): factor a shared render entry out of the lightbox's /regenerate route so both callers share it. The new entry renders an init temp file → output temp file through mediaJobQueue (GPU serialization is mandatory — cannot bypass the queue or two FLUX renders OOM the box), without creating a permanent gallery sidecar. Reuses resolveRegenBackend, buildRegenParams (fed temp paths instead of a gallery filename), clampRegenDimensions, and enqueueJob. Optional "Save result to gallery" button on the page for when the user does want to keep it.
- The one genuine lift: a non-gallery output mode for the regen render job (today
generateImage always writes a gallery file + sidecar). Add an output-target option (temp dir, no sidecar) and have the lightbox path keep its current gallery-write behavior. This keeps lineage/variant-grouping logic untouched for the lightbox.
Because the GPU path is async (job queue), when GPU diffusion is selected the request returns a jobId and the client tracks progress via the existing media-job progress channel (the same SSE/poll pattern MediaJobsQueue/MediaJobThumb already use), then fetches the result bytes. Sync-only pipelines (metadata, median/sharpen, CPU light) keep the simple request/response.
Q3 — Batch (was: now vs. follow-up) → single-file now; batch deferred
Keep single-file. GPU diffusion is GPU-serialized and slow, so batch is really a queue-management feature, not a quick add. File a future follow-up issue for batch (drag-N-files → per-file pipeline, GPU steps queued) once the single-file pipeline ships.
Proposed UX
Replace the single auto-run Clean with a step selector (toggles), executed server-side in order: metadata → median/sharpen → diffusion.
Ignore-zone alpha mask (preserve-region compositing)
Diffusion img2img — even at our minimal strength 0.25 with an empty prompt — redraws text, and diffusion models garble glyphs (comic-book dialog is the worst case). The CPU light pass and resize-squeeze also blur text (downscale→upscale degrades glyphs). To preserve those regions:
- The page lets the user paint an "ignore zone" over the source (brush + rectangle, with clear/undo) to mark regions to protect.
- After the diffusion render completes, the server alpha-composites the original pixels back into the masked regions with a feathered edge (
sharp composite + a blurred mask for a soft boundary). This is pure post-processing — no runner/inpaint support needed; the render stays full-frame.
- Honest tradeoff to surface in the UI: SynthID is spatially distributed, so any region preserved verbatim keeps its original watermark locally. The mask is therefore a deliberate per-region quality-vs-disruption choice the operator makes consciously. (A future refinement could apply only text-safe vectors — e.g. the color/contrast nudge, which doesn't garble glyphs — inside the mask instead of preserving verbatim; out of scope here.)
- Mask transport: the mask is tiny — send it as a second field (a 1-bit/8-bit PNG mask) alongside the raw image, or base64 in the options sidecar. Feather radius is a small advanced control.
- Applies to both diffusion sub-modes (GPU and CPU light), since both degrade text.
Result panel reports per-step status ("C2PA chunk removed", "denoised", "regenerated @ strength 0.25, ΔpixelDeltaPct/PSNR"). Preserve the honest SynthID messaging per step: metadata + median/sharpen do NOT defeat SynthID; only the diffusion pass disrupts it (and "disrupt," not "guaranteed removal").
Resolution detection + diffusion options
- On file select, detect and display source W×H + megapixels (client already shows post-clean dims via the report; surface them pre-clean too).
- Feed source resolution into the diffusion step:
- Show the render budget (
DEFAULT_MAX_REGEN_MEGAPIXELS = 2.0MP, env PORTOS_REGEN_MAX_MP) and warn when the source exceeds it (render downscales to fit FLUX's O(tokens²) attention budget, then upscales back to source dims via upscaleTo — explain the quality implication).
- Denoise strength slider — bounds from
getRegenAvailability() (strengthMin 0.02, strengthMax 0.6, strengthDefault 0.25). Default via resolveRegenStrengthDefault() (arbitrary uploads → conservative 0.25).
- Resolution-squeeze indicator (
REGEN_SQUEEZE_FACTOR = 0.9) — applied automatically as the second SynthID-disruption vector; note it.
- Model selection when multiple local img2img runners are available (
orderRegenCandidates).
- Optional max render megapixels override for the run.
Implementation plan (file-by-file)
Server
server/lib/imageClean.js — make cleanImageBuffer(buffer, { metadata, denoise }) apply steps conditionally instead of always-both. Extend the lossless metadata strip beyond caBX (EXIF/XMP/IPTC/PNG text). Remove MAX_INPUT_BYTES/MAX_BASE64_CHARS; keep MAX_PIXELS.
server/services/imageGen/regen.js (or a new imageClean service) — extract a shared buffer/temp → render entry from the lightbox regenerate flow with a non-gallery output mode; reuse buildRegenParams/resolveRegenBackend/enqueueJob.
server/routes/imageClean.js — switch to express.raw() upload + query-string options; run sync steps (metadata, median/sharpen, CPU light) and return bytes + X-Clean-Report; for GPU diffusion, save temp init, enqueue, return a jobId. Add a result-fetch + optional save-to-gallery endpoint. Wire Zod validation for the query options + the optional mask field.
server/services/imageGen/variants.js — reuse/extend so the lightbox and image-clean share the GPU render entry (avoid drift).
- Ignore-zone compositing — a pure helper (
server/lib/imageClean.js or a sibling) that takes (diffusedBuffer, originalBuffer, maskBuffer, { feather }) and sharp-composites the original back into masked regions with a feathered (blurred) mask. Applied after the diffusion sub-mode (GPU or CPU) completes. Unit-tested standalone.
Client
6. client/src/pages/ImageClean.jsx — step-selector UI; remove MAX_BYTES; raw upload via the new API; pre-clean resolution display; diffusion options (strength slider, model select, budget warning); ignore-zone mask painter (canvas overlay: brush + rectangle + clear/undo, exported as a PNG mask) shown when the diffusion step is enabled; per-step result report; async job-progress handling for the GPU path (reuse existing media-job progress hook); optional "Save to gallery".
7. client/src/services/api*.js — update/add the clean wrapper(s) for raw upload + optional mask + job-tracking; pass { silent: true } where the page owns its error UI.
8. client/src/lib/imageCleaners.js — keep client/server cleaner-default resolution mirrored if defaults change.
Tests
9. server/lib/imageClean.test.js — per-step selection (each combination), expanded metadata strip on a tagged sample (EXIF/XMP/PNG text removed; pixels intact for lossless), MAX_PIXELS retained, byte-cap gone.
10. Ignore-zone compositing test — masked region matches the original byte-for-byte at the mask core, feathered at the boundary; unmasked region matches the diffused buffer.
11. server/routes/imageClean.test.js — raw transport, query-option parsing, optional mask field, sync response + header report, GPU job enqueue path (mocked), result fetch, save-to-gallery.
12. Regen reuse: a test asserting the shared render entry's non-gallery mode doesn't write a sidecar, and the lightbox path still does.
Acceptance criteria
Background: what we found testing SynthID defeat
Recorded in docs/plans/2026-06-05-synthid-removal-eval.md (evaluated 5 public tools; shipped the VAE round-trip + universal resize-squeeze + CPU light fallback + fidelity metric):
- ~8% pixel-change floor from the VAE round-trip, flat across strength 0.02–0.25 (irreducible reconstruction error; PSNR ~30 dB).
- SynthID carriers are resolution-dependent → the downscale→upscale resize-squeeze disrupts them nearly for free.
- Important caveat: none of this is confirmed against Google's actual SynthID Detector (limited-access, no public API). All in-tool validation is the pixel-delta/PSNR fidelity metric, not detector clearance. Every "disrupt" claim is best-effort, not verified — which is why the UI must say "disrupt," never "remove."
This motivates the ignore-zone mask (diffusion garbles text, especially comic dialog) and is why we keep strength low.
Out of scope (follow-ups)
- Adversarial jamming (inject structured noise targeted at SynthID's frequency bands; the reverse-SynthID "FFT phase subtraction" family) — tracked separately as a scientific-curiosity experiment.
- "Adding our own/second watermark to overwhelm SynthID" — rejected as stated (can't embed a real Google SynthID; watermarks coexist by design). The only salvageable idea is the jamming above; an intentional PortOS provenance mark is a different feature and does not defeat Google's mark.
- Batch / multi-file processing → file a
future issue after this ships.
- Any cloud/remote diffusion provider — diffusion stays local-runner-only by design.
Summary
The Image Cleaner devtools page (
/devtools/image-clean) currently runs a fixed pipeline with no user control: it always strips the C2PAcaBXchunk and always applies a median(3)+sharpen denoise, then re-encodes. Users can't pick which operations run, and there's no way to run the real SynthID-disruption path (img2img diffusion round-trip) from this page — that capability only exists post-hoc in the ImageGen lightbox (server/services/imageGen/regen.js).This issue turns Image Cleaner into a composable, opt-in pipeline where the user selects any combination of:
It also removes the artificial 40MB upload cap, switches the upload transport to raw bytes (no base64 inflation), and detects source resolution, feeding it into the diffusion step with appropriate options.
The three open questions from the original draft are resolved below (see Resolved design decisions), making this directly actionable.
Current behavior (verified)
client/src/pages/ImageClean.jsxMAX_BYTES = 40MB(L11); always C2PA + median/sharpen; base64-in-JSON upload viaapi.cleanImageserver/routes/imageClean.jsPOST /api/image-clean; base64 in JSON body;MAX_BASE64_CHARSreject (L28); callscleanImageBuffer()server/lib/imageClean.jscleanImageBuffer()always strips C2PA +median(3).sharpen();MAX_INPUT_BYTES = 40MB(L48);MAX_PIXELS = 96MPbomb guard (L54)server/services/imageGen/regen.jsapplyLightRegen(buffer)CPU fallback. Pure helpers:clampRegenDimensions,resolveRegenStrengthDefault,getRegenAvailabilityserver/routes/imageGen.js:677POST /api/image-gen/:filename/regenerate— gallery-filename-oriented:assertGalleryFilename, reads sidecar, GPU pathenqueueJob→ gallery variant; CPU pathapplyLightRegenVariantwrites_regen-light.pngserver/index.js:430express.json({ limit: '55mb' })— the cap that base64-in-JSON is sized againstserver/lib/navManifest.js:108nav.devtools.image-cleanCentral integration finding: the existing regen flow assumes a gallery image (filename + sidecar + lineage stamping). Image Cleaner operates on an arbitrary uploaded buffer with no gallery presence. So the diffusion step cannot just call the existing
/regenerateroute — it needs a buffer/temp-oriented entry. The pure helpers (applyLightRegen,clampRegenDimensions,resolveRegenStrengthDefault,getRegenAvailability,buildRegenParams) are reusable; the gallery-write coupling is the only part that must be factored.Resolved design decisions
Q1 — Upload transport (was: multipart vs. raised JSON limit) → raw bytes via
express.raw()Switch the upload from base64-in-JSON to raw image bytes as the request body, scoped to the clean route(s):
multer/busboy— uses built-in Express).?metadata=1&denoise=1&diffusion=gpu&strength=0.25), not the body.c2paStripped, per-step status) in a response header (e.g.X-Clean-Report: <json>). The client uses the body blob directly for the After preview + download — removing today's base64→Blob decode step entirely.MAX_INPUT_BYTES/MAX_BASE64_CHARS, clientMAX_BYTES). KeepMAX_PIXELS = 96MP— that's a decompression-bomb/OOM guard, not an arbitrary blocker. Theexpress.rawlimitbecomes the only (generous) byte ceiling.Q2 — Diffusion reuse (was: call regen.js directly vs. shared helper) → two-tier, both reuse regen.js
The diffusion step exposes two sub-modes, mirroring what
regen.jsalready supports:applyLightRegen(buffer)directly — it's already buffer-in/buffer-out with no gallery dependency. Zero refactor. Synchronous, runs in the same request as the other steps. Honestly labeled "best-effort, lower reliability than the GPU round-trip."getRegenAvailability()): factor a shared render entry out of the lightbox's/regenerateroute so both callers share it. The new entry renders an init temp file → output temp file throughmediaJobQueue(GPU serialization is mandatory — cannot bypass the queue or two FLUX renders OOM the box), without creating a permanent gallery sidecar. ReusesresolveRegenBackend,buildRegenParams(fed temp paths instead of a gallery filename),clampRegenDimensions, andenqueueJob. Optional "Save result to gallery" button on the page for when the user does want to keep it.generateImagealways writes a gallery file + sidecar). Add an output-target option (temp dir, no sidecar) and have the lightbox path keep its current gallery-write behavior. This keeps lineage/variant-grouping logic untouched for the lightbox.Because the GPU path is async (job queue), when GPU diffusion is selected the request returns a jobId and the client tracks progress via the existing media-job progress channel (the same SSE/poll pattern
MediaJobsQueue/MediaJobThumbalready use), then fetches the result bytes. Sync-only pipelines (metadata, median/sharpen, CPU light) keep the simple request/response.Q3 — Batch (was: now vs. follow-up) → single-file now; batch deferred
Keep single-file. GPU diffusion is GPU-serialized and slow, so batch is really a queue-management feature, not a quick add. File a
futurefollow-up issue for batch (drag-N-files → per-file pipeline, GPU steps queued) once the single-file pipeline ships.Proposed UX
Replace the single auto-run Clean with a step selector (toggles), executed server-side in order: metadata → median/sharpen → diffusion.
caBXchunk (existingstripPngC2PAChunk).tEXt/zTXt/iTXt/eXIf). sharp drops most metadata on re-encode by default; verify PNG text-chunk coverage and extend the chunk walker if needed.median(3).sharpen(). Optionally expose median window + sharpen sigma as advanced controls.getRegenAvailability().available, else CPU light pass (always offered, clearly labeled lower-reliability). Show a note when no local FLUX runner is installed.Ignore-zone alpha mask (preserve-region compositing)
Diffusion img2img — even at our minimal strength 0.25 with an empty prompt — redraws text, and diffusion models garble glyphs (comic-book dialog is the worst case). The CPU light pass and resize-squeeze also blur text (downscale→upscale degrades glyphs). To preserve those regions:
sharpcomposite+ a blurred mask for a soft boundary). This is pure post-processing — no runner/inpaint support needed; the render stays full-frame.Result panel reports per-step status ("C2PA chunk removed", "denoised", "regenerated @ strength 0.25, ΔpixelDeltaPct/PSNR"). Preserve the honest SynthID messaging per step: metadata + median/sharpen do NOT defeat SynthID; only the diffusion pass disrupts it (and "disrupt," not "guaranteed removal").
Resolution detection + diffusion options
DEFAULT_MAX_REGEN_MEGAPIXELS = 2.0MP, envPORTOS_REGEN_MAX_MP) and warn when the source exceeds it (render downscales to fit FLUX's O(tokens²) attention budget, then upscales back to source dims viaupscaleTo— explain the quality implication).getRegenAvailability()(strengthMin 0.02,strengthMax 0.6,strengthDefault 0.25). Default viaresolveRegenStrengthDefault()(arbitrary uploads → conservative 0.25).REGEN_SQUEEZE_FACTOR = 0.9) — applied automatically as the second SynthID-disruption vector; note it.orderRegenCandidates).Implementation plan (file-by-file)
Server
server/lib/imageClean.js— makecleanImageBuffer(buffer, { metadata, denoise })apply steps conditionally instead of always-both. Extend the lossless metadata strip beyondcaBX(EXIF/XMP/IPTC/PNG text). RemoveMAX_INPUT_BYTES/MAX_BASE64_CHARS; keepMAX_PIXELS.server/services/imageGen/regen.js(or a newimageCleanservice) — extract a shared buffer/temp → render entry from the lightbox regenerate flow with a non-gallery output mode; reusebuildRegenParams/resolveRegenBackend/enqueueJob.server/routes/imageClean.js— switch toexpress.raw()upload + query-string options; run sync steps (metadata, median/sharpen, CPU light) and return bytes +X-Clean-Report; for GPU diffusion, save temp init, enqueue, return a jobId. Add a result-fetch + optional save-to-gallery endpoint. Wire Zod validation for the query options + the optional mask field.server/services/imageGen/variants.js— reuse/extend so the lightbox and image-clean share the GPU render entry (avoid drift).server/lib/imageClean.jsor a sibling) that takes(diffusedBuffer, originalBuffer, maskBuffer, { feather })andsharp-composites the original back into masked regions with a feathered (blurred) mask. Applied after the diffusion sub-mode (GPU or CPU) completes. Unit-tested standalone.Client
6.
client/src/pages/ImageClean.jsx— step-selector UI; removeMAX_BYTES; raw upload via the new API; pre-clean resolution display; diffusion options (strength slider, model select, budget warning); ignore-zone mask painter (canvas overlay: brush + rectangle + clear/undo, exported as a PNG mask) shown when the diffusion step is enabled; per-step result report; async job-progress handling for the GPU path (reuse existing media-job progress hook); optional "Save to gallery".7.
client/src/services/api*.js— update/add the clean wrapper(s) for raw upload + optional mask + job-tracking; pass{ silent: true }where the page owns its error UI.8.
client/src/lib/imageCleaners.js— keep client/server cleaner-default resolution mirrored if defaults change.Tests
9.
server/lib/imageClean.test.js— per-step selection (each combination), expanded metadata strip on a tagged sample (EXIF/XMP/PNG text removed; pixels intact for lossless),MAX_PIXELSretained, byte-cap gone.10. Ignore-zone compositing test — masked region matches the original byte-for-byte at the mask core, feathered at the boundary; unmasked region matches the diffused buffer.
11.
server/routes/imageClean.test.js— raw transport, query-option parsing, optional mask field, sync response + header report, GPU job enqueue path (mocked), result fetch, save-to-gallery.12. Regen reuse: a test asserting the shared render entry's non-gallery mode doesn't write a sidecar, and the lightbox path still does.
Acceptance criteria
MAX_PIXELSretained; raw-byte upload handles >40MB without a 413.Background: what we found testing SynthID defeat
Recorded in
docs/plans/2026-06-05-synthid-removal-eval.md(evaluated 5 public tools; shipped the VAE round-trip + universal resize-squeeze + CPU light fallback + fidelity metric):This motivates the ignore-zone mask (diffusion garbles text, especially comic dialog) and is why we keep strength low.
Out of scope (follow-ups)
futureissue after this ships.