Skip to content

Image Cleaner: composable opt-in pipeline (metadata/C2PA, median+sharpen, local diffusion SynthID pass) + remove 40MB cap + resolution-aware diffusion #1763

Description

@atomantic

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:

  1. Metadata / C2PA clean (lossless) — strip provenance + identifying metadata
  2. Median pass + sharpen (lossy) — artifact reduction
  3. 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/regenerategallery-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.

  • Strip metadata & C2PA — default ON (lossless, safe)
    • Strip the C2PA caBX chunk (existing stripPngC2PAChunk).
    • Extend to EXIF, XMP, IPTC, and non-critical PNG text chunks (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 + sharpen — default OFF (lossy; blurs fine text). Existing median(3).sharpen(). Optionally expose median window + sharpen sigma as advanced controls.
  • Diffusion pass — disrupt SynthID — default OFF (lossy)
    • Sub-mode auto-selected by hardware: GPU FLUX round-trip when getRegenAvailability().available, else CPU light pass (always offered, clearly labeled lower-reliability). Show a note when no local FLUX runner is installed.
    • Ignore-zone mask (see below) — a user-painted region whose original pixels are composited back over the diffused result to preserve detail (comic dialog, faces, fine text).
    • Options (see Resolution section).

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

  1. 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.
  2. 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.
  3. 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.
  4. server/services/imageGen/variants.js — reuse/extend so the lightbox and image-clean share the GPU render entry (avoid drift).
  5. 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

  • Three operations presented as independently selectable steps (metadata ON, others OFF by default); selected steps run in pipeline order with a per-step report.
  • Metadata step strips C2PA and EXIF/XMP/IPTC/PNG text chunks (test-verified on a tagged sample).
  • 40MB byte cap removed (client + server); MAX_PIXELS retained; raw-byte upload handles >40MB without a 413.
  • Source resolution (W×H + MP) shown on select, before cleaning.
  • Diffusion step: GPU when available (job-queued, progress-tracked), CPU light fallback always offered and labeled; fed detected resolution; exposes strength + render-budget + model options.
  • GPU diffusion result does not pollute the gallery by default; optional explicit save-to-gallery.
  • Ignore-zone mask: when the diffusion step is on, the user can paint a preserve-region; masked pixels are composited back (feathered) over the diffused result; the quality-vs-disruption tradeoff is surfaced in the UI.
  • Honest SynthID messaging retained per step.
  • Tests cover per-step selection, metadata expansion, ignore-zone compositing, raw transport, and the regen reuse seam.

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.

Metadata

Metadata

Assignees

Labels

area:devtoolsDevtools/workspace/code-review surfacesenhancementNew feature or requestplanTracked by /do:replan

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions