feat: add non-destructive adjustment layers
This commit is contained in:
39
commands/adjustment.test.ts
Normal file
39
commands/adjustment.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
} : {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
26
core/adjustment-layer.ts
Normal 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 },
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
10
platform/browser/adjustment.test.ts
Normal file
10
platform/browser/adjustment.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
@@ -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
111
renderer/adjustment-pass.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user