feat: add non-destructive adjustment layers

This commit is contained in:
syntaxbullet
2026-07-11 12:31:06 +02:00
parent b928fb599b
commit 606426c885
30 changed files with 427 additions and 49 deletions

View File

@@ -0,0 +1,10 @@
import { describe, expect, test } from "bun:test";
import { adjustPixel } from "./exportArtboardPng";
import { neutralColorAdjustment } from "@core/adjustment-layer";
describe("adjustment pixel calculation", () => {
test("neutral adjustment is stable", () => expect(adjustPixel([20, 100, 220], neutralColorAdjustment)).toEqual([20, 100, 220]));
test("brightness, contrast, saturation and balance use deterministic ordering", () => {
expect(adjustPixel([100, 120, 140], { brightness: .1, contrast: .2, saturation: -.5, colorBalance: { red: .1, green: 0, blue: -.1 } })).toEqual([153, 150, 146]);
});
});

View File

@@ -55,7 +55,18 @@ async function drawLayer(
context.globalAlpha *= layer.opacity;
if (layer.type === "group") {
for (const child of renderStack(layer.children)) await drawLayer(context, child, layerTree, assets, artboardBounds, options);
const groupCanvas = createArtboardCanvas(artboardBounds);
const groupContext = translatedContext(groupCanvas, artboardBounds);
const groupMaskIds = collectMaskLayerIds(layer.children);
for (const child of renderStack(layer.children)) await drawLayer(groupContext, child, layer.children, assets, artboardBounds, { maskLayerIds: groupMaskIds });
groupContext.restore();
context.drawImage(groupCanvas, artboardBounds.x, artboardBounds.y);
context.restore();
return;
}
if (layer.type === "adjustment") {
applyAdjustmentToCanvas(context, layer.adjustment, layer.opacity);
context.restore();
return;
}
@@ -73,7 +84,7 @@ async function drawLayer(
context.restore();
}
export function resolveLayerDrawImage(layer: Exclude<Layer, { type: "group" }>, intrinsicSize: Size) {
export function resolveLayerDrawImage(layer: Extract<Layer, { type: "image" | "raster" }>, intrinsicSize: Size) {
const source = layer.sourceRect ?? { x: 0, y: 0, ...intrinsicSize };
return {
source,
@@ -86,6 +97,34 @@ export function resolveLayerDrawImage(layer: Exclude<Layer, { type: "group" }>,
};
}
export function adjustPixel(rgb: readonly [number, number, number], adjustment: Extract<Layer, { type: "adjustment" }>["adjustment"]): [number, number, number] {
let [red, green, blue] = rgb.map((value) => value / 255) as [number, number, number];
red += adjustment.brightness + adjustment.colorBalance.red;
green += adjustment.brightness + adjustment.colorBalance.green;
blue += adjustment.brightness + adjustment.colorBalance.blue;
const contrast = 1 + adjustment.contrast;
red = (red - 0.5) * contrast + 0.5;
green = (green - 0.5) * contrast + 0.5;
blue = (blue - 0.5) * contrast + 0.5;
const luminance = red * 0.2126 + green * 0.7152 + blue * 0.0722;
const saturation = 1 + adjustment.saturation;
return [red, green, blue].map((value) => Math.round(Math.max(0, Math.min(1, luminance + (value - luminance) * saturation)) * 255)) as [number, number, number];
}
function applyAdjustmentToCanvas(context: CanvasRenderingContext2D, adjustment: Extract<Layer, { type: "adjustment" }>["adjustment"], opacity: number) {
if (opacity <= 0) return;
const { width, height } = context.canvas;
const image = context.getImageData(0, 0, width, height);
for (let index = 0; index < image.data.length; index += 4) {
const original: [number, number, number] = [image.data[index]!, image.data[index + 1]!, image.data[index + 2]!];
const adjusted = adjustPixel(original, adjustment);
image.data[index] = Math.round(original[0] + (adjusted[0] - original[0]) * opacity);
image.data[index + 1] = Math.round(original[1] + (adjusted[1] - original[1]) * opacity);
image.data[index + 2] = Math.round(original[2] + (adjusted[2] - original[2]) * opacity);
}
context.putImageData(image, 0, 0);
}
async function drawMaskedLayer(
context: CanvasRenderingContext2D,
layer: Layer,