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,39 @@
import { describe, expect, test } from "bun:test";
import { createInitialAppState } from "@editor/initial-state";
import { neutralColorAdjustment } from "@core/adjustment-layer";
import { documentAddArtboardCommand, documentAddAdjustmentLayerCommand, documentSetAdjustmentCommand, documentDuplicateLayerCommand, documentMoveLayerCommand } from "./document";
const transform = { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 };
function stateWithAdjustment() {
let state = documentAddArtboardCommand.execute({ state: createInitialAppState("test") }, { id: "board", name: "Board", bounds: { x: 0, y: 0, w: 10, h: 10 } });
state = documentAddAdjustmentLayerCommand.execute({ state }, { artboardId: "board", layer: { id: "adjust", type: "adjustment", name: "Color", visible: true, locked: false, opacity: 1, transform, adjustment: neutralColorAdjustment } });
return state;
}
describe("adjustment commands", () => {
test("creates and selects a persistent adjustment node", () => {
const state = stateWithAdjustment();
expect(state.document.artboards[0]?.layers[0]?.type).toBe("adjustment");
expect(state.editor.selection.layerIds).toEqual(["adjust"]);
});
test("validates edits and preserves locked layers", () => {
const state = stateWithAdjustment();
const changed = documentSetAdjustmentCommand.execute({ state }, { layerId: "adjust", adjustment: { ...neutralColorAdjustment, brightness: .25 } });
const invalid = documentSetAdjustmentCommand.execute({ state: changed }, { layerId: "adjust", adjustment: { ...neutralColorAdjustment, contrast: 2 } });
expect((changed.document.artboards[0]?.layers[0] as { adjustment: { brightness: number } }).adjustment.brightness).toBe(.25);
expect(invalid).toBe(changed);
});
test("duplicates and moves adjustment nodes like tree siblings", () => {
let state = stateWithAdjustment();
state = documentDuplicateLayerCommand.execute({ state }, { layerId: "adjust", idByLayerId: { adjust: "copy" } });
state = documentMoveLayerCommand.execute({ state }, { layerId: "copy", toArtboardId: "board", toIndex: 0 });
expect(state.document.artboards[0]?.layers.map((layer) => [layer.id, layer.type])).toEqual([["copy", "adjustment"], ["adjust", "adjustment"]]);
});
test("rejects nested creation, movement, and grouping to preserve exact scope", () => {
const state = stateWithAdjustment();
const nested = documentAddAdjustmentLayerCommand.execute({ state }, { artboardId: "board", parentGroupId: "group", layer: { id: "nested", type: "adjustment", name: "Nested", visible: true, locked: false, opacity: 1, transform, adjustment: neutralColorAdjustment } });
const moved = documentMoveLayerCommand.execute({ state }, { layerId: "adjust", toArtboardId: "board", toParentGroupId: "group", toIndex: 0 });
expect(nested).toBe(state);
expect(moved).toBe(state);
});
});

View File

