mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-16 20:02:20 +02:00
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:
co-authored by
Claude Opus 5.5
parent
c74f475679
commit
5fac693c1c
@@ -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 };
|
||||
Reference in New Issue
Block a user