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

@@ -36,12 +36,26 @@ describe("project format", () => {
test("rejects invalid persisted layer crops", () => {
const document = projectDocument();
const layer = document.artboards[0]!.layers[0]!;
if (layer.type === "group") throw new Error("Expected image layer");
if (layer.type !== "image" && layer.type !== "raster") throw new Error("Expected image layer");
layer.sourceRect = { x: 0, y: 0, w: 0, h: 10 };
const source = JSON.stringify({ format: "image-studio-project", version: 1, savedAt: "2026-07-10T12:00:00.000Z", document });
expect(() => parseProject(source)).toThrow("invalid source crop");
});
test("round-trips adjustment layers and rejects out-of-range settings", () => {
const document = projectDocument();
document.artboards[0]!.layers.unshift({ id: "adjust", type: "adjustment", name: "Color", visible: true, locked: false, opacity: 1, transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 }, adjustment: { brightness: .2, contrast: 0, saturation: 0, colorBalance: { red: 0, green: 0, blue: 0 } } });
expect(parseProject(serializeProject(document)).document.artboards[0]?.layers[0]?.type).toBe("adjustment");
const invalid = JSON.parse(serializeProject(document)); invalid.document.artboards[0].layers[0].adjustment.brightness = 4;
expect(() => parseProject(JSON.stringify(invalid))).toThrow("invalid settings");
});
test("rejects nested adjustment scope", () => {
const document = projectDocument();
document.artboards[0]!.layers = [{ id: "group", type: "group", name: "Group", visible: true, locked: false, opacity: 1, transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 }, children: [{ id: "adjust", type: "adjustment", name: "Color", visible: true, locked: false, opacity: 1, transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 }, adjustment: { brightness: 0, contrast: 0, saturation: 0, colorBalance: { red: 0, green: 0, blue: 0 } } }] }];
expect(() => parseProject(serializeProject(document))).toThrow("artboard level");
});
test("creates safe project file names", () => {
expect(projectFileName(" Summer / Study ")).toBe("Summer-Study.image-studio.json");
expect(projectFileName("***")).toBe("untitled.image-studio.json");

View File

@@ -75,7 +75,7 @@ function assertImageDocument(value: unknown): asserts value is ImageDocument {
if (!isRecord(artboard) || typeof artboard.id !== "string" || typeof artboard.name !== "string" || !isRect(artboard.bounds) || !Array.isArray(artboard.layers)) {
throw new Error("The project contains an invalid artboard.");
}
assertLayers(artboard.layers);
assertLayers(artboard.layers, false);
}
}
@@ -103,14 +103,18 @@ function isOwnedAssetSource(source: string): boolean {
return source.startsWith("data:");
}
function assertLayers(value: unknown[]): asserts value is Layer[] {
function assertLayers(value: unknown[], nested: boolean): asserts value is Layer[] {
for (const layer of value) {
if (!isRecord(layer) || typeof layer.id !== "string" || typeof layer.name !== "string" || typeof layer.type !== "string" || typeof layer.visible !== "boolean" || typeof layer.locked !== "boolean" || typeof layer.opacity !== "number" || !isTransform(layer.transform)) {
throw new Error("The project contains an invalid layer.");
}
if (layer.type === "group") {
if (!Array.isArray(layer.children)) throw new Error("A project group is missing its children.");
assertLayers(layer.children);
assertLayers(layer.children, true);
} else if (layer.type === "adjustment") {
if (nested) throw new Error("Adjustment layers must stay at artboard level.");
if (!isAdjustment(layer.adjustment)) throw new Error("A project adjustment layer contains invalid settings.");
if (layer.layerMask !== undefined || layer.clippingMask !== undefined) throw new Error("Adjustment layers do not support masks.");
} else if ((layer.type === "image" || layer.type === "raster") && typeof layer.assetId !== "string") {
throw new Error("A project layer is missing its asset reference.");
} else if ((layer.type === "image" || layer.type === "raster") && layer.sourceRect !== undefined && (!isRect(layer.sourceRect) || layer.sourceRect.w < 1 || layer.sourceRect.h < 1)) {
@@ -121,6 +125,12 @@ function assertLayers(value: unknown[]): asserts value is Layer[] {
}
}
function isAdjustment(value: unknown): boolean {
if (!isRecord(value) || !isRecord(value.colorBalance)) return false;
return [value.brightness, value.contrast, value.saturation, value.colorBalance.red, value.colorBalance.green, value.colorBalance.blue]
.every((number) => isFiniteNumber(number) && number >= -1 && number <= 1);
}
function walkLayers(layers: Layer[], visit: (layer: Layer) => void): void {
for (const layer of layers) {
visit(layer);