feat(photo-editor): Ajax Image Editor client (Photoshop-parity tools)

Photoshop-parity editor client: layers, selection/masking, brushes,
smart filters, LUT import, Quick Edit, mobile gestures, workspaces,
batch operations, and the service-worker versioning (v978->v1006).

Source: vendor/photocraft (ad1cff44) photo-editor client files, merged
onto upstream/dev (a8c147b2). Conflict resolution documented in
pr_work/A_conflicts.md; editor files taken from vendor/photocraft
(upstream's editor is an earlier snapshot of the same feature).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
pewdiepie-archdaemon
2026-10-15 11:57:35 +00:00
co-authored by Claude Opus 5.5
parent c74f475679
commit 5fac693c1c
290 changed files with 34358 additions and 766 deletions
+145
View File
@@ -0,0 +1,145 @@
// Patch / Content-Aware Move pixel engine.
//
// Deterministic and fully offline (no AI/network). Two operations:
// * patchRegion — Photoshop-style Patch: replace what is inside the
// selection with the same-shaped source region shifted by (dx,dy),
// blended at a feathered edge so the seam disappears.
// * contentAwareMove — cut the selection, paste it at +(dx,dy), then
// fill the vacated hole by diffusion (repeatedly averaging the four
// neighbouring colours inward from the hole boundary).
//
// All geometry is passed as canvases + a layer offset so the helpers stay
// dependency-light and can be unit-tested with a fake `createCanvas`.
// `createCanvas(w,h)` must return a canvas-like object with getContext('2d').
// Draw `source` into a fresh canvas, keeping only the pixels where `mask`
// is opaque (destination-in). Returns the new canvas.
function restrictToMask(createCanvas, source, mask, width, height) {
const out = createCanvas(width, height);
const ctx = out.getContext('2d');
ctx.drawImage(source, 0, 0);
ctx.globalCompositeOperation = 'destination-in';
ctx.drawImage(mask, 0, 0);
return out;
}
// Feathered copy of a mask (soft edge for blending a patch). Returns a NEW
// canvas; the source is not mutated.
function featherMask(createCanvas, mask, width, height, radius) {
const out = createCanvas(width, height);
const ctx = out.getContext('2d');
const r = Math.max(0, Number(radius) || 0);
if (r > 0 && 'filter' in ctx) ctx.filter = `blur(${r}px)`;
ctx.drawImage(mask, 0, 0);
if (r > 0 && 'filter' in ctx) ctx.filter = 'none';
return out;
}
// Translate a document-space mask into layer space (shift by -offset).
function maskInLayerSpace(createCanvas, mask, offset, width, height) {
const out = createCanvas(width, height);
const ctx = out.getContext('2d');
ctx.drawImage(mask, -(offset.x || 0), -(offset.y || 0));
return out;
}
/**
* Patch: fill the selection with content sampled from (dx,dy) away.
* @returns {*} a NEW canvas the size of `layerCanvas` with the patch applied.
*/
export function patchRegion(createCanvas, layerCanvas, maskCanvas, offset, dx, dy, feather = 0) {
const w = layerCanvas.width;
const h = layerCanvas.height;
const out = createCanvas(w, h);
const octx = out.getContext('2d');
octx.drawImage(layerCanvas, 0, 0);
let localMask = maskInLayerSpace(createCanvas, maskCanvas, offset, w, h);
if (feather > 0) localMask = featherMask(createCanvas, localMask, w, h, feather);
// Source pixels sampled at (x+dx, y+dy) == the layer drawn shifted by
// (-dx, -dy), then clipped to the selection.
const shifted = createCanvas(w, h);
shifted.getContext('2d').drawImage(layerCanvas, -dx, -dy);
const sourceInMask = restrictToMask(createCanvas, shifted, localMask, w, h);
octx.drawImage(sourceInMask, 0, 0);
return out;
}
/**
* Content-Aware Move: move the selection content by (dx,dy) and fill the
* hole it leaves behind with a diffusion fill grown from the boundary.
*/
export function contentAwareMove(createCanvas, layerCanvas, maskCanvas, offset, dx, dy, iterations = 16) {
const w = layerCanvas.width;
const h = layerCanvas.height;
const localMask = maskInLayerSpace(createCanvas, maskCanvas, offset, w, h);
// Content inside the selection, cut out on its own.
const cut = restrictToMask(createCanvas, layerCanvas, localMask, w, h);
// Fill the hole. Start with the surrounding content (hole transparent),
// then grow the edge colour inwards one pixel per pass in all four
// directions (nearest-boundary fill, "destination-over" only writes where
// the buffer is still transparent). This reliably covers even a large hole,
// unlike a plain diffusion which needs O(radius^2) iterations to converge.
const fill = createCanvas(w, h);
const fctx = fill.getContext('2d');
fctx.drawImage(layerCanvas, 0, 0);
fctx.globalCompositeOperation = 'destination-out';
fctx.drawImage(localMask, 0, 0);
fctx.globalCompositeOperation = 'source-over';
let bw = 0;
let bh = 0;
try {
const md = localMask.getContext('2d').getImageData(0, 0, w, h).data;
let minX = w, minY = h, maxX = -1, maxY = -1;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
if (md[(y * w + x) * 4 + 3] > 128) {
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
}
if (maxX >= 0) { bw = maxX - minX + 1; bh = maxY - minY + 1; }
} catch (_) { /* no getImageData — use the requested pass count */ }
const radius = Math.ceil(Math.max(bw, bh) / 2);
const passes = Math.max(0, Math.min(256, Math.max(Math.round(iterations) || 0, radius + 2)));
fctx.globalCompositeOperation = 'destination-over';
for (let i = 0; i < passes; i++) {
fctx.drawImage(fill, 1, 0);
fctx.drawImage(fill, -1, 0);
fctx.drawImage(fill, 0, 1);
fctx.drawImage(fill, 0, -1);
}
fctx.globalCompositeOperation = 'source-over';
// Light smoothing pass so the grown fill is not blocky.
for (let i = 0; i < 3; i++) {
const avg = createCanvas(w, h);
const actx = avg.getContext('2d');
actx.globalAlpha = 0.25;
actx.drawImage(fill, 1, 0);
actx.drawImage(fill, -1, 0);
actx.drawImage(fill, 0, 1);
actx.drawImage(fill, 0, -1);
actx.globalAlpha = 1;
const maskedAvg = restrictToMask(createCanvas, avg, localMask, w, h);
fctx.drawImage(maskedAvg, 0, 0);
}
const filledHole = restrictToMask(createCanvas, fill, localMask, w, h);
const out = createCanvas(w, h);
const octx = out.getContext('2d');
octx.drawImage(layerCanvas, 0, 0);
octx.drawImage(filledHole, 0, 0);
octx.drawImage(cut, dx, dy);
return out;
}
export default { patchRegion, contentAwareMove };