dragonflight/services/editor/apps/image/src/utils/flood-fill.ts
Zac b68f0c6aba feat(editor): integrate openreel-video as services/editor with MAM hooks
Vendored Augani/openreel-video (MIT) into services/editor and wired it to the MAM. Editor runs as its own container on port 47435. Library assets pull in via ?asset=<uuid>; render exports route back via POST /api/v1/upload/simple. Sidebar Editor link on every page; Edit button on every preview modal. See services/editor/INTEGRATION.md for the patch map.
2026-05-17 21:44:37 -04:00

156 lines
4.4 KiB
TypeScript

export interface FloodFillOptions {
tolerance: number;
contiguous: boolean;
antiAlias: boolean;
opacity: number;
}
function colorDistance(r1: number, g1: number, b1: number, a1: number, r2: number, g2: number, b2: number, a2: number): number {
return Math.sqrt(
Math.pow(r1 - r2, 2) +
Math.pow(g1 - g2, 2) +
Math.pow(b1 - b2, 2) +
Math.pow(a1 - a2, 2)
);
}
function hexToRgba(hex: string): [number, number, number, number] {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
if (result) {
return [
parseInt(result[1], 16),
parseInt(result[2], 16),
parseInt(result[3], 16),
255
];
}
return [0, 0, 0, 255];
}
export function floodFill(
imageData: ImageData,
startX: number,
startY: number,
fillColor: string,
options: FloodFillOptions
): ImageData {
const { width, height, data } = imageData;
const result = new ImageData(new Uint8ClampedArray(data), width, height);
const resultData = result.data;
const [fillR, fillG, fillB] = hexToRgba(fillColor);
const fillA = Math.round(options.opacity * 255);
const x = Math.floor(startX);
const y = Math.floor(startY);
if (x < 0 || x >= width || y < 0 || y >= height) {
return result;
}
const startIdx = (y * width + x) * 4;
const targetR = data[startIdx];
const targetG = data[startIdx + 1];
const targetB = data[startIdx + 2];
const targetA = data[startIdx + 3];
const threshold = options.tolerance * Math.sqrt(4);
if (
colorDistance(targetR, targetG, targetB, targetA, fillR, fillG, fillB, fillA) < 1
) {
return result;
}
const visited = new Uint8Array(width * height);
function matchesTarget(idx: number): boolean {
const r = data[idx];
const g = data[idx + 1];
const b = data[idx + 2];
const a = data[idx + 3];
return colorDistance(r, g, b, a, targetR, targetG, targetB, targetA) <= threshold;
}
function fillPixel(idx: number, strength: number = 1) {
const blendAlpha = fillA * strength;
const srcAlpha = resultData[idx + 3];
const outAlpha = blendAlpha + srcAlpha * (1 - blendAlpha / 255);
if (outAlpha > 0) {
resultData[idx] = (fillR * blendAlpha + resultData[idx] * srcAlpha * (1 - blendAlpha / 255)) / outAlpha;
resultData[idx + 1] = (fillG * blendAlpha + resultData[idx + 1] * srcAlpha * (1 - blendAlpha / 255)) / outAlpha;
resultData[idx + 2] = (fillB * blendAlpha + resultData[idx + 2] * srcAlpha * (1 - blendAlpha / 255)) / outAlpha;
resultData[idx + 3] = Math.round(outAlpha);
}
}
if (options.contiguous) {
const stack: [number, number][] = [[x, y]];
while (stack.length > 0) {
const [cx, cy] = stack.pop()!;
const pixelIdx = cy * width + cx;
if (visited[pixelIdx]) continue;
visited[pixelIdx] = 1;
const idx = pixelIdx * 4;
if (!matchesTarget(idx)) continue;
fillPixel(idx);
if (cx > 0) stack.push([cx - 1, cy]);
if (cx < width - 1) stack.push([cx + 1, cy]);
if (cy > 0) stack.push([cx, cy - 1]);
if (cy < height - 1) stack.push([cx, cy + 1]);
}
if (options.antiAlias) {
for (let py = 0; py < height; py++) {
for (let px = 0; px < width; px++) {
const pixelIdx = py * width + px;
if (!visited[pixelIdx]) continue;
const neighbors = [
px > 0 ? visited[pixelIdx - 1] : 1,
px < width - 1 ? visited[pixelIdx + 1] : 1,
py > 0 ? visited[pixelIdx - width] : 1,
py < height - 1 ? visited[pixelIdx + width] : 1,
];
const filledNeighbors = neighbors.filter(n => n === 1).length;
if (filledNeighbors < 4) {
const idx = pixelIdx * 4;
const edgeStrength = 0.5 + filledNeighbors * 0.125;
fillPixel(idx, edgeStrength);
}
}
}
}
} else {
for (let py = 0; py < height; py++) {
for (let px = 0; px < width; px++) {
const idx = (py * width + px) * 4;
if (matchesTarget(idx)) {
fillPixel(idx);
}
}
}
}
return result;
}
export function applyFloodFillToCanvas(
canvas: HTMLCanvasElement,
ctx: CanvasRenderingContext2D,
x: number,
y: number,
fillColor: string,
options: FloodFillOptions
): void {
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const filledData = floodFill(imageData, x, y, fillColor, options);
ctx.putImageData(filledData, 0, 0);
}