mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-16 11:52:25 +02:00
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>
146 lines
5.7 KiB
JavaScript
146 lines
5.7 KiB
JavaScript
// 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 };
|