feat: add chroma key tool and settings, including UI components and controls
This commit is contained in:
@@ -25,6 +25,7 @@ export const commandIds = {
|
|||||||
selectionAddLayer: "selection.addLayer",
|
selectionAddLayer: "selection.addLayer",
|
||||||
toolSetActive: "tool.setActive",
|
toolSetActive: "tool.setActive",
|
||||||
toolSetBrushSettings: "tool.setBrushSettings",
|
toolSetBrushSettings: "tool.setBrushSettings",
|
||||||
|
toolSetChromaKeySettings: "tool.setChromaKeySettings",
|
||||||
toolSetBrushPreview: "tool.setBrushPreview",
|
toolSetBrushPreview: "tool.setBrushPreview",
|
||||||
toolSetBrushStrokePreview: "tool.setBrushStrokePreview",
|
toolSetBrushStrokePreview: "tool.setBrushStrokePreview",
|
||||||
toolSetMaskViewMode: "tool.setMaskViewMode",
|
toolSetMaskViewMode: "tool.setMaskViewMode",
|
||||||
|
|||||||
@@ -54,10 +54,10 @@ export type { CommandRegistry } from "./registry";
|
|||||||
export { createCommandRegistry } from "./registry";
|
export { createCommandRegistry } from "./registry";
|
||||||
export { selectionAddLayerCommand, selectionClearCommand, selectionCommands, selectionSetCommand } from "./selection";
|
export { selectionAddLayerCommand, selectionClearCommand, selectionCommands, selectionSetCommand } from "./selection";
|
||||||
export type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
|
export type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
|
||||||
export { toolCommands, toolEnterMaskEditCommand, toolEnterTemporaryPanCommand, toolExitMaskEditCommand, toolExitTemporaryPanCommand, toolSetActiveCommand, toolSetBrushPreviewCommand, toolSetBrushSettingsCommand, toolSetBrushStrokePreviewCommand, toolSetMaskViewModeCommand } from "./tool";
|
export { toolCommands, toolEnterMaskEditCommand, toolEnterTemporaryPanCommand, toolExitMaskEditCommand, toolExitTemporaryPanCommand, toolSetActiveCommand, toolSetBrushPreviewCommand, toolSetBrushSettingsCommand, toolSetBrushStrokePreviewCommand, toolSetChromaKeySettingsCommand, toolSetMaskViewModeCommand } from "./tool";
|
||||||
export { transformBeginCommand, transformCommands, transformEndCommand, transformSetBoundsCommand, transformUpdateCommand } from "./transform";
|
export { transformBeginCommand, transformCommands, transformEndCommand, transformSetBoundsCommand, transformUpdateCommand } from "./transform";
|
||||||
export type { TransformBeginPayload, TransformSetBoundsPayload, TransformUpdatePayload } from "./transform";
|
export type { TransformBeginPayload, TransformSetBoundsPayload, TransformUpdatePayload } from "./transform";
|
||||||
export type { ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetMaskViewModePayload } from "./tool";
|
export type { ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetChromaKeySettingsPayload, ToolSetMaskViewModePayload } from "./tool";
|
||||||
export {
|
export {
|
||||||
viewportCommands,
|
viewportCommands,
|
||||||
viewportPanCommand,
|
viewportPanCommand,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import type {
|
|||||||
DocumentUngroupLayerPayload,
|
DocumentUngroupLayerPayload,
|
||||||
} from "./document";
|
} from "./document";
|
||||||
import type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
|
import type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
|
||||||
import type { ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetMaskViewModePayload } from "./tool";
|
import type { ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetChromaKeySettingsPayload, ToolSetMaskViewModePayload } from "./tool";
|
||||||
import type { TransformBeginPayload, TransformSetBoundsPayload, TransformUpdatePayload } from "./transform";
|
import type { TransformBeginPayload, TransformSetBoundsPayload, TransformUpdatePayload } from "./transform";
|
||||||
import type {
|
import type {
|
||||||
ViewportFitArtboardPayload,
|
ViewportFitArtboardPayload,
|
||||||
@@ -60,6 +60,7 @@ export type CommandPayloads = {
|
|||||||
[commandIds.selectionAddLayer]: SelectionAddLayerPayload;
|
[commandIds.selectionAddLayer]: SelectionAddLayerPayload;
|
||||||
[commandIds.toolSetActive]: ToolSetActivePayload;
|
[commandIds.toolSetActive]: ToolSetActivePayload;
|
||||||
[commandIds.toolSetBrushSettings]: ToolSetBrushSettingsPayload;
|
[commandIds.toolSetBrushSettings]: ToolSetBrushSettingsPayload;
|
||||||
|
[commandIds.toolSetChromaKeySettings]: ToolSetChromaKeySettingsPayload;
|
||||||
[commandIds.toolSetBrushPreview]: ToolSetBrushPreviewPayload;
|
[commandIds.toolSetBrushPreview]: ToolSetBrushPreviewPayload;
|
||||||
[commandIds.toolSetBrushStrokePreview]: ToolSetBrushStrokePreviewPayload;
|
[commandIds.toolSetBrushStrokePreview]: ToolSetBrushStrokePreviewPayload;
|
||||||
[commandIds.toolSetMaskViewMode]: ToolSetMaskViewModePayload;
|
[commandIds.toolSetMaskViewMode]: ToolSetMaskViewModePayload;
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { createInitialAppState } from "@editor/initial-state";
|
import { createInitialAppState } from "@editor/initial-state";
|
||||||
import { toolEnterMaskEditCommand, toolEnterTemporaryPanCommand, toolExitMaskEditCommand, toolExitTemporaryPanCommand, toolSetActiveCommand, toolSetBrushPreviewCommand, toolSetBrushSettingsCommand, toolSetBrushStrokePreviewCommand, toolSetMaskViewModeCommand } from "./tool";
|
import { toolEnterMaskEditCommand, toolEnterTemporaryPanCommand, toolExitMaskEditCommand, toolExitTemporaryPanCommand, toolSetActiveCommand, toolSetBrushPreviewCommand, toolSetBrushSettingsCommand, toolSetBrushStrokePreviewCommand, toolSetChromaKeySettingsCommand, toolSetMaskViewModeCommand } from "./tool";
|
||||||
|
|
||||||
const defaultBrush = { color: "#111827", size: 8, hardness: 100 };
|
const defaultBrush = { color: "#111827", size: 8, hardness: 100 };
|
||||||
|
const defaultChromaKey = { color: "#00ff00", tolerance: 32, softness: 24, feather: 0, choke: 0, despeckle: 0, spill: 50 };
|
||||||
|
|
||||||
describe("tool commands", () => {
|
describe("tool commands", () => {
|
||||||
test("sets active tool", () => {
|
test("sets active tool", () => {
|
||||||
const next = toolSetActiveCommand.execute({ state: createInitialAppState("Test") }, { tool: "brush" });
|
const next = toolSetActiveCommand.execute({ state: createInitialAppState("Test") }, { tool: "brush" });
|
||||||
expect(next.editor.tools).toEqual({ activeTool: "brush", interactionMode: { type: "tool", tool: "brush" }, brush: defaultBrush });
|
expect(next.editor.tools).toEqual({ activeTool: "brush", interactionMode: { type: "tool", tool: "brush" }, brush: defaultBrush, chromaKey: defaultChromaKey });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("sets brush settings", () => {
|
test("sets brush settings", () => {
|
||||||
@@ -16,6 +17,12 @@ describe("tool commands", () => {
|
|||||||
expect(next.editor.tools.brush).toEqual({ color: "#ff0000", size: 24, hardness: 50 });
|
expect(next.editor.tools.brush).toEqual({ color: "#ff0000", size: 24, hardness: 50 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("sets chroma key settings", () => {
|
||||||
|
const next = toolSetChromaKeySettingsCommand.execute({ state: createInitialAppState("Test") }, { color: "#123456", tolerance: 300 });
|
||||||
|
|
||||||
|
expect(next.editor.tools.chromaKey).toEqual({ color: "#123456", tolerance: 255, softness: 24, feather: 0, choke: 0, despeckle: 0, spill: 50 });
|
||||||
|
});
|
||||||
|
|
||||||
test("sets and clears brush preview", () => {
|
test("sets and clears brush preview", () => {
|
||||||
const showing = toolSetBrushPreviewCommand.execute({ state: createInitialAppState("Test") }, { position: { x: 10, y: 20 } });
|
const showing = toolSetBrushPreviewCommand.execute({ state: createInitialAppState("Test") }, { position: { x: 10, y: 20 } });
|
||||||
const cleared = toolSetBrushPreviewCommand.execute({ state: showing }, undefined);
|
const cleared = toolSetBrushPreviewCommand.execute({ state: showing }, undefined);
|
||||||
@@ -56,7 +63,7 @@ describe("tool commands", () => {
|
|||||||
const panning = toolEnterTemporaryPanCommand.execute({ state: initial }, undefined);
|
const panning = toolEnterTemporaryPanCommand.execute({ state: initial }, undefined);
|
||||||
const restored = toolExitTemporaryPanCommand.execute({ state: panning }, undefined);
|
const restored = toolExitTemporaryPanCommand.execute({ state: panning }, undefined);
|
||||||
|
|
||||||
expect(panning.editor.tools).toEqual({ activeTool: "select", interactionMode: { type: "temporary-pan", previousTool: "select" }, brush: defaultBrush });
|
expect(panning.editor.tools).toEqual({ activeTool: "select", interactionMode: { type: "temporary-pan", previousTool: "select" }, brush: defaultBrush, chromaKey: defaultChromaKey });
|
||||||
expect(restored.editor.tools).toEqual(initial.editor.tools);
|
expect(restored.editor.tools).toEqual(initial.editor.tools);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { Vec2D } from "@core/geometry";
|
|||||||
import type { LayerId, ArtboardId, AssetId } from "@core/id";
|
import type { LayerId, ArtboardId, AssetId } from "@core/id";
|
||||||
import type { Layer } from "@core/layer";
|
import type { Layer } from "@core/layer";
|
||||||
import type { MaskViewMode } from "@editor/state";
|
import type { MaskViewMode } from "@editor/state";
|
||||||
import type { BrushSettings, ToolId } from "@editor/tools";
|
import type { BrushSettings, ChromaKeySettings, ToolId } from "@editor/tools";
|
||||||
import type { Command } from "./command";
|
import type { Command } from "./command";
|
||||||
import { commandIds } from "./ids";
|
import { commandIds } from "./ids";
|
||||||
|
|
||||||
@@ -13,6 +13,8 @@ export type ToolSetActivePayload = {
|
|||||||
|
|
||||||
export type ToolSetBrushSettingsPayload = Partial<BrushSettings>;
|
export type ToolSetBrushSettingsPayload = Partial<BrushSettings>;
|
||||||
|
|
||||||
|
export type ToolSetChromaKeySettingsPayload = Partial<ChromaKeySettings>;
|
||||||
|
|
||||||
export type ToolSetBrushPreviewPayload = { position: Vec2D } | undefined;
|
export type ToolSetBrushPreviewPayload = { position: Vec2D } | undefined;
|
||||||
|
|
||||||
export type ToolSetBrushStrokePreviewPayload =
|
export type ToolSetBrushStrokePreviewPayload =
|
||||||
@@ -73,6 +75,31 @@ export const toolSetBrushSettingsCommand: Command<ToolSetBrushSettingsPayload> =
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const toolSetChromaKeySettingsCommand: Command<ToolSetChromaKeySettingsPayload> = {
|
||||||
|
id: commandIds.toolSetChromaKeySettings,
|
||||||
|
name: "Set chroma key settings",
|
||||||
|
execute({ state }, payload) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
editor: {
|
||||||
|
...state.editor,
|
||||||
|
tools: {
|
||||||
|
...state.editor.tools,
|
||||||
|
chromaKey: {
|
||||||
|
color: payload.color ?? state.editor.tools.chromaKey.color,
|
||||||
|
tolerance: clampNumber(payload.tolerance ?? state.editor.tools.chromaKey.tolerance, 0, 255),
|
||||||
|
softness: clampNumber(payload.softness ?? state.editor.tools.chromaKey.softness, 0, 255),
|
||||||
|
feather: clampNumber(payload.feather ?? state.editor.tools.chromaKey.feather, 0, 20),
|
||||||
|
choke: clampNumber(payload.choke ?? state.editor.tools.chromaKey.choke, -20, 20),
|
||||||
|
despeckle: clampNumber(payload.despeckle ?? state.editor.tools.chromaKey.despeckle, 0, 20),
|
||||||
|
spill: clampNumber(payload.spill ?? state.editor.tools.chromaKey.spill, 0, 100),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const toolSetBrushPreviewCommand: Command<ToolSetBrushPreviewPayload> = {
|
export const toolSetBrushPreviewCommand: Command<ToolSetBrushPreviewPayload> = {
|
||||||
id: commandIds.toolSetBrushPreview,
|
id: commandIds.toolSetBrushPreview,
|
||||||
name: "Set brush preview",
|
name: "Set brush preview",
|
||||||
@@ -222,6 +249,7 @@ export const toolExitTemporaryPanCommand: Command = {
|
|||||||
export const toolCommands = [
|
export const toolCommands = [
|
||||||
toolSetActiveCommand,
|
toolSetActiveCommand,
|
||||||
toolSetBrushSettingsCommand,
|
toolSetBrushSettingsCommand,
|
||||||
|
toolSetChromaKeySettingsCommand,
|
||||||
toolSetBrushPreviewCommand,
|
toolSetBrushPreviewCommand,
|
||||||
toolSetBrushStrokePreviewCommand,
|
toolSetBrushStrokePreviewCommand,
|
||||||
toolSetMaskViewModeCommand,
|
toolSetMaskViewModeCommand,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export type { AppState, EditorState, SelectionState, ViewportState } from "./state";
|
export type { AppState, EditorState, SelectionState, ViewportState } from "./state";
|
||||||
export type { InteractionMode, ToolId, ToolState } from "./tools";
|
export type { BrushSettings, ChromaKeySettings, InteractionMode, ToolId, ToolState } from "./tools";
|
||||||
export { initialToolState } from "./tools";
|
export { initialToolState } from "./tools";
|
||||||
export { createInitialAppState, initialEditorState } from "./initial-state";
|
export { createInitialAppState, initialEditorState } from "./initial-state";
|
||||||
export type { AppStore, StateListener } from "./store";
|
export type { AppStore, StateListener } from "./store";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export const availableToolIds = ["select", "brush", "eraser", "pan"] as const;
|
export const availableToolIds = ["select", "brush", "eraser", "chromaKey", "pan"] as const;
|
||||||
|
|
||||||
export type ToolId = (typeof availableToolIds)[number];
|
export type ToolId = (typeof availableToolIds)[number];
|
||||||
|
|
||||||
@@ -12,16 +12,28 @@ export type BrushSettings = {
|
|||||||
hardness: number;
|
hardness: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ChromaKeySettings = {
|
||||||
|
color: string;
|
||||||
|
tolerance: number;
|
||||||
|
softness: number;
|
||||||
|
feather: number;
|
||||||
|
choke: number;
|
||||||
|
despeckle: number;
|
||||||
|
spill: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type ToolState = {
|
export type ToolState = {
|
||||||
activeTool: ToolId;
|
activeTool: ToolId;
|
||||||
interactionMode: InteractionMode;
|
interactionMode: InteractionMode;
|
||||||
brush: BrushSettings;
|
brush: BrushSettings;
|
||||||
|
chromaKey: ChromaKeySettings;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const initialToolState: ToolState = {
|
export const initialToolState: ToolState = {
|
||||||
activeTool: "select",
|
activeTool: "select",
|
||||||
interactionMode: { type: "tool", tool: "select" },
|
interactionMode: { type: "tool", tool: "select" },
|
||||||
brush: { color: "#111827", size: 8, hardness: 100 },
|
brush: { color: "#111827", size: 8, hardness: 100 },
|
||||||
|
chromaKey: { color: "#00ff00", tolerance: 32, softness: 24, feather: 0, choke: 0, despeckle: 0, spill: 50 },
|
||||||
};
|
};
|
||||||
|
|
||||||
export function isPanInteractionMode(interactionMode: InteractionMode): boolean {
|
export function isPanInteractionMode(interactionMode: InteractionMode): boolean {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { KeybindEvent } from "./keyboard";
|
|||||||
const toolKeybinds = {
|
const toolKeybinds = {
|
||||||
b: "brush",
|
b: "brush",
|
||||||
e: "eraser",
|
e: "eraser",
|
||||||
|
k: "chromaKey",
|
||||||
p: "pan",
|
p: "pan",
|
||||||
s: "select",
|
s: "select",
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import type { PointerInputEvent } from "./pointer";
|
|||||||
|
|
||||||
type TransformHandle = "body" | "nw" | "n" | "ne" | "e" | "se" | "s" | "sw" | "w";
|
type TransformHandle = "body" | "nw" | "n" | "ne" | "e" | "se" | "s" | "sw" | "w";
|
||||||
|
|
||||||
type InputToolId = "select" | "brush" | "eraser" | "pan";
|
type InputToolId = "select" | "brush" | "eraser" | "chromaKey" | "pan";
|
||||||
|
|
||||||
type InputInteractionMode =
|
type InputInteractionMode =
|
||||||
| { type: "tool"; tool: InputToolId }
|
| { type: "tool"; tool: InputToolId }
|
||||||
|
|||||||
@@ -112,11 +112,14 @@ export function App({ app }: AppProps) {
|
|||||||
/>
|
/>
|
||||||
<div className="absolute inset-x-0 bottom-4 z-10 flex justify-center">
|
<div className="absolute inset-x-0 bottom-4 z-10 flex justify-center">
|
||||||
<BottomControlsIsland
|
<BottomControlsIsland
|
||||||
|
document={state.document}
|
||||||
|
selection={state.editor.selection}
|
||||||
viewport={state.editor.viewport}
|
viewport={state.editor.viewport}
|
||||||
visible={state.editor.tools.activeTool === "brush" || state.editor.tools.activeTool === "eraser" || Boolean(transformBounds) || viewportActivityIsland.visible}
|
visible={state.editor.tools.activeTool === "brush" || state.editor.tools.activeTool === "eraser" || state.editor.tools.activeTool === "chromaKey" || Boolean(transformBounds) || viewportActivityIsland.visible}
|
||||||
action={viewportActivityIsland.action}
|
action={viewportActivityIsland.action}
|
||||||
activeTool={state.editor.tools.activeTool}
|
activeTool={state.editor.tools.activeTool}
|
||||||
brushSettings={state.editor.tools.brush}
|
brushSettings={state.editor.tools.brush}
|
||||||
|
chromaKeySettings={state.editor.tools.chromaKey}
|
||||||
editingMask={Boolean(state.editor.maskEdit)}
|
editingMask={Boolean(state.editor.maskEdit)}
|
||||||
maskViewMode={state.editor.maskEdit?.viewMode ?? "composite"}
|
maskViewMode={state.editor.maskEdit?.viewMode ?? "composite"}
|
||||||
brushHint={brushHint}
|
brushHint={brushHint}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { AppStore } from "@editor/store";
|
import type { AppStore } from "@editor/store";
|
||||||
import type { MaskViewMode, ViewportState } from "@editor/state";
|
import type { ImageDocument } from "@core/document";
|
||||||
import type { BrushSettings, ToolId } from "@editor/tools";
|
import type { MaskViewMode, SelectionState, ViewportState } from "@editor/state";
|
||||||
|
import type { BrushSettings, ChromaKeySettings, ToolId } from "@editor/tools";
|
||||||
import { BrushControls } from "./bottom-controls/BrushControls";
|
import { BrushControls } from "./bottom-controls/BrushControls";
|
||||||
|
import { ChromaKeyControls } from "./bottom-controls/ChromaKeyControls";
|
||||||
import { PanControls } from "./bottom-controls/PanControls";
|
import { PanControls } from "./bottom-controls/PanControls";
|
||||||
import { TransformControls } from "./bottom-controls/TransformControls";
|
import { TransformControls } from "./bottom-controls/TransformControls";
|
||||||
import { ZoomControls } from "./bottom-controls/ZoomControls";
|
import { ZoomControls } from "./bottom-controls/ZoomControls";
|
||||||
@@ -10,11 +12,14 @@ import type { TransformTarget } from "@editor/transform";
|
|||||||
export type BottomControlsAction = "pan" | "zoom";
|
export type BottomControlsAction = "pan" | "zoom";
|
||||||
|
|
||||||
export type BottomControlsIslandProps = {
|
export type BottomControlsIslandProps = {
|
||||||
|
document: ImageDocument;
|
||||||
|
selection: SelectionState;
|
||||||
viewport: ViewportState;
|
viewport: ViewportState;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
action: BottomControlsAction;
|
action: BottomControlsAction;
|
||||||
activeTool: ToolId;
|
activeTool: ToolId;
|
||||||
brushSettings: BrushSettings;
|
brushSettings: BrushSettings;
|
||||||
|
chromaKeySettings: ChromaKeySettings;
|
||||||
editingMask?: boolean;
|
editingMask?: boolean;
|
||||||
maskViewMode?: MaskViewMode;
|
maskViewMode?: MaskViewMode;
|
||||||
transformBounds?: Rect;
|
transformBounds?: Rect;
|
||||||
@@ -23,7 +28,7 @@ export type BottomControlsIslandProps = {
|
|||||||
dispatch: AppStore["dispatch"];
|
dispatch: AppStore["dispatch"];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function BottomControlsIsland({ viewport, visible, action, activeTool, brushSettings, editingMask = false, maskViewMode = "composite", transformBounds, transformTarget, brushHint, dispatch }: BottomControlsIslandProps) {
|
export function BottomControlsIsland({ document, selection, viewport, visible, action, activeTool, brushSettings, chromaKeySettings, editingMask = false, maskViewMode = "composite", transformBounds, transformTarget, brushHint, dispatch }: BottomControlsIslandProps) {
|
||||||
const zoomPercent = Math.round(viewport.zoom * 100);
|
const zoomPercent = Math.round(viewport.zoom * 100);
|
||||||
const x = Math.round(viewport.center.x);
|
const x = Math.round(viewport.center.x);
|
||||||
const y = Math.round(viewport.center.y);
|
const y = Math.round(viewport.center.y);
|
||||||
@@ -39,6 +44,8 @@ export function BottomControlsIsland({ viewport, visible, action, activeTool, br
|
|||||||
<BrushHint tool={activeTool} hint={brushHint} />
|
<BrushHint tool={activeTool} hint={brushHint} />
|
||||||
) : activeTool === "brush" || activeTool === "eraser" ? (
|
) : activeTool === "brush" || activeTool === "eraser" ? (
|
||||||
<BrushControls tool={activeTool} settings={brushSettings} editingMask={editingMask} maskViewMode={maskViewMode} dispatch={dispatch} />
|
<BrushControls tool={activeTool} settings={brushSettings} editingMask={editingMask} maskViewMode={maskViewMode} dispatch={dispatch} />
|
||||||
|
) : activeTool === "chromaKey" ? (
|
||||||
|
<ChromaKeyControls document={document} selection={selection} settings={chromaKeySettings} dispatch={dispatch} />
|
||||||
) : transformBounds && transformTarget ? (
|
) : transformBounds && transformTarget ? (
|
||||||
<TransformControls bounds={transformBounds} target={transformTarget} dispatch={dispatch} />
|
<TransformControls bounds={transformBounds} target={transformTarget} dispatch={dispatch} />
|
||||||
) : action === "pan" ? (
|
) : action === "pan" ? (
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const shortcuts: Shortcut[] = [
|
|||||||
{ keys: ["S"], label: "Select" },
|
{ keys: ["S"], label: "Select" },
|
||||||
{ keys: ["B"], label: "Brush" },
|
{ keys: ["B"], label: "Brush" },
|
||||||
{ keys: ["E"], label: "Eraser" },
|
{ keys: ["E"], label: "Eraser" },
|
||||||
|
{ keys: ["K"], label: "Chroma key" },
|
||||||
{ keys: ["P"], label: "Pan" },
|
{ keys: ["P"], label: "Pan" },
|
||||||
{ keys: ["Space"], label: "Hold to pan" },
|
{ keys: ["Space"], label: "Hold to pan" },
|
||||||
{ keys: ["L"], label: "Layers" },
|
{ keys: ["L"], label: "Layers" },
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Cursor, Eraser, Hand, PaintBrush } from "@phosphor-icons/react";
|
import { Cursor, Eraser, Hand, PaintBrush, DropHalf } from "@phosphor-icons/react";
|
||||||
import { commandIds } from "@commands/ids";
|
import { commandIds } from "@commands/ids";
|
||||||
import type { AppStore } from "@editor/store";
|
import type { AppStore } from "@editor/store";
|
||||||
import type { InteractionMode, ToolId } from "@editor/tools";
|
import type { InteractionMode, ToolId } from "@editor/tools";
|
||||||
@@ -45,6 +45,8 @@ function iconForTool(tool: ToolId) {
|
|||||||
return PaintBrush;
|
return PaintBrush;
|
||||||
case "eraser":
|
case "eraser":
|
||||||
return Eraser;
|
return Eraser;
|
||||||
|
case "chromaKey":
|
||||||
|
return DropHalf;
|
||||||
case "pan":
|
case "pan":
|
||||||
return Hand;
|
return Hand;
|
||||||
case "select":
|
case "select":
|
||||||
|
|||||||
382
view/bottom-controls/ChromaKeyControls.tsx
Normal file
382
view/bottom-controls/ChromaKeyControls.tsx
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { DropHalf } from "@phosphor-icons/react";
|
||||||
|
import { commandIds } from "@commands/ids";
|
||||||
|
import type { ImageDocument } from "@core/document";
|
||||||
|
import type { Layer } from "@core/layer";
|
||||||
|
import { resolveTransformTargetBounds } from "@editor/transform-targets";
|
||||||
|
import type { AppStore } from "@editor/store";
|
||||||
|
import type { ChromaKeySettings } from "@editor/tools";
|
||||||
|
import type { SelectionState } from "@editor/state";
|
||||||
|
import { BottomControlColorPicker } from "./ColorPicker";
|
||||||
|
import { BottomControlDivider } from "./Divider";
|
||||||
|
import { BottomControlSlider } from "./Slider";
|
||||||
|
import { bottomControlFieldClass, bottomControlIconSlotClass, bottomControlLabelClass, bottomControlMenuClass } from "./styles";
|
||||||
|
|
||||||
|
export type ChromaKeyControlsProps = {
|
||||||
|
document: ImageDocument;
|
||||||
|
selection: SelectionState;
|
||||||
|
settings: ChromaKeySettings;
|
||||||
|
dispatch: AppStore["dispatch"];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ChromaKeyControls({ document, selection, settings, dispatch }: ChromaKeyControlsProps) {
|
||||||
|
const target = resolveChromaKeyTarget(document, selection);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
dispatch(commandIds.toolSetBrushStrokePreview, undefined);
|
||||||
|
};
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
dispatch(commandIds.toolSetBrushStrokePreview, undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void chromaKeySource(target.asset.source, target.asset.intrinsicSize.w, target.asset.intrinsicSize.h, settings).then((source) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
dispatch(commandIds.toolSetBrushStrokePreview, { layerId: target.layer.id, assetId: target.asset.id, source });
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [dispatch, settings.choke, settings.color, settings.despeckle, settings.feather, settings.softness, settings.spill, settings.tolerance, target?.asset.id, target?.asset.source, target?.asset.intrinsicSize.w, target?.asset.intrinsicSize.h, target?.layer.id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={bottomControlMenuClass()}>
|
||||||
|
<span className={bottomControlIconSlotClass()} title="Chroma key">
|
||||||
|
<DropHalf size={24} weight="regular" />
|
||||||
|
</span>
|
||||||
|
<BottomControlDivider />
|
||||||
|
<label className={bottomControlFieldClass()}>
|
||||||
|
<span className={bottomControlLabelClass()}>Key</span>
|
||||||
|
<BottomControlColorPicker
|
||||||
|
value={settings.color}
|
||||||
|
aria-label="Chroma key color"
|
||||||
|
onValueChange={(color) => dispatch(commandIds.toolSetChromaKeySettings, { color })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<BottomControlDivider />
|
||||||
|
<label className={bottomControlFieldClass()}>
|
||||||
|
<span className={bottomControlLabelClass()}>Tol</span>
|
||||||
|
<BottomControlSlider
|
||||||
|
min={0}
|
||||||
|
max={255}
|
||||||
|
value={settings.tolerance}
|
||||||
|
className="w-36"
|
||||||
|
aria-label="Chroma key tolerance"
|
||||||
|
onValueChange={(tolerance) => dispatch(commandIds.toolSetChromaKeySettings, { tolerance })}
|
||||||
|
/>
|
||||||
|
<span className="w-10 text-right text-base text-white">{Math.round(settings.tolerance)}</span>
|
||||||
|
</label>
|
||||||
|
<BottomControlDivider />
|
||||||
|
<label className={bottomControlFieldClass()}>
|
||||||
|
<span className={bottomControlLabelClass()}>Soft</span>
|
||||||
|
<BottomControlSlider
|
||||||
|
min={0}
|
||||||
|
max={255}
|
||||||
|
value={settings.softness}
|
||||||
|
className="w-36"
|
||||||
|
aria-label="Chroma key edge softness"
|
||||||
|
onValueChange={(softness) => dispatch(commandIds.toolSetChromaKeySettings, { softness })}
|
||||||
|
/>
|
||||||
|
<span className="w-10 text-right text-base text-white">{Math.round(settings.softness)}</span>
|
||||||
|
</label>
|
||||||
|
<BottomControlDivider />
|
||||||
|
<label className={bottomControlFieldClass()}>
|
||||||
|
<span className={bottomControlLabelClass()}>Feather</span>
|
||||||
|
<BottomControlSlider
|
||||||
|
min={0}
|
||||||
|
max={20}
|
||||||
|
value={settings.feather}
|
||||||
|
className="w-32"
|
||||||
|
aria-label="Chroma key mask feather"
|
||||||
|
onValueChange={(feather) => dispatch(commandIds.toolSetChromaKeySettings, { feather })}
|
||||||
|
/>
|
||||||
|
<span className="w-8 text-right text-base text-white">{Math.round(settings.feather)}</span>
|
||||||
|
</label>
|
||||||
|
<BottomControlDivider />
|
||||||
|
<label className={bottomControlFieldClass()}>
|
||||||
|
<span className={bottomControlLabelClass()}>Choke</span>
|
||||||
|
<BottomControlSlider
|
||||||
|
min={-20}
|
||||||
|
max={20}
|
||||||
|
value={settings.choke}
|
||||||
|
className="w-32"
|
||||||
|
aria-label="Chroma key mask choke or expand"
|
||||||
|
onValueChange={(choke) => dispatch(commandIds.toolSetChromaKeySettings, { choke })}
|
||||||
|
/>
|
||||||
|
<span className="w-8 text-right text-base text-white">{Math.round(settings.choke)}</span>
|
||||||
|
</label>
|
||||||
|
<BottomControlDivider />
|
||||||
|
<label className={bottomControlFieldClass()}>
|
||||||
|
<span className={bottomControlLabelClass()}>Clean</span>
|
||||||
|
<BottomControlSlider
|
||||||
|
min={0}
|
||||||
|
max={20}
|
||||||
|
value={settings.despeckle}
|
||||||
|
className="w-32"
|
||||||
|
aria-label="Chroma key mask despeckle"
|
||||||
|
onValueChange={(despeckle) => dispatch(commandIds.toolSetChromaKeySettings, { despeckle })}
|
||||||
|
/>
|
||||||
|
<span className="w-8 text-right text-base text-white">{Math.round(settings.despeckle)}</span>
|
||||||
|
</label>
|
||||||
|
<BottomControlDivider />
|
||||||
|
<label className={bottomControlFieldClass()}>
|
||||||
|
<span className={bottomControlLabelClass()}>Spill</span>
|
||||||
|
<BottomControlSlider
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={settings.spill}
|
||||||
|
className="w-36"
|
||||||
|
aria-label="Chroma key spill suppression"
|
||||||
|
onValueChange={(spill) => dispatch(commandIds.toolSetChromaKeySettings, { spill })}
|
||||||
|
/>
|
||||||
|
<span className="w-10 text-right text-base text-white">{Math.round(settings.spill)}</span>
|
||||||
|
</label>
|
||||||
|
<BottomControlDivider />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-full bg-white px-5 py-2 text-base font-medium text-black transition hover:bg-white/90 disabled:pointer-events-none disabled:opacity-35"
|
||||||
|
disabled={!target}
|
||||||
|
title={target ? "Create or update a layer mask from the chroma key" : "Select one image or raster layer to create a chroma key mask"}
|
||||||
|
onClick={() => target && void applyChromaKeyMask(target, settings, dispatch)}
|
||||||
|
>
|
||||||
|
{target?.maskAsset ? "Update Mask" : "Create Mask"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveChromaKeyTarget(document: ImageDocument, selection: SelectionState) {
|
||||||
|
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;
|
||||||
|
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
|
||||||
|
const bounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.id });
|
||||||
|
const maskLayer = layer.clippingMask ? findLayer(document.artboards.flatMap((artboard) => artboard.layers), layer.clippingMask.maskLayerId) : undefined;
|
||||||
|
const maskAsset = maskLayer && maskLayer.type !== "group" ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined;
|
||||||
|
return asset && bounds ? { layer, asset, bounds, maskLayer, maskAsset } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findLayer(layers: readonly Layer[], layerId: string): Layer | undefined {
|
||||||
|
for (const layer of layers) {
|
||||||
|
if (layer.id === layerId) return layer;
|
||||||
|
if (layer.type === "group") {
|
||||||
|
const found = findLayer(layer.children, layerId);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyChromaKeyMask(target: NonNullable<ReturnType<typeof resolveChromaKeyTarget>>, settings: ChromaKeySettings, dispatch: AppStore["dispatch"]) {
|
||||||
|
const source = await chromaKeyMaskSource(target.asset.source, target.asset.intrinsicSize.w, target.asset.intrinsicSize.h, settings);
|
||||||
|
dispatch(commandIds.toolSetBrushStrokePreview, undefined);
|
||||||
|
|
||||||
|
if (target.maskAsset) {
|
||||||
|
dispatch(commandIds.documentUpdateAssetSource, { assetId: target.maskAsset.id, source });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const assetId = crypto.randomUUID();
|
||||||
|
const maskLayerId = crypto.randomUUID();
|
||||||
|
const width = Math.max(1, Math.round(target.asset.intrinsicSize.w));
|
||||||
|
const height = Math.max(1, Math.round(target.asset.intrinsicSize.h));
|
||||||
|
dispatch(commandIds.documentAddLayerMask, {
|
||||||
|
layerId: target.layer.id,
|
||||||
|
asset: {
|
||||||
|
id: assetId,
|
||||||
|
name: `${target.layer.name} Chroma Mask`,
|
||||||
|
mimeType: "image/png",
|
||||||
|
source,
|
||||||
|
intrinsicSize: { w: width, h: height },
|
||||||
|
},
|
||||||
|
maskLayer: {
|
||||||
|
id: maskLayerId,
|
||||||
|
type: "raster",
|
||||||
|
name: `${target.layer.name} Chroma Mask`,
|
||||||
|
visible: true,
|
||||||
|
locked: false,
|
||||||
|
opacity: 1,
|
||||||
|
assetId,
|
||||||
|
transform: {
|
||||||
|
position: { x: target.bounds.x, y: target.bounds.y },
|
||||||
|
scale: { x: target.bounds.w / width, y: target.bounds.h / height },
|
||||||
|
rotation: target.layer.transform.rotation,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
dispatch(commandIds.toolExitMaskEdit, undefined);
|
||||||
|
dispatch(commandIds.toolSetActive, { tool: "chromaKey" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chromaKeySource(source: string, width: number, height: number, settings: ChromaKeySettings) {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = Math.max(1, Math.round(width));
|
||||||
|
canvas.height = Math.max(1, Math.round(height));
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
if (!context) return source;
|
||||||
|
const image = await loadImage(source);
|
||||||
|
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||||
|
const data = context.getImageData(0, 0, canvas.width, canvas.height);
|
||||||
|
const alpha = chromaKeyAlpha(data, canvas.width, canvas.height, settings);
|
||||||
|
for (let pixel = 0; pixel < alpha.length; pixel++) data.data[pixel * 4 + 3] = alpha[pixel] ?? 255;
|
||||||
|
context.putImageData(data, 0, 0);
|
||||||
|
return canvas.toDataURL("image/png");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chromaKeyMaskSource(source: string, width: number, height: number, settings: ChromaKeySettings) {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = Math.max(1, Math.round(width));
|
||||||
|
canvas.height = Math.max(1, Math.round(height));
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
if (!context) return source;
|
||||||
|
const image = await loadImage(source);
|
||||||
|
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||||
|
const data = context.getImageData(0, 0, canvas.width, canvas.height);
|
||||||
|
const alpha = chromaKeyAlpha(data, canvas.width, canvas.height, settings);
|
||||||
|
|
||||||
|
for (let pixel = 0; pixel < alpha.length; pixel++) {
|
||||||
|
const index = pixel * 4;
|
||||||
|
data.data[index] = 255;
|
||||||
|
data.data[index + 1] = 255;
|
||||||
|
data.data[index + 2] = 255;
|
||||||
|
data.data[index + 3] = alpha[pixel] ?? 255;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.putImageData(data, 0, 0);
|
||||||
|
return canvas.toDataURL("image/png");
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToRgb(color: string) {
|
||||||
|
const hex = color.replace("#", "");
|
||||||
|
return { r: Number.parseInt(hex.slice(0, 2), 16), g: Number.parseInt(hex.slice(2, 4), 16), b: Number.parseInt(hex.slice(4, 6), 16) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function chromaKeyAlpha(data: ImageData, width: number, height: number, settings: ChromaKeySettings) {
|
||||||
|
const key = hexToRgb(settings.color);
|
||||||
|
const alpha = new Uint8ClampedArray(width * height);
|
||||||
|
for (let pixel = 0; pixel < alpha.length; pixel++) {
|
||||||
|
const index = pixel * 4;
|
||||||
|
const red = data.data[index] ?? 0;
|
||||||
|
const green = data.data[index + 1] ?? 0;
|
||||||
|
const blue = data.data[index + 2] ?? 0;
|
||||||
|
const sourceAlpha = data.data[index + 3] ?? 255;
|
||||||
|
alpha[pixel] = Math.round(sourceAlpha * chromaKeyKeepFactor(red, green, blue, key, settings));
|
||||||
|
}
|
||||||
|
return postProcessAlpha(alpha, width, height, settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
function chromaKeyKeepFactor(red: number, green: number, blue: number, key: { r: number; g: number; b: number }, settings: ChromaKeySettings) {
|
||||||
|
const tolerance = Math.max(0, Math.min(255, settings.tolerance));
|
||||||
|
const softness = Math.max(0, Math.min(255, settings.softness));
|
||||||
|
const spill = Math.max(0, Math.min(100, settings.spill)) / 100;
|
||||||
|
const distance = Math.hypot(red - key.r, green - key.g, blue - key.b);
|
||||||
|
const edgeKeep = distance <= tolerance ? 0 : softness > 0 && distance < tolerance + softness ? (distance - tolerance) / softness : 1;
|
||||||
|
if (spill <= 0) return edgeKeep;
|
||||||
|
|
||||||
|
const dominant = key.g >= key.r && key.g >= key.b ? green : key.r >= key.b ? red : blue;
|
||||||
|
const neutral = key.g >= key.r && key.g >= key.b ? Math.max(red, blue) : key.r >= key.b ? Math.max(green, blue) : Math.max(red, green);
|
||||||
|
const spillAmount = Math.max(0, dominant - neutral) / 255;
|
||||||
|
return Math.max(0, Math.min(edgeKeep, 1 - spillAmount * spill));
|
||||||
|
}
|
||||||
|
|
||||||
|
function postProcessAlpha(alpha: Uint8ClampedArray, width: number, height: number, settings: ChromaKeySettings) {
|
||||||
|
let next = alpha;
|
||||||
|
const despeckle = Math.round(Math.max(0, Math.min(20, settings.despeckle)));
|
||||||
|
const choke = Math.round(Math.max(-20, Math.min(20, settings.choke)));
|
||||||
|
const feather = Math.round(Math.max(0, Math.min(20, settings.feather)));
|
||||||
|
|
||||||
|
if (despeckle > 0) next = despeckleAlpha(next, width, height, despeckle);
|
||||||
|
if (choke > 0) next = erodeAlpha(next, width, height, choke);
|
||||||
|
if (choke < 0) next = dilateAlpha(next, width, height, -choke);
|
||||||
|
if (feather > 0) next = blurAlpha(next, width, height, feather);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function despeckleAlpha(alpha: Uint8ClampedArray, width: number, height: number, strength: number) {
|
||||||
|
const radius = Math.max(1, Math.ceil(strength / 6));
|
||||||
|
const threshold = Math.max(1, Math.round(strength / 2));
|
||||||
|
const next = new Uint8ClampedArray(alpha);
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
const index = y * width + x;
|
||||||
|
const visible = (alpha[index] ?? 0) > 127;
|
||||||
|
let same = 0;
|
||||||
|
for (let oy = -radius; oy <= radius; oy++) {
|
||||||
|
for (let ox = -radius; ox <= radius; ox++) {
|
||||||
|
if (ox === 0 && oy === 0) continue;
|
||||||
|
const sample = alpha[clamp(y + oy, 0, height - 1) * width + clamp(x + ox, 0, width - 1)] ?? 0;
|
||||||
|
if ((sample > 127) === visible) same += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (same <= threshold) next[index] = visible ? 0 : 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function erodeAlpha(alpha: Uint8ClampedArray, width: number, height: number, radius: number) {
|
||||||
|
const next = new Uint8ClampedArray(alpha.length);
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
let value = 255;
|
||||||
|
for (let oy = -radius; oy <= radius; oy++) {
|
||||||
|
for (let ox = -radius; ox <= radius; ox++) value = Math.min(value, alpha[clamp(y + oy, 0, height - 1) * width + clamp(x + ox, 0, width - 1)] ?? 0);
|
||||||
|
}
|
||||||
|
next[y * width + x] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dilateAlpha(alpha: Uint8ClampedArray, width: number, height: number, radius: number) {
|
||||||
|
const next = new Uint8ClampedArray(alpha.length);
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
let value = 0;
|
||||||
|
for (let oy = -radius; oy <= radius; oy++) {
|
||||||
|
for (let ox = -radius; ox <= radius; ox++) value = Math.max(value, alpha[clamp(y + oy, 0, height - 1) * width + clamp(x + ox, 0, width - 1)] ?? 0);
|
||||||
|
}
|
||||||
|
next[y * width + x] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function blurAlpha(alpha: Uint8ClampedArray, width: number, height: number, radius: number) {
|
||||||
|
const next = new Uint8ClampedArray(alpha.length);
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
let total = 0;
|
||||||
|
let count = 0;
|
||||||
|
for (let oy = -radius; oy <= radius; oy++) {
|
||||||
|
for (let ox = -radius; ox <= radius; ox++) {
|
||||||
|
total += alpha[clamp(y + oy, 0, height - 1) * width + clamp(x + ox, 0, width - 1)] ?? 0;
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next[y * width + x] = Math.round(total / count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number) {
|
||||||
|
return Math.max(min, Math.min(max, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadImage(source: string) {
|
||||||
|
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||||
|
const image = new Image();
|
||||||
|
image.onload = () => resolve(image);
|
||||||
|
image.onerror = () => reject(new Error("Failed to load image"));
|
||||||
|
image.src = source;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -9,5 +9,6 @@ export function canvasCursorClass(interactionMode: InteractionMode, input: Canva
|
|||||||
if (!canBrush) return "cursor-not-allowed";
|
if (!canBrush) return "cursor-not-allowed";
|
||||||
return hasBrushPreview ? "cursor-none" : "cursor-crosshair";
|
return hasBrushPreview ? "cursor-none" : "cursor-crosshair";
|
||||||
}
|
}
|
||||||
|
if (interactionMode.type === "tool" && interactionMode.tool === "chromaKey") return "cursor-crosshair";
|
||||||
return "cursor-default";
|
return "cursor-default";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ export function labelForTool(tool: ToolId): string {
|
|||||||
switch (tool) {
|
switch (tool) {
|
||||||
case "brush":
|
case "brush":
|
||||||
return "Brush";
|
return "Brush";
|
||||||
|
case "chromaKey":
|
||||||
|
return "Chroma key";
|
||||||
case "eraser":
|
case "eraser":
|
||||||
return "Eraser";
|
return "Eraser";
|
||||||
case "pan":
|
case "pan":
|
||||||
|
|||||||
Reference in New Issue
Block a user