From 606426c885851c1e61080efc0c83cd3643402ebb Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Sat, 11 Jul 2026 12:31:06 +0200 Subject: [PATCH] feat: add non-destructive adjustment layers --- commands/adjustment.test.ts | 39 ++++++++ commands/document.ts | 42 +++++++- commands/ids.ts | 2 + commands/index.ts | 4 + commands/payloads.ts | 4 + commands/transform-document.ts | 5 +- core/adjustment-layer.ts | 26 +++++ core/index.ts | 2 + core/layer.ts | 3 +- editor/document-indexes.ts | 3 + input/read-model.ts | 1 + operations/document/layerActions.ts | 7 +- operations/generation/inpaintPrep.ts | 4 +- operations/generation/outputPlacement.ts | 2 +- operations/generation/preconditions.ts | 4 +- operations/generation/runGenerate.ts | 4 +- operations/generation/workflow.ts | 2 +- operations/masks/chromaKey.ts | 6 +- operations/masks/magic-wand.ts | 6 +- operations/project/format.test.ts | 16 ++- operations/project/format.ts | 16 ++- platform/browser/adjustment.test.ts | 10 ++ platform/browser/exportArtboardPng.ts | 43 +++++++- renderer/adjustment-pass.ts | 111 +++++++++++++++++++++ renderer/layers.ts | 14 ++- renderer/renderer.ts | 5 +- view/LayersSheet.tsx | 72 +++++++++++-- view/bottom-controls/TransformControls.tsx | 16 +-- view/layers/LayerThumbnail.tsx | 3 +- view/layers/thumbnailModel.ts | 4 + 30 files changed, 427 insertions(+), 49 deletions(-) create mode 100644 commands/adjustment.test.ts create mode 100644 core/adjustment-layer.ts create mode 100644 platform/browser/adjustment.test.ts create mode 100644 renderer/adjustment-pass.ts diff --git a/commands/adjustment.test.ts b/commands/adjustment.test.ts new file mode 100644 index 0000000..6150ad7 --- /dev/null +++ b/commands/adjustment.test.ts @@ -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); + }); +}); diff --git a/commands/document.ts b/commands/document.ts index aca3f9a..c3b8ca8 100644 --- a/commands/document.ts +++ b/commands/document.ts @@ -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 }, }; +export const documentAddAdjustmentLayerCommand: Command = { + 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 = { + 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 = { 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 = { 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 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 = 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= 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), } : { diff --git a/commands/ids.ts b/commands/ids.ts index f6a0cb3..dc27f0d 100644 --- a/commands/ids.ts +++ b/commands/ids.ts @@ -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", diff --git a/commands/index.ts b/commands/index.ts index 0ba7f25..a6c7c2c 100644 --- a/commands/index.ts +++ b/commands/index.ts @@ -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, diff --git a/commands/payloads.ts b/commands/payloads.ts index e7058e0..8d1f7e6 100644 --- a/commands/payloads.ts +++ b/commands/payloads.ts @@ -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; diff --git a/commands/transform-document.ts b/commands/transform-document.ts index a50616a..e4af0a7 100644 --- a/commands/transform-document.ts +++ b/commands/transform-document.ts @@ -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 }; diff --git a/core/adjustment-layer.ts b/core/adjustment-layer.ts new file mode 100644 index 0000000..f539a9c --- /dev/null +++ b/core/adjustment-layer.ts @@ -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 }, +}; diff --git a/core/index.ts b/core/index.ts index 552bb73..13d9953 100644 --- a/core/index.ts +++ b/core/index.ts @@ -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"; diff --git a/core/layer.ts b/core/layer.ts index 496896e..4738ef8 100644 --- a/core/layer.ts +++ b/core/layer.ts @@ -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; diff --git a/editor/document-indexes.ts b/editor/document-indexes.ts index 8c0a19e..ee7bfd0 100644 --- a/editor/document-indexes.ts +++ b/editor/document-indexes.ts @@ -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; diff --git a/input/read-model.ts b/input/read-model.ts index e6beca8..3c39f4e 100644 --- a/input/read-model.ts +++ b/input/read-model.ts @@ -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 = { diff --git a/operations/document/layerActions.ts b/operations/document/layerActions.ts index a54d15c..713f19d 100644 --- a/operations/document/layerActions.ts +++ b/operations/document/layerActions.ts @@ -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); diff --git a/operations/generation/inpaintPrep.ts b/operations/generation/inpaintPrep.ts index b2d1f4b..261c02e 100644 --- a/operations/generation/inpaintPrep.ts +++ b/operations/generation/inpaintPrep.ts @@ -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."); diff --git a/operations/generation/outputPlacement.ts b/operations/generation/outputPlacement.ts index dceb1f1..c48dbb3 100644 --- a/operations/generation/outputPlacement.ts +++ b/operations/generation/outputPlacement.ts @@ -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); diff --git a/operations/generation/preconditions.ts b/operations/generation/preconditions.ts index 654acd9..3faa6ab 100644 --- a/operations/generation/preconditions.ts +++ b/operations/generation/preconditions.ts @@ -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)) { diff --git a/operations/generation/runGenerate.ts b/operations/generation/runGenerate.ts index b95792f..e2c1c2a 100644 --- a/operations/generation/runGenerate.ts +++ b/operations/generation/runGenerate.ts @@ -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; } diff --git a/operations/generation/workflow.ts b/operations/generation/workflow.ts index 4777345..fad9afe 100644 --- a/operations/generation/workflow.ts +++ b/operations/generation/workflow.ts @@ -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); diff --git a/operations/masks/chromaKey.ts b/operations/masks/chromaKey.ts index db2aab4..1571da7 100644 --- a/operations/masks/chromaKey.ts +++ b/operations/masks/chromaKey.ts @@ -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>, 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; diff --git a/operations/masks/magic-wand.ts b/operations/masks/magic-wand.ts index e91cf7e..54dcae7 100644 --- a/operations/masks/magic-wand.ts +++ b/operations/masks/magic-wand.ts @@ -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; } diff --git a/operations/project/format.test.ts b/operations/project/format.test.ts index 49f156d..c067eec 100644 --- a/operations/project/format.test.ts +++ b/operations/project/format.test.ts @@ -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"); diff --git a/operations/project/format.ts b/operations/project/format.ts index 09a35dd..7a7f1f3 100644 --- a/operations/project/format.ts +++ b/operations/project/format.ts @@ -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); diff --git a/platform/browser/adjustment.test.ts b/platform/browser/adjustment.test.ts new file mode 100644 index 0000000..1dd80ba --- /dev/null +++ b/platform/browser/adjustment.test.ts @@ -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]); + }); +}); diff --git a/platform/browser/exportArtboardPng.ts b/platform/browser/exportArtboardPng.ts index f7611a5..b52cbed 100644 --- a/platform/browser/exportArtboardPng.ts +++ b/platform/browser/exportArtboardPng.ts @@ -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, intrinsicSize: Size) { +export function resolveLayerDrawImage(layer: Extract, intrinsicSize: Size) { const source = layer.sourceRect ?? { x: 0, y: 0, ...intrinsicSize }; return { source, @@ -86,6 +97,34 @@ export function resolveLayerDrawImage(layer: Exclude, }; } +export function adjustPixel(rgb: readonly [number, number, number], adjustment: Extract["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["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, diff --git a/renderer/adjustment-pass.ts b/renderer/adjustment-pass.ts new file mode 100644 index 0000000..47df8a5 --- /dev/null +++ b/renderer/adjustment-pass.ts @@ -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; +} diff --git a/renderer/layers.ts b/renderer/layers.ts index a469979..0c71f02 100644 --- a/renderer/layers.ts +++ b/renderer/layers.ts @@ -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, ) { @@ -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: Extract, 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); diff --git a/renderer/renderer.ts b/renderer/renderer.ts index 50de897..0ff9f91 100644 --- a/renderer/renderer.ts +++ b/renderer/renderer.ts @@ -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(); }, }; diff --git a/view/LayersSheet.tsx b/view/LayersSheet.tsx index ae19625..7389cf1 100644 --- a/view/LayersSheet.tsx +++ b/view/LayersSheet.tsx @@ -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({ +
@@ -110,6 +114,7 @@ function LayersSheetBody({
+ {selectedLayer?.layer.type === "adjustment" ? : null}
{document.artboards.map((artboard) => { const displayLayerCount = documentIndex.displayLayerCountByArtboardId.get(artboard.id) ?? 0; @@ -199,6 +204,61 @@ function LayersSheetBody({ ); } +function AdjustmentInspector({ layer, dispatch }: { layer: Extract; dispatch: AppStore["dispatch"] }) { + const [draft, setDraft] = useState(() => 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 ( +
+

+ Affects visible artboard layers beneath it. Adjustment layers stay at artboard level. +

+
+ {fields.map(([label, key, value]) => ( + + ))} +
+
+ ); +} + +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 - {maskAsset && maskLayer.type !== "group" ? ( + {maskAsset && maskLayer.type !== "group" && maskLayer.type !== "adjustment" ? ( ) : null}
diff --git a/view/bottom-controls/TransformControls.tsx b/view/bottom-controls/TransformControls.tsx index c1e3825..1c06106 100644 --- a/view/bottom-controls/TransformControls.tsx +++ b/view/bottom-controls/TransformControls.tsx @@ -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 - {layer.type !== "group" ? ( + {layer.type !== "group" && layer.type !== "adjustment" ? ( ) : null} - {layer.type !== "group" ? ( + {layer.type !== "group" && layer.type !== "adjustment" ? (