@@ -8,6 +8,7 @@ import { getLayerMask } from "@core/layer-mask-utils";
import type { RasterLayer } from "@core/raster-layer";
import type { Layer } from "@core/layer";
import type { LayerGroup } from "@core/layer-group";
import type { AdjustmentLayer, ColorAdjustment } from "@core/adjustment-layer";
import type { Command } from "./command";
import { commandIds } from "./ids";
@@ -73,6 +74,8 @@ export type DocumentAddGroupLayerPayload = {
parentGroupId?: LayerId;
group: LayerGroup;
};
export type DocumentAddAdjustmentLayerPayload = { artboardId: ArtboardId; parentGroupId?: LayerId; layer: AdjustmentLayer };
export type DocumentSetAdjustmentPayload = { layerId: LayerId; adjustment: ColorAdjustment };
export type DocumentMoveLayerPayload = {
layerId: LayerId;
@@ -353,10 +356,31 @@ export const documentAddGroupLayerCommand: Command<DocumentAddGroupLayerPayload>
},
};
export const documentAddAdjustmentLayerCommand: Command<DocumentAddAdjustmentLayerPayload> = {
id: commandIds.documentAddAdjustmentLayer,
name: "Add adjustment layer",
execute({ state }, payload) {
if (payload.parentGroupId || !validAdjustment(payload.layer.adjustment)) return state;
return { ...state, document: insertLayer(state.document, payload.artboardId, undefined, payload.layer, 0), editor: { ...state.editor, selection: { artboardId: payload.artboardId, layerIds: [payload.layer.id] } } };
},
};
export const documentSetAdjustmentCommand: Command<DocumentSetAdjustmentPayload> = {
id: commandIds.documentSetAdjustment,
name: "Edit adjustment layer",
execute({ state }, payload) {
const location = findLayerLocation(state.document, payload.layerId);
if (!location || location.layer.type !== "adjustment" || location.layer.locked || !validAdjustment(payload.adjustment)) return state;
return { ...state, document: mapLayerInDocument(state.document, payload.layerId, (layer) => layer.type === "adjustment" ? { ...layer, adjustment: payload.adjustment } : layer) };
},
};
export const documentMoveLayerCommand: Command<DocumentMoveLayerPayload> = {
id: commandIds.documentMoveLayer,
name: "Move layer",
execute({ state }, payload) {
const source = findLayerLocation(state.document, payload.layerId);
if (source?.layer.type === "adjustment" && payload.toParentGroupId) return state;
if (payload.toParentGroupId && !findGroup(state.document, payload.toParentGroupId)) return state;
const removed = removeLayerFromDocument(state.document, payload.layerId);
@@ -390,6 +414,7 @@ export const documentGroupLayersCommand: Command<DocumentGroupLayersPayload> = {
const location = findLayerLocation(state.document, layerId);
return location ? [location] : [];
});
if (requestedLocations.some((location) => location.layer.type === "adjustment")) return state;
if (requestedLocations.length !== requestedIds.length) return state;
const firstLocation = requestedLocations[0];
@@ -463,14 +488,14 @@ export const documentSetLayerSourceRectCommand: Command<DocumentSetLayerSourceRe
name: "Crop layer",
execute({ state }, payload) {
const location = findLayerLocation(state.document, payload.layerId);
if (!location || location.layer.type === "group" || location.layer.locked || location.layer.transform.rotation !== 0) return state;
if (!location || location.layer.type === "group" || location.layer.type === "adjustment" || location.layer.locked || location.layer.transform.rotation !== 0) return state;
const leaf = location.layer;
const asset = state.document.assets.find((candidate) => candidate.id === leaf.assetId);
if (!asset) return state;
const sourceRect = payload.sourceRect ? clampSourceRect(payload.sourceRect, asset.intrinsicSize.w, asset.intrinsicSize.h) : undefined;
if (payload.sourceRect && !sourceRect) return state;
return { ...state, document: mapLayerInDocument(state.document, payload.layerId, (layer) => {
if (layer.type === "group") return layer;
if (layer.type === "group" || layer.type === "adjustment") return layer;
if (sourceRect) return { ...layer, sourceRect };
const uncropped = { ...layer };
delete uncropped.sourceRect;
@@ -549,6 +574,7 @@ export const documentSetLayerClippingMaskCommand: Command<DocumentSetLayerClippi
const targetLocation = findLayerLocation(state.document, payload.layerId);
const maskLocation = findLayerLocation(state.document, payload.maskLayerId);
if (!targetLocation || !maskLocation) return state;
if (targetLocation.layer.type === "adjustment") return state;
if (targetLocation.artboardId !== maskLocation.artboardId || targetLocation.parentGroupId !== maskLocation.parentGroupId) return state;
const removed = removeLayerFromDocument(state.document, payload.layerId);
@@ -575,7 +601,7 @@ export const documentAddLayerMaskCommand: Command<DocumentAddLayerMaskPayload> =
name: "Add layer mask",
execute({ state }, payload) {
const targetLocation = findLayerLocation(state.document, payload.layerId);
if (!targetLocation || targetLocation.layer.type === "group") return state;
if (!targetLocation || targetLocation.layer.type === "group" || targetLocation.layer.type === "adjustment") return state;
const existingMaskId = getLayerMask(targetLocation.layer)?.maskLayerId;
if (existingMaskId) {
@@ -638,7 +664,7 @@ export const documentApplyLayerMaskOperationCommand: Command<DocumentApplyLayerM
if (!payload.source.trim()) return state;
const maskLocation = findLayerLocation(state.document, payload.maskLayerId);
if (!maskLocation || maskLocation.layer.type === "group") return state;
if (!maskLocation || maskLocation.layer.type === "group" || maskLocation.layer.type === "adjustment") return state;
if (!isReferencedMaskLayer(state.document, payload.maskLayerId)) return state;
const maskAssetId = maskLocation.layer.assetId;
@@ -730,6 +756,8 @@ export const documentCommands = [
documentAddImageLayerCommand,
documentAddRasterLayerCommand,
documentAddGroupLayerCommand,
documentAddAdjustmentLayerCommand,
documentSetAdjustmentCommand,
documentMoveLayerCommand,
documentGroupLayersCommand,
documentUngroupLayerCommand,
@@ -750,6 +778,10 @@ function validRect(rect: Rect): Rect | undefined {
return [rect.x, rect.y, rect.w, rect.h].every(Number.isFinite) && rect.w >= 1 && rect.h >= 1 ? { ...rect } : undefined;
}
function validAdjustment(value: ColorAdjustment): boolean {
return [value.brightness, value.contrast, value.saturation, value.colorBalance.red, value.colorBalance.green, value.colorBalance.blue].every((number) => Number.isFinite(number) && number >= -1 && number <= 1);
}
function clampSourceRect(rect: Rect, width: number, height: number): Rect | undefined {
if (![rect.x, rect.y, rect.w, rect.h].every(Number.isFinite)) return undefined;
const x = Math.max(0, Math.min(width - 1, rect.x));
@@ -760,7 +792,7 @@ function clampSourceRect(rect: Rect, width: number, height: number): Rect | unde
}
function scaleLayerTree(layers: Layer[], before: Rect, after: Rect, scaleX: number, scaleY: number): Layer[] {
return layers.map((layer) => layer.type === "group" ? {
return layers.map((layer) => layer.type === "adjustment" ? layer : layer.type === "group" ? {
...layer,
children: scaleLayerTree(layer.children, before, after, scaleX, scaleY),
} : {

View File

@@ -12,6 +12,8 @@ export const commandIds = {
documentAddImageLayer: "document.addImageLayer",
documentAddRasterLayer: "document.addRasterLayer",
documentAddGroupLayer: "document.addGroupLayer",
documentAddAdjustmentLayer: "document.addAdjustmentLayer",
documentSetAdjustment: "document.setAdjustment",
documentMoveLayer: "document.moveLayer",
documentGroupLayers: "document.groupLayers",
documentUngroupLayer: "document.ungroupLayer",

View File

@@ -5,6 +5,8 @@ export {
documentAddArtboardCommand,
documentAddAssetCommand,
documentAddGroupLayerCommand,
documentAddAdjustmentLayerCommand,
documentSetAdjustmentCommand,
documentAddImageLayerCommand,
documentAddLayerMaskCommand,
documentAddRasterLayerCommand,
@@ -32,6 +34,8 @@ export type {
DocumentAddArtboardPayload,
DocumentAddAssetPayload,
DocumentAddGroupLayerPayload,
DocumentAddAdjustmentLayerPayload,
DocumentSetAdjustmentPayload,
DocumentAddImageLayerPayload,
DocumentAddLayerMaskPayload,
DocumentAddRasterLayerPayload,

View File

@@ -3,6 +3,8 @@ import type {
DocumentAddArtboardPayload,
DocumentAddAssetPayload,
DocumentAddGroupLayerPayload,
DocumentAddAdjustmentLayerPayload,
DocumentSetAdjustmentPayload,
DocumentAddImageLayerPayload,
DocumentAddLayerMaskPayload,
DocumentAddRasterLayerPayload,
@@ -75,6 +77,8 @@ export type CommandPayloads = {
[commandIds.documentAddImageLayer]: DocumentAddImageLayerPayload;
[commandIds.documentAddRasterLayer]: DocumentAddRasterLayerPayload;
[commandIds.documentAddGroupLayer]: DocumentAddGroupLayerPayload;
[commandIds.documentAddAdjustmentLayer]: DocumentAddAdjustmentLayerPayload;
[commandIds.documentSetAdjustment]: DocumentSetAdjustmentPayload;
[commandIds.documentAddLayerMask]: DocumentAddLayerMaskPayload;
[commandIds.documentApplyLayerMaskOperation]: DocumentApplyLayerMaskOperationPayload;
[commandIds.documentRemoveLayerMask]: DocumentRemoveLayerMaskPayload;

View File

@@ -10,7 +10,7 @@ export function applyTransformTargetBounds(document: ImageDocument, target: Tran
if (target.type === "artboard") return { ...document, artboards: document.artboards.map((artboard) => artboard.id === target.id ? { ...artboard, bounds: { ...bounds } } : artboard) };
const layer = findLayer(document, target.id);
if (!layer) return document;
return layer.type === "group" ? applyGroupBounds(document, layer.id, bounds) : applyLeafBounds(document, layer.id, bounds);
return layer.type === "group" ? applyGroupBounds(document, layer.id, bounds) : layer.type === "adjustment" ? document : applyLeafBounds(document, layer.id, bounds);
}
function applyLeafBounds(document: ImageDocument, layerId: LayerId, bounds: Rect): ImageDocument {
@@ -39,13 +39,14 @@ function mapGroupBounds(document: ImageDocument, layers: Layer[], groupId: Layer
function scaleSubtree(document: ImageDocument, layer: Layer, initial: Rect, bounds: Rect, scale: { x: number; y: number }): Layer {
if (layer.type === "group") return { ...layer, children: layer.children.map((child) => scaleSubtree(document, child, initial, bounds, scale)) };
if (layer.type === "adjustment") return layer;
if (!document.assets.some((asset) => asset.id === layer.assetId)) return layer;
return { ...layer, transform: { ...layer.transform, position: { x: bounds.x + (layer.transform.position.x - initial.x) * scale.x, y: bounds.y + (layer.transform.position.y - initial.y) * scale.y }, scale: { x: layer.transform.scale.x * scale.x, y: layer.transform.scale.y * scale.y } } };
}
function mapLeafBounds(document: ImageDocument, layers: Layer[], layerId: LayerId, bounds: Rect): Layer[] {
return layers.map((layer) => {
if (layer.id === layerId && layer.type !== "group") {
if (layer.id === layerId && layer.type !== "group" && layer.type !== "adjustment") {
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
if (!asset) return layer;
const source = layer.sourceRect ?? { x: 0, y: 0, ...asset.intrinsicSize };

26
core/adjustment-layer.ts Normal file
View File

@@ -0,0 +1,26 @@
import type { BaseLayer } from "./base-layer";
export type ColorAdjustment = {
brightness: number;
contrast: number;
saturation: number;
colorBalance: { red: number; green: number; blue: number };
};
/**
* A non-destructive adjustment affecting pixels already composited beneath it
* on its artboard. Adjustment layers are top-level artboard nodes so live WebGL
* and exported PNG compositing have identical scope. Transform and masks are
* intentionally unsupported.
*/
export type AdjustmentLayer = BaseLayer & {
type: "adjustment";
adjustment: ColorAdjustment;
};
export const neutralColorAdjustment: ColorAdjustment = {
brightness: 0,
contrast: 0,
saturation: 0,
colorBalance: { red: 0, green: 0, blue: 0 },
};

View File

@@ -15,6 +15,8 @@ export type {
} from "./geometry";
export type { ArtboardId, AssetId, DocumentId, GenerationCandidateId, GenerationJobId, LayerId } from "./id";
export type { ImageLayer } from "./image-layer";
export type { AdjustmentLayer, ColorAdjustment } from "./adjustment-layer";
export { neutralColorAdjustment } from "./adjustment-layer";
export type { Layer } from "./layer";
export type { LayerMask } from "./layer-mask";
export { getLayerMask, hasLayerMask } from "./layer-mask-utils";

View File

@@ -1,5 +1,6 @@
import type { ImageLayer } from "./image-layer";
import type { LayerGroup } from "./layer-group";
import type { RasterLayer } from "./raster-layer";
import type { AdjustmentLayer } from "./adjustment-layer";
export type Layer = ImageLayer | RasterLayer | LayerGroup;
export type Layer = ImageLayer | RasterLayer | LayerGroup | AdjustmentLayer;

View File

@@ -75,6 +75,8 @@ export function resolveIndexedLayerBounds(index: DocumentReadIndex, layerOrId: L
switch (layer.type) {
case "group":
return unionLayerBounds(index, layer.children);
case "adjustment":
return undefined;
case "image":
case "raster": {
const asset = index.assetById.get(layer.assetId);
@@ -152,6 +154,7 @@ function unionLayerBounds(index: DocumentReadIndex, layers: readonly Layer[]): R
stack.push(...layer.children);
continue;
}
if (layer.type === "adjustment") continue;
const layerBounds = resolveIndexedLayerBounds(index, layer);
if (!layerBounds) continue;
bounds = bounds ? unionRects(bounds, layerBounds) : layerBounds;

View File

@@ -16,6 +16,7 @@ type InputBaseLayer = {
export type InputLayer =
| (InputBaseLayer & { type: "group"; children: InputLayer[] })
| (InputBaseLayer & { type: "adjustment" })
| (InputBaseLayer & { type: "image" | "raster"; assetId: string });
export type InputDocument = {

View File

@@ -7,6 +7,7 @@ import type { DocumentReadIndex, IndexedLayerInfo } from "@editor/document-index
import { resolveIndexedLayerBounds } from "@editor/document-indexes";
import type { SelectionState } from "@editor/state";
import type { AppStore } from "@editor/store";
import { neutralColorAdjustment } from "@core/adjustment-layer";
export function addArtboard(document: ImageDocument, dispatch: AppStore["dispatch"]) {
const index = document.artboards.length + 1;
@@ -62,6 +63,10 @@ export function addGroupLayer(artboardId: ArtboardId, dispatch: AppStore["dispat
dispatch(commandIds.documentAddGroupLayer, { artboardId, group: createGroup("Group") });
}
export function addAdjustmentLayer(artboardId: ArtboardId, dispatch: AppStore["dispatch"]) {
dispatch(commandIds.documentAddAdjustmentLayer, { artboardId, layer: { id: crypto.randomUUID(), type: "adjustment", name: "Color adjustment", visible: true, locked: false, opacity: 1, transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 }, adjustment: { ...neutralColorAdjustment, colorBalance: { ...neutralColorAdjustment.colorBalance } } } });
}
export function groupLayers(artboardId: ArtboardId, layerIds: string[], dispatch: AppStore["dispatch"]) {
dispatch(commandIds.documentGroupLayers, { artboardId, layerIds, group: createGroup("Group") });
}
@@ -105,7 +110,7 @@ export function moveLayer(documentIndex: DocumentReadIndex, info: IndexedLayerIn
export function addLayerMask(documentIndex: DocumentReadIndex, layerInfo: IndexedLayerInfo, dispatch: AppStore["dispatch"]) {
const layer = layerInfo.layer;
if (layer.type === "group") return;
if (layer.type === "group" || layer.type === "adjustment") return;
const asset = documentIndex.assetById.get(layer.assetId);
const bounds = resolveIndexedLayerBounds(documentIndex, layer);

View File

@@ -134,7 +134,7 @@ function resolveInpaintTarget(document: ImageDocument, selection: SelectionState
const documentIndex = createDocumentReadIndex(document);
const layerInfo = documentIndex.layerInfoById.get(selection.layerIds[0]);
if (!layerInfo || layerInfo.layer.type === "group") throw new Error("Select one image or raster layer to inpaint.");
if (!layerInfo || layerInfo.layer.type === "group" || layerInfo.layer.type === "adjustment") throw new Error("Select one image or raster layer to inpaint.");
const asset = documentIndex.assetById.get(layerInfo.layer.assetId);
if (!asset) throw new Error("The selected layer is missing its source image.");
@@ -142,7 +142,7 @@ function resolveInpaintTarget(document: ImageDocument, selection: SelectionState
if (!layerMask?.enabled) throw new Error("Add a layer mask before running inpaint.");
const maskLayer = documentIndex.layerById.get(layerMask.maskLayerId);
if (!maskLayer || maskLayer.type === "group") throw new Error("The selected layer mask is missing.");
if (!maskLayer || maskLayer.type === "group" || maskLayer.type === "adjustment") throw new Error("The selected layer mask is missing.");
const maskAsset = documentIndex.assetById.get(maskLayer.assetId);
if (!maskAsset) throw new Error("The selected layer mask is missing its image data.");

View File

@@ -85,7 +85,7 @@ function resolveTarget(document: ImageDocument, selection: SelectionState, artbo
const layerId = selection.layerIds[0];
const index = createDocumentReadIndex(document);
const layerInfo = layerId ? index.layerInfoById.get(layerId) : undefined;
if (!layerInfo || layerInfo.artboardId !== artboardId || layerInfo.layer.type === "group") {
if (!layerInfo || layerInfo.artboardId !== artboardId || layerInfo.layer.type === "group" || layerInfo.layer.type === "adjustment") {
throw new Error("Select one image or raster layer before placing generated output.");
}
const asset = index.assetById.get(layerInfo.layer.assetId);

View File

@@ -27,7 +27,7 @@ export function checkGenerationPreconditions(
const index = createDocumentReadIndex(document);
const layerInfo = index.layerInfoById.get(selection.layerIds[0]);
if (!layerInfo || layerInfo.artboardId !== artboard.id || layerInfo.layer.type === "group") {
if (!layerInfo || layerInfo.artboardId !== artboard.id || layerInfo.layer.type === "group" || layerInfo.layer.type === "adjustment") {
return missing(modeSelectionMessage(settings.mode));
}
@@ -47,7 +47,7 @@ export function checkGenerationPreconditions(
const layerMask = getLayerMask(layerInfo.layer);
if (!layerMask?.enabled) return missing("Paint a mask over the area you want AI to replace.", "add-mask");
const maskLayer = index.layerById.get(layerMask.maskLayerId);
if (!maskLayer || maskLayer.type === "group") return missing("The selected layer mask is missing.");
if (!maskLayer || maskLayer.type === "group" || maskLayer.type === "adjustment") return missing("The selected layer mask is missing.");
const maskAsset = index.assetById.get(maskLayer.assetId);
if (!maskAsset) return missing("The selected layer mask is missing its image data.");
if (Math.round(asset.intrinsicSize.w) !== Math.round(maskAsset.intrinsicSize.w) || Math.round(asset.intrinsicSize.h) !== Math.round(maskAsset.intrinsicSize.h)) {

View File

@@ -210,11 +210,11 @@ function resolveSelectedImage(document: ImageDocument, selection: SelectionState
const layerId = selection.layerIds[0];
if (!layerId) return undefined;
const layer = findLayer(document.artboards.find((artboard) => artboard.id === selection.artboardId)?.layers ?? [], layerId);
if (!layer || layer.type === "group") return undefined;
if (!layer || layer.type === "group" || layer.type === "adjustment") return undefined;
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
const layerMask = getLayerMask(layer);
const maskLayer = layerMask?.enabled ? findLayer(document.artboards.flatMap((artboard) => artboard.layers), layerMask.maskLayerId) : undefined;
const maskAsset = maskLayer && maskLayer.type !== "group" ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined;
const maskAsset = maskLayer && maskLayer.type !== "group" && maskLayer.type !== "adjustment" ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined;
return asset ? { layer, asset, maskAsset } : undefined;
}

View File

@@ -57,7 +57,7 @@ export function createGenerationWorkflow(store: AppStore, dependencies: Generati
if (!layerId) return;
const artboard = state.document.artboards.find((candidate) => candidate.id === state.editor.selection.artboardId);
const layer = artboard ? findLayer(artboard.layers, layerId) : undefined;
if (!layer || layer.type === "group") return;
if (!layer || layer.type === "group" || layer.type === "adjustment") return;
const asset = state.document.assets.find((candidate) => candidate.id === layer.assetId);
if (!asset) return;
const source = await dependencies.createRefinementMask(asset.intrinsicSize.w, asset.intrinsicSize.h);

View File

@@ -14,19 +14,19 @@ export function resolveChromaKeyTarget(document: ImageDocument, selection: Selec
const layerId = selection.layerIds[0];
if (selection.layerIds.length !== 1 || !layerId) return undefined;
const layer = findLayer(document.artboards.find((artboard) => artboard.id === selection.artboardId)?.layers ?? [], layerId);
if (!layer || layer.type === "group") return undefined;
if (!layer || layer.type === "group" || layer.type === "adjustment") return undefined;
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
const bounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.id });
const layerMask = getLayerMask(layer);
const maskLayer = layerMask?.enabled ? findLayer(document.artboards.flatMap((artboard) => artboard.layers), layerMask.maskLayerId) : undefined;
const maskAsset = maskLayer && maskLayer.type !== "group" ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined;
const maskAsset = maskLayer && maskLayer.type !== "group" && maskLayer.type !== "adjustment" ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined;
return asset && bounds ? { layer, asset, bounds, maskLayer, maskAsset } : undefined;
}
export async function applyChromaKeyMask(target: NonNullable<ReturnType<typeof resolveChromaKeyTarget>>, settings: ChromaKeySettings, dispatch: AppStore["dispatch"]) {
const source = await createChromaKeyMask(target.asset.source, target.asset.intrinsicSize.w, target.asset.intrinsicSize.h, settings);
dispatch(commandIds.toolSetBrushStrokePreview, undefined);
if (target.maskAsset && target.maskLayer && target.maskLayer.type !== "group") {
if (target.maskAsset && target.maskLayer && target.maskLayer.type !== "group" && target.maskLayer.type !== "adjustment") {
dispatch(commandIds.documentApplyLayerMaskOperation, { maskLayerId: target.maskLayer.id, source, mimeType: "image/png", operation: { type: "chromaKey" } });
dispatch(commandIds.workspaceSetPanel, { panel: "none" });
return;

View File

@@ -17,7 +17,7 @@ export async function applyMagicWandAt(store: AppStore, point: Vec2D, modeOverri
const y = Math.floor((point.y - target.layer.transform.position.y) / Math.max(0.0001, target.layer.transform.scale.y));
if (x < 0 || y < 0 || x >= target.asset.intrinsicSize.w || y >= target.asset.intrinsicSize.h) return true;
const source = await createWandMask(target.asset.source, target.maskAsset?.source, Math.round(target.asset.intrinsicSize.w), Math.round(target.asset.intrinsicSize.h), x, y, { ...state.editor.tools.magicWand, mode: modeOverride ?? state.editor.tools.magicWand.mode });
if (target.maskAsset && target.maskLayer && target.maskLayer.type !== "group") {
if (target.maskAsset && target.maskLayer && target.maskLayer.type !== "group" && target.maskLayer.type !== "adjustment") {
store.dispatch(commandIds.documentApplyLayerMaskOperation, { maskLayerId: target.maskLayer.id, source, mimeType: "image/png", operation: { type: "magicWand" } });
return true;
}
@@ -39,12 +39,12 @@ function resolveTarget(document: ImageDocument, editor: EditorState) {
const layerId = editor.selection.layerIds[0];
if (!layerId || editor.selection.layerIds.length !== 1) return undefined;
const layer = findLayer(document.artboards.flatMap((artboard) => artboard.layers), layerId);
if (!layer || layer.type === "group") return undefined;
if (!layer || layer.type === "group" || layer.type === "adjustment") return undefined;
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
const bounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.id });
const layerMask = getLayerMask(layer);
const maskLayer = layerMask?.enabled ? findLayer(document.artboards.flatMap((artboard) => artboard.layers), layerMask.maskLayerId) : undefined;
const maskAsset = maskLayer && maskLayer.type !== "group" ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined;
const maskAsset = maskLayer && maskLayer.type !== "group" && maskLayer.type !== "adjustment" ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined;
return asset && bounds ? { layer, asset, bounds, maskLayer, maskAsset } : undefined;
}

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);

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,

111
renderer/adjustment-pass.ts Normal file
View File

@@ -0,0 +1,111 @@
import type { ColorAdjustment } from "@core/adjustment-layer";
import type { ScreenRect, WebGlRendererContext } from "./types";
export type AdjustmentPass = {
apply(adjustment: ColorAdjustment, opacity: number, clip: ScreenRect): void;
dispose(): void;
};
const vertexSource = `#version 300 es
in vec2 a_position;
void main(){ gl_Position=vec4(a_position,0.,1.); }`;
const fragmentSource = `#version 300 es
precision highp float;
uniform sampler2D u_pixels; uniform vec2 u_size; uniform float u_brightness,u_contrast,u_saturation,u_opacity; uniform vec3 u_balance;
out vec4 outColor;
void main(){ vec2 uv=gl_FragCoord.xy/u_size; vec4 original=texture(u_pixels,uv); vec3 c=original.rgb+vec3(u_brightness)+u_balance; c=(c-0.5)*(1.0+u_contrast)+0.5; float l=dot(c,vec3(.2126,.7152,.0722)); c=clamp(vec3(l)+(c-vec3(l))*(1.0+u_saturation),0.0,1.0); outColor=vec4(mix(original.rgb,c,u_opacity),original.a); }`;
/** Renderer-owned framebuffer adjustment resources, reused across layers/frames. */
export function createAdjustmentPass(context: WebGlRendererContext): AdjustmentPass {
const { gl, canvas } = context;
const program = createProgram(gl, vertexSource, fragmentSource);
const texture = gl.createTexture();
const buffer = gl.createBuffer();
if (!program || !texture || !buffer) throw new Error("Failed to create adjustment pass");
const position = gl.getAttribLocation(program, "a_position");
const size = requiredUniform(gl, program, "u_size");
const brightness = requiredUniform(gl, program, "u_brightness");
const contrast = requiredUniform(gl, program, "u_contrast");
const saturation = requiredUniform(gl, program, "u_saturation");
const opacityLocation = requiredUniform(gl, program, "u_opacity");
const balance = requiredUniform(gl, program, "u_balance");
const pixels = requiredUniform(gl, program, "u_pixels");
let allocatedWidth = 0;
let allocatedHeight = 0;
let disposed = false;
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), gl.STATIC_DRAW);
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
return {
apply(adjustment, opacity, clip) {
if (disposed || canvas.width < 1 || canvas.height < 1) return;
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture);
if (allocatedWidth !== canvas.width || allocatedHeight !== canvas.height) {
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, canvas.width, canvas.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
allocatedWidth = canvas.width;
allocatedHeight = canvas.height;
}
gl.copyTexSubImage2D(gl.TEXTURE_2D, 0, 0, 0, 0, 0, canvas.width, canvas.height);
gl.useProgram(program);
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.enableVertexAttribArray(position);
gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
gl.uniform1i(pixels, 0);
gl.uniform2f(size, canvas.width, canvas.height);
gl.uniform1f(brightness, adjustment.brightness);
gl.uniform1f(contrast, adjustment.contrast);
gl.uniform1f(saturation, adjustment.saturation);
gl.uniform1f(opacityLocation, Math.max(0, Math.min(1, opacity)));
gl.uniform3f(balance, adjustment.colorBalance.red, adjustment.colorBalance.green, adjustment.colorBalance.blue);
gl.scissor(clip.x, canvas.height - clip.y - clip.h, clip.w, clip.h);
gl.disable(gl.BLEND);
gl.drawArrays(gl.TRIANGLES, 0, 6);
gl.enable(gl.BLEND);
},
dispose() {
if (disposed) return;
disposed = true;
gl.deleteTexture(texture);
gl.deleteBuffer(buffer);
gl.deleteProgram(program);
},
};
}
function requiredUniform(gl: WebGL2RenderingContext, program: WebGLProgram, name: string) {
const location = gl.getUniformLocation(program, name);
if (!location) throw new Error(`Missing adjustment uniform: ${name}`);
return location;
}
function createProgram(gl: WebGL2RenderingContext, vertex: string, fragment: string) {
const vertexShader = compile(gl, gl.VERTEX_SHADER, vertex);
const fragmentShader = compile(gl, gl.FRAGMENT_SHADER, fragment);
if (!vertexShader || !fragmentShader) return undefined;
const program = gl.createProgram();
if (!program) return undefined;
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
gl.deleteShader(vertexShader);
gl.deleteShader(fragmentShader);
if (gl.getProgramParameter(program, gl.LINK_STATUS)) return program;
gl.deleteProgram(program);
return undefined;
}
function compile(gl: WebGL2RenderingContext, type: number, source: string) {
const shader = gl.createShader(type);
if (!shader) return undefined;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return shader;
gl.deleteShader(shader);
return undefined;
}

View File

@@ -9,6 +9,7 @@ import { clearScreenRect } from "./clear-rect";
import type { ImageTextureRenderer } from "./image-textures";
import { documentRectToScreenRect } from "./screen-rect";
import type { RgbaColor, ScreenRect, WebGlRendererContext } from "./types";
import type { AdjustmentPass } from "./adjustment-pass";
const imageLayerColor: RgbaColor = [0.38, 0.42, 0.5, 1];
const imageLayerInsetColor: RgbaColor = [0.48, 0.54, 0.64, 1];
@@ -16,7 +17,7 @@ const hiddenMaskOverlayColor: RgbaColor = [1, 0.08, 0.08, 0.45];
const comparisonDividerColor: RgbaColor = [1, 1, 1, 0.9];
const maskRevealPreviewOpacity = 0.28;
export function renderLayers(context: WebGlRendererContext, document: ImageDocument, editor: EditorState, imageTextureRenderer: ImageTextureRenderer) {
export function renderLayers(context: WebGlRendererContext, document: ImageDocument, editor: EditorState, imageTextureRenderer: ImageTextureRenderer, adjustmentPass: AdjustmentPass) {
const documentIndex = createDocumentReadIndex(document);
const generationCandidate = selectedGenerationCandidate(editor);
@@ -24,7 +25,7 @@ export function renderLayers(context: WebGlRendererContext, document: ImageDocum
if (!artboard.visible) continue;
const clipRect = documentRectToScreenRect(context.canvas, artboard.bounds, editor.viewport);
const maskLayerIds = documentIndex.maskLayerIdsByArtboardId.get(artboard.id) ?? emptyLayerIds;
renderLayerTree(context, documentIndex, editor, artboard.layers, imageTextureRenderer, clipRect, maskLayerIds);
renderLayerTree(context, documentIndex, editor, artboard.layers, imageTextureRenderer, adjustmentPass, clipRect, maskLayerIds);
if (generationCandidate?.placement.artboardId === artboard.id) renderGenerationCandidatePreview(context, editor, generationCandidate, imageTextureRenderer, clipRect);
}
}
@@ -39,6 +40,7 @@ function renderLayerTree(
editor: EditorState,
layers: readonly Layer[],
imageTextureRenderer: ImageTextureRenderer,
adjustmentPass: AdjustmentPass,
clipRect: ScreenRect,
maskLayerIds: ReadonlySet<string>,
) {
@@ -57,6 +59,10 @@ function renderLayerTree(
for (const child of layer.children) stack.push({ layer: child, clipRect: effectiveClipRect, inheritedOpacity: effectiveOpacity });
continue;
}
if (layer.type === "adjustment") {
adjustmentPass.apply(layer.adjustment, effectiveOpacity, effectiveClipRect);
continue;
}
renderLeafLayer(context, documentIndex, editor, layer, imageTextureRenderer, effectiveClipRect, effectiveOpacity);
}
}
@@ -65,7 +71,7 @@ function renderLeafLayer(
context: WebGlRendererContext,
documentIndex: DocumentReadIndex,
editor: EditorState,
layer: Exclude<Layer, { type: "group" }>,
layer: Extract<Layer, { type: "image" | "raster" }>,
imageTextureRenderer: ImageTextureRenderer,
effectiveClipRect: ScreenRect,
effectiveOpacity: number,
@@ -82,7 +88,7 @@ function renderLeafLayer(
const asset = assetWithBrushStrokePreview(documentIndex.assetById.get(layer.assetId), editor);
const layerMask = getLayerMask(layer);
const maskLayer = !editingMaskLayer && layerMask?.enabled ? documentIndex.layerById.get(layerMask.maskLayerId) : undefined;
const maskAsset = assetWithBrushStrokePreview(maskLayer && maskLayer.type !== "group" ? documentIndex.assetById.get(maskLayer.assetId) : undefined, editor);
const maskAsset = assetWithBrushStrokePreview(maskLayer && maskLayer.type !== "group" && maskLayer.type !== "adjustment" ? documentIndex.assetById.get(maskLayer.assetId) : undefined, editor);
const maskBounds = maskLayer ? resolveIndexedLayerBounds(documentIndex, maskLayer) : undefined;
const maskRect = maskBounds ? documentRectToScreenRect(context.canvas, maskBounds, editor.viewport) : undefined;
const activeMaskTarget = Boolean(editor.maskEdit?.targetLayerId === layer.id && editor.maskEdit.maskLayerId === layerMask?.maskLayerId);

View File

@@ -8,6 +8,7 @@ import { generationCandidatePreviewAssets, renderLayers } from "./layers";
import { renderSelectionOverlay } from "./selection";
import { renderTransformControls } from "./transform-controls";
import type { WebGlRendererContext } from "./types";
import { createAdjustmentPass } from "./adjustment-pass";
export type RenderFrame = {
document: ImageDocument;
@@ -34,6 +35,7 @@ export function createRenderer(canvas: HTMLCanvasElement, backend: RendererBacke
const rendererContext: WebGlRendererContext = { gl: context, canvas };
const checkerboardRenderer = createCheckerboardRenderer(rendererContext);
const brushPreviewRenderer = createOptionalBrushPreviewRenderer(rendererContext);
const adjustmentPass = createAdjustmentPass(rendererContext);
let lastFrame: RenderFrame | undefined;
let rerenderQueued = false;
const imageTextureRenderer = createImageTextureRenderer(rendererContext, () => {
@@ -63,7 +65,7 @@ export function createRenderer(canvas: HTMLCanvasElement, backend: RendererBacke
if (artboard.visible) renderArtboard(rendererContext, artboard, frame.editor.viewport, checkerboardRenderer);
}
imageTextureRenderer.syncAssets([...frame.document.assets, ...generationCandidatePreviewAssets(frame.editor)]);
renderLayers(rendererContext, frame.document, frame.editor, imageTextureRenderer);
renderLayers(rendererContext, frame.document, frame.editor, imageTextureRenderer, adjustmentPass);
if (!frame.editor.maskEdit) {
renderSelectionOverlay(rendererContext, frame.document, frame.editor);
@@ -76,6 +78,7 @@ export function createRenderer(canvas: HTMLCanvasElement, backend: RendererBacke
dispose() {
checkerboardRenderer.dispose();
imageTextureRenderer.dispose();
adjustmentPass.dispose();
brushPreviewRenderer?.dispose();
},
};

View File

@@ -1,8 +1,9 @@
import { useMemo, useRef, useState, type DragEvent, type MutableRefObject } from "react";
import { ArrowDown, ArrowUp, DownloadSimple, Eye, EyeSlash, FolderPlus, Lock, LockOpen, Plus, Stack, Trash } from "@phosphor-icons/react";
import { useEffect, useMemo, useRef, useState, type DragEvent, type MutableRefObject } from "react";
import { ArrowDown, ArrowUp, DownloadSimple, Eye, EyeSlash, FolderPlus, Lock, LockOpen, Plus, SlidersHorizontal, Stack, Trash } from "@phosphor-icons/react";
import { commandIds } from "@commands/ids";
import type { ImageDocument } from "@core/document";
import type { Layer } from "@core/layer";
import type { ColorAdjustment } from "@core/adjustment-layer";
import { getLayerMask } from "@core/layer-mask-utils";
import type { ArtboardId } from "@core/id";
import { createDocumentReadIndex, type DocumentReadIndex } from "@editor/document-indexes";
@@ -10,7 +11,7 @@ import type { MaskEditState, SelectionState } from "@editor/state";
import type { AppStore } from "@editor/store";
import { resolveLayerDrop } from "@input/index";
import type { DocumentActions } from "@app/document-actions";
import { addArtboard, addEmptyLayer, addGroupLayer, addLayerMask, deleteSelection, groupLayers, moveLayer } from "@operations/document/layerActions";
import { addAdjustmentLayer, addArtboard, addEmptyLayer, addGroupLayer, addLayerMask, deleteSelection, groupLayers, moveLayer } from "@operations/document/layerActions";
import { MaskOperationButtons, MaskStatus } from "./layers/MaskControls";
import { LayerThumbnail } from "./layers/LayerThumbnail";
import { createLayerThumbnailIndex, type LayerThumbnailModel } from "./layers/thumbnailModel";
@@ -91,6 +92,9 @@ function LayersSheetBody({
<button type="button" className={toolbarButtonClass()} aria-label="Add group" title="Add group" disabled={!selectedArtboardId} onClick={() => selectedArtboardId && addGroupLayer(selectedArtboardId, dispatch)}>
<FolderPlus size={24} />
</button>
<button type="button" className={toolbarButtonClass()} aria-label="Add color adjustment" title="Add non-destructive artboard color adjustment" disabled={!selectedArtboardId} onClick={() => selectedArtboardId && addAdjustmentLayer(selectedArtboardId, dispatch)}>
<SlidersHorizontal size={24} />
</button>
</div>
</header>
<div className="mb-4 grid grid-cols-5 gap-2">
@@ -110,6 +114,7 @@ function LayersSheetBody({
<Trash size={24} />
</button>
</div>
{selectedLayer?.layer.type === "adjustment" ? <AdjustmentInspector layer={selectedLayer.layer} dispatch={dispatch} /> : null}
<div className="min-h-0 flex-1 overflow-auto pb-2">
{document.artboards.map((artboard) => {
const displayLayerCount = documentIndex.displayLayerCountByArtboardId.get(artboard.id) ?? 0;
@@ -199,6 +204,61 @@ function LayersSheetBody({
);
}
function AdjustmentInspector({ layer, dispatch }: { layer: Extract<Layer, { type: "adjustment" }>; dispatch: AppStore["dispatch"] }) {
const [draft, setDraft] = useState<ColorAdjustment>(() => cloneAdjustment(layer.adjustment));
useEffect(() => setDraft(cloneAdjustment(layer.adjustment)), [layer.id, layer.adjustment]);
const fields = [
["Brightness", "brightness", draft.brightness],
["Contrast", "contrast", draft.contrast],
["Saturation", "saturation", draft.saturation],
["Red", "red", draft.colorBalance.red],
["Green", "green", draft.colorBalance.green],
["Blue", "blue", draft.colorBalance.blue],
] as const;
const commit = () => {
if (JSON.stringify(draft) === JSON.stringify(layer.adjustment)) return;
dispatch(commandIds.documentSetAdjustment, { layerId: layer.id, adjustment: draft });
};
return (
<section aria-label="Adjustment settings" className="mb-3 rounded-[1.5rem] bg-white/[0.05] p-3">
<p className="mb-2 text-xs text-white/45">
Affects visible artboard layers beneath it. Adjustment layers stay at artboard level.
</p>
<div className="grid grid-cols-2 gap-2">
{fields.map(([label, key, value]) => (
<label key={key} className="text-[0.7rem] text-white/55">
<span>{label}</span>
<input
className="mt-1 w-full accent-violet-300"
type="range"
min="-1"
max="1"
step="0.01"
value={value}
disabled={layer.locked}
onChange={(event) => setDraft(withAdjustmentValue(draft, key, Number(event.currentTarget.value)))}
onPointerUp={commit}
onBlur={commit}
/>
</label>
))}
</div>
</section>
);
}
function cloneAdjustment(adjustment: ColorAdjustment): ColorAdjustment {
return { ...adjustment, colorBalance: { ...adjustment.colorBalance } };
}
function withAdjustmentValue(adjustment: ColorAdjustment, key: "brightness" | "contrast" | "saturation" | "red" | "green" | "blue", value: number): ColorAdjustment {
if (key === "brightness" || key === "contrast" || key === "saturation") return { ...adjustment, [key]: value };
return { ...adjustment, colorBalance: { ...adjustment.colorBalance, [key]: value } };
}
function LayerRow({
document,
documentIndex,
@@ -234,8 +294,8 @@ function LayerRow({
const layerInfo = documentIndex.layerInfoById.get(layer.id);
const layerMask = getLayerMask(layer);
const maskLayer = layerMask ? documentIndex.layerById.get(layerMask.maskLayerId) : undefined;
const maskAsset = maskLayer && maskLayer.type !== "group" ? documentIndex.assetById.get(maskLayer.assetId) : undefined;
const canAddMask = Boolean(layerInfo && layer.type !== "group" && !layerMask);
const maskAsset = maskLayer && maskLayer.type !== "group" && maskLayer.type !== "adjustment" ? documentIndex.assetById.get(maskLayer.assetId) : undefined;
const canAddMask = Boolean(layerInfo && layer.type !== "group" && layer.type !== "adjustment" && !layerMask);
const editingMask = Boolean(maskEdit && layerMask && maskEdit.targetLayerId === layer.id && maskEdit.maskLayerId === layerMask.maskLayerId);
const thumbnail = thumbnailByLayerId.get(layer.id) ?? { kind: "empty" };
const maskThumbnail = maskLayer ? thumbnailByLayerId.get(maskLayer.id) : undefined;
@@ -354,7 +414,7 @@ function LayerRow({
>
Hide
</button>
{maskAsset && maskLayer.type !== "group" ? (
{maskAsset && maskLayer.type !== "group" && maskLayer.type !== "adjustment" ? (
<MaskOperationButtons maskLayerId={maskLayer.id} maskAsset={maskAsset} dispatch={dispatch} />
) : null}
</div>

View File

@@ -31,7 +31,7 @@ export function TransformControls({ bounds, target, documentIndex, layerInfo, di
const layer = layerInfo?.layer;
const locked = layer?.locked ?? false;
const mask = layer ? getLayerMask(layer) : undefined;
const rotatedMaskEditingUnsupported = Boolean(layer && layer.type !== "group" && layer.transform.rotation !== 0);
const rotatedMaskEditingUnsupported = Boolean(layer && layer.type !== "group" && layer.type !== "adjustment" && layer.transform.rotation !== 0);
useEffect(() => {
setDraft(draftFromBounds(bounds));
@@ -39,7 +39,7 @@ export function TransformControls({ bounds, target, documentIndex, layerInfo, di
useEffect(() => setOpacityDraft(String(Math.round((layer?.opacity ?? 1) * 100))), [layer?.id, layer?.opacity]);
useEffect(() => setRotationDraft(rotationDegrees(layerInfo)), [layer?.id, layer?.transform.rotation]);
useEffect(() => setCropDraft(cropDraftFor(layerInfo, documentIndex)), [documentIndex, layer?.id, layer?.type === "group" ? undefined : layer?.sourceRect]);
useEffect(() => setCropDraft(cropDraftFor(layerInfo, documentIndex)), [documentIndex, layer?.id, layer?.type === "image" || layer?.type === "raster" ? layer.sourceRect : undefined]);
useEffect(() => setResizeDraft((current) => ({ ...current, w: String(Math.round(bounds.w)), h: String(Math.round(bounds.h)) })), [bounds.w, bounds.h, target]);
const commitField = (field: BoundsField) => {
@@ -89,8 +89,8 @@ export function TransformControls({ bounds, target, documentIndex, layerInfo, di
label="Rotation"
suffix="°"
value={rotationDraft}
disabled={locked || layer.type === "group"}
title={layer.type === "group" ? "Group rotation is not supported" : undefined}
disabled={locked || layer.type === "group" || layer.type === "adjustment"}
title={layer.type === "group" || layer.type === "adjustment" ? "Group rotation is not supported" : undefined}
onChange={setRotationDraft}
onCommit={() => {
const value = Number.parseFloat(rotationDraft);
@@ -102,12 +102,12 @@ export function TransformControls({ bounds, target, documentIndex, layerInfo, di
<button type="button" className={actionButtonClass()} disabled={locked} title={locked ? "Unlock the layer to duplicate it" : "Duplicate layer"} onClick={() => duplicateLayer(documentIndex, layerInfo!, dispatch)}>
<Copy size={20} /> Duplicate
</button>
{layer.type !== "group" ? (
{layer.type !== "group" && layer.type !== "adjustment" ? (
<button type="button" className={actionButtonClass()} disabled={locked || layer.transform.rotation !== 0} title={layer.transform.rotation !== 0 ? "Reset rotation before cropping" : "Crop visible source pixels"} onClick={() => setCropOpen((open) => !open)}>
<Crop size={20} /> Crop
</button>
) : null}
{layer.type !== "group" ? (
{layer.type !== "group" && layer.type !== "adjustment" ? (
<button
type="button"
className={actionButtonClass()}
@@ -122,7 +122,7 @@ export function TransformControls({ bounds, target, documentIndex, layerInfo, di
</button>
) : null}
{locked ? <span className="text-xs text-amber-200/70">Unlock to edit</span> : null}
{cropOpen && layer.type !== "group" ? <CropEditor draft={cropDraft} setDraft={setCropDraft} onCancel={() => { setCropDraft(cropDraftFor(layerInfo, documentIndex)); setCropOpen(false); }} onReset={() => { dispatch(commandIds.documentSetLayerSourceRect, { layerId: layer.id }); setCropOpen(false); }} onApply={() => { const sourceRect = parseRectDraft(cropDraft); if (sourceRect) { dispatch(commandIds.documentSetLayerSourceRect, { layerId: layer.id, sourceRect }); setCropOpen(false); } }} /> : null}
{cropOpen && layer.type !== "group" && layer.type !== "adjustment" ? <CropEditor draft={cropDraft} setDraft={setCropDraft} onCancel={() => { setCropDraft(cropDraftFor(layerInfo, documentIndex)); setCropOpen(false); }} onReset={() => { dispatch(commandIds.documentSetLayerSourceRect, { layerId: layer.id }); setCropOpen(false); }} onApply={() => { const sourceRect = parseRectDraft(cropDraft); if (sourceRect) { dispatch(commandIds.documentSetLayerSourceRect, { layerId: layer.id, sourceRect }); setCropOpen(false); } }} /> : null}
</>
) : target.type === "artboard" ? (
<>
@@ -154,7 +154,7 @@ function ArtboardResizeEditor({ draft, setDraft, onApply, onCancel }: { draft: {
}
function cropDraftFor(layerInfo: IndexedLayerInfo | undefined, index: DocumentReadIndex): CropDraft {
if (!layerInfo || layerInfo.layer.type === "group") return { x: "0", y: "0", w: "1", h: "1" };
if (!layerInfo || layerInfo.layer.type === "group" || layerInfo.layer.type === "adjustment") return { x: "0", y: "0", w: "1", h: "1" };
const asset = index.assetById.get(layerInfo.layer.assetId);
const rect = layerInfo.layer.sourceRect ?? { x: 0, y: 0, w: asset?.intrinsicSize.w ?? 1, h: asset?.intrinsicSize.h ?? 1 };
return draftFromBounds(rect);

View File

@@ -1,4 +1,4 @@
import { FolderSimple, ImageBroken } from "@phosphor-icons/react";
import { FolderSimple, ImageBroken, SlidersHorizontal } from "@phosphor-icons/react";
import type { LayerThumbnailModel, RasterThumbnailModel } from "./thumbnailModel";
export function LayerThumbnail({ model, label, compact = false }: { model: LayerThumbnailModel; label: string; compact?: boolean }) {
@@ -15,6 +15,7 @@ export function LayerThumbnail({ model, label, compact = false }: { model: Layer
</>
) : null}
{model.kind === "empty" ? <ImageBroken size={compact ? 14 : 17} className="text-white/35" /> : null}
{model.kind === "adjustment" ? <SlidersHorizontal size={compact ? 14 : 17} className="text-violet-200" /> : null}
</span>
);
}

View File

@@ -14,6 +14,7 @@ export type RasterThumbnailModel = {
export type LayerThumbnailModel =
| RasterThumbnailModel
| { kind: "adjustment" }
| { kind: "group"; previews: readonly RasterThumbnailModel[] }
| { kind: "empty" };
@@ -23,6 +24,7 @@ export function resolveLayerThumbnail(
assetById: ReadonlyMap<AssetId, Asset>,
excludedLayerIds: ReadonlySet<LayerId> = new Set(),
): LayerThumbnailModel {
if (layer.type === "adjustment") return { kind: "adjustment" };
if (layer.type !== "group") return resolveRasterThumbnail(layer, assetById) ?? { kind: "empty" };
const previews: RasterThumbnailModel[] = [];
@@ -56,6 +58,7 @@ export function createLayerThumbnailIndex(
const { layer } = frame;
if (frame.visited || layer.type !== "group") {
if (layer.type !== "group") {
if (layer.type === "adjustment") { result.set(layer.id, { kind: "adjustment" }); continue; }
result.set(layer.id, resolveRasterThumbnail(layer, assetById) ?? { kind: "empty" });
continue;
}
@@ -80,6 +83,7 @@ export function createLayerThumbnailIndex(
}
function resolveRasterThumbnail(layer: Exclude<Layer, { type: "group" }>, assetById: ReadonlyMap<AssetId, Asset>): RasterThumbnailModel | undefined {
if (layer.type === "adjustment") return undefined;
const asset = assetById.get(layer.assetId);
if (!asset || !asset.source.trim() || !positiveFinite(asset.intrinsicSize.w) || !positiveFinite(asset.intrinsicSize.h)) return undefined